StemRoller: desktop stem separator, Android stems player, and server daemons
- stemroller/: PySide6/QML desktop app and player for separating music into stems - android/: Plex music player that plays per-stem mixes, with Android Auto support, a graphic + parametric (Equalizer APO-style) EQ, and offline downloads - server/: stemserve (serves stems to the app) and stemd (separates the library on the server's GPU, with "Stem Now" priority requests) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
716c5499ff
commit
1845f03c41
184 files changed
+24648
No files matched your search
@@ -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
|
||||
+18
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+10
@@ -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(...);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Debug builds only: lets adb grab a software render of the UI (emulator screencap is black here). -->
|
||||
<provider
|
||||
android:name=".DebugCapture"
|
||||
android:authorities="${applicationId}.debugcapture"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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<Activity>(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<String>?, s: String?, a: Array<String>?, 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<String>?) = 0
|
||||
override fun update(u: android.net.Uri, v: ContentValues?, s: String?, a: Array<String>?) = 0
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.StemRoller"
|
||||
tools:targetApi="36">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|uiMode|keyboard|keyboardHidden|navigation"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
tools:ignore="DiscouragedApi,LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".playback.PlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Album art for Android Auto, which only loads content:// artwork. Read-only. -->
|
||||
<provider
|
||||
android:name=".auto.CarArtProvider"
|
||||
android:authorities="com.stemroller.mobile.carart"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<service
|
||||
android:name=".cache.DownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MediaController>? = 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"
|
||||
}
|
||||
}
|
||||
@@ -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<out String>?, s: String?, a: Array<out String>?, o: String?) = null
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
override fun delete(uri: Uri, s: String?, a: Array<out String>?) = 0
|
||||
override fun update(uri: Uri, v: ContentValues?, s: String?, a: Array<out String>?) = 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) }
|
||||
}
|
||||
}
|
||||
@@ -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<Track>, val start: Int = 0, val shuffle: Boolean = false)
|
||||
|
||||
/**
|
||||
* The browse tree Android Auto draws. Auto renders everything itself, so "designing for the
|
||||
* Miata" means shaping this tree for an 800×480 screen driven only by the commander knob:
|
||||
* four tabs, shallow folders, Shuffle as the first row, A–Z folders instead of long lists,
|
||||
* and grids only where the art identifies the item (albums).
|
||||
*
|
||||
* Away from home (Plex and stemserve unreachable) everything comes from the HTTP cache and
|
||||
* only songs that will actually play offline are listed, with Downloads as the first tab.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class CarLibrary(private val g: Graph) {
|
||||
private val plex get() = g.carPlex
|
||||
private val pm get() = g.playback
|
||||
|
||||
private class Cached(val at: Long, val tracks: List<Track>)
|
||||
private val trackCache = object : LinkedHashMap<String, Cached>(16, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Cached>) = size > 24
|
||||
}
|
||||
|
||||
private suspend fun away() = !g.reach.reachable()
|
||||
|
||||
// ---- tree -----------------------------------------------------------------------
|
||||
|
||||
suspend fun root(limit: Int): List<MediaItem> {
|
||||
val tabs = listOf(
|
||||
tab(CarIds.PLAYLISTS, "Playlists", MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM),
|
||||
tab(CarIds.ALBUMS, "Albums", MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM),
|
||||
tab(CarIds.ARTISTS, "Artists", MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM),
|
||||
tab(CarIds.DOWNLOADS, "Downloads", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM),
|
||||
)
|
||||
val ordered = if (away()) listOf(tabs[3]) + tabs.take(3) else tabs
|
||||
return ordered.take(limit.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
/** Children of [id], or null if the id means nothing to us. */
|
||||
suspend fun children(id: String): List<MediaItem>? = withContext(Dispatchers.IO) {
|
||||
val away = away()
|
||||
when (id) {
|
||||
CarIds.DOWNLOADS -> songs(CarIds.DOWNLOADS, tracksOf(CarIds.DOWNLOADS), shuffleRow = true, showArtist = true)
|
||||
CarIds.PLAYLISTS -> playlists(away)
|
||||
CarIds.ALBUMS -> albumsTab(away)
|
||||
CarIds.ARTISTS -> artistsTab(away)
|
||||
else -> {
|
||||
val (kind, key) = CarIds.split(id) ?: return@withContext null
|
||||
when (kind) {
|
||||
"playlist" -> songs(id, tracksOf(id), shuffleRow = true, showArtist = true)
|
||||
"album" -> songs(id, tracksOf(id), shuffleRow = false, showArtist = false)
|
||||
"artist" -> artistPage(key, away)
|
||||
"albums" -> albumsFor(away).filter { CarTree.letterOf(it.title) == key }.map(::albumItem)
|
||||
"artists" -> artistsFor(away).filter { CarTree.letterOf(it.sortTitle) == key }.map(::artistItem)
|
||||
"search" -> searchItems(key)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun playlists(away: Boolean): List<MediaItem> {
|
||||
val all = plex.playlists()
|
||||
val shown = if (!away) all else all.filter { p -> runCatching { tracksOf(CarIds.playlist(p.ratingKey)).isNotEmpty() }.getOrDefault(false) }
|
||||
return shown.map(::playlistItem)
|
||||
}
|
||||
|
||||
private suspend fun albumsTab(away: Boolean): List<MediaItem> {
|
||||
val albums = albumsFor(away)
|
||||
if (away || albums.size <= CarTree.FLAT_MAX) return albums.map(::albumItem)
|
||||
val recent = runCatching { plex.recentlyAdded(CarTree.RECENT_ALBUMS) }.getOrDefault(emptyList())
|
||||
return recent.map { albumItem(it, group = "Recently added") } +
|
||||
CarTree.buckets(albums) { it.title }.map { (letter, list) ->
|
||||
folder(CarIds.albumLetter(letter), letter, CarTree.countLabel(list.size, "album", "albums"),
|
||||
MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, art = CarArtProvider.letterUri(letter), childStyle = MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, group = "All albums")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun artistsTab(away: Boolean): List<MediaItem> {
|
||||
val artists = artistsFor(away)
|
||||
if (artists.size <= CarTree.FLAT_MAX) return artists.map(::artistItem)
|
||||
return CarTree.buckets(artists) { it.sortTitle }.map { (letter, list) ->
|
||||
folder(CarIds.artistLetter(letter), letter, CarTree.countLabel(list.size, "artist", "artists"), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS,
|
||||
art = CarArtProvider.letterUri(letter))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun artistPage(key: String, away: Boolean): List<MediaItem> {
|
||||
val id = CarIds.artist(key)
|
||||
val albums = if (away) downloadedTracks().filter { it.artistKey == key }.let(::albumsOf) else plex.artistAlbums(key)
|
||||
val shuffle = if (tracksOf(id).isNotEmpty()) listOf(shuffleItem(id, "Shuffle artist")) else emptyList()
|
||||
return shuffle + albums.map(::albumItem)
|
||||
}
|
||||
|
||||
/** Away from home, the library is what's downloaded. */
|
||||
private suspend fun albumsFor(away: Boolean): List<Album> =
|
||||
if (away) albumsOf(downloadedTracks()).sortedBy { it.title.lowercase() } else plex.albums()
|
||||
|
||||
private suspend fun artistsFor(away: Boolean): List<Artist> =
|
||||
if (away) {
|
||||
downloadedTracks().filter { it.artistKey != null }.distinctBy { it.artistKey }
|
||||
.map { Artist(it.artistKey!!, it.artist, it.artist, null) }.sortedBy { it.sortTitle.lowercase() }
|
||||
} else plex.artists()
|
||||
|
||||
private fun albumsOf(tracks: List<Track>) = tracks.filter { it.albumKey != null }.distinctBy { it.albumKey }
|
||||
.map { Album(it.albumKey!!, it.album, it.artist, it.artistKey, it.year, it.thumb) }
|
||||
|
||||
private fun downloadedTracks(): List<Track> =
|
||||
g.downloads.records.value.values.filter { it.done }.map { it.track }
|
||||
.sortedWith(compareBy({ it.artist.lowercase() }, { it.album.lowercase() }, { it.disc ?: 0 }, { it.index ?: 0 }))
|
||||
|
||||
/** A container's songs, minus those that can't play right now. */
|
||||
private suspend fun tracksOf(container: String): List<Track> {
|
||||
val now = System.nanoTime()
|
||||
val away = away()
|
||||
// Home and away lists differ (away keeps only what plays offline), so they cache apart.
|
||||
val key = "$away|$container"
|
||||
synchronized(trackCache) {
|
||||
trackCache[key]?.takeIf { now - it.at < CACHE_NS && it.tracks.isNotEmpty() }?.let { return it.tracks }
|
||||
}
|
||||
val all = when (container) {
|
||||
CarIds.DOWNLOADS -> downloadedTracks()
|
||||
else -> {
|
||||
val (kind, key) = CarIds.split(container) ?: return emptyList()
|
||||
when (kind) {
|
||||
"playlist" -> plex.playlistTracks(key)
|
||||
"album" -> plex.albumTracks(key)
|
||||
"artist" -> if (away) downloadedTracks().filter { it.artistKey == key } else plex.artistTracks(key)
|
||||
"search" -> searchTracks(key)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
val playable = if (away) all.filter { pm.playableOffline(it) } else all
|
||||
synchronized(trackCache) { trackCache[key] = Cached(now, playable) }
|
||||
return playable
|
||||
}
|
||||
|
||||
// ---- picks -----------------------------------------------------------------------
|
||||
|
||||
/** Turns a tapped row into a queue. */
|
||||
suspend fun resolve(mediaId: String): Resolved? = withContext(Dispatchers.IO) {
|
||||
val pick = CarIds.parsePick(mediaId) ?: return@withContext null
|
||||
val tracks = tracksOf(pick.container)
|
||||
if (tracks.isEmpty()) return@withContext null
|
||||
when (pick) {
|
||||
is CarIds.Pick.Shuffle -> Resolved(tracks, shuffle = true)
|
||||
is CarIds.Pick.Song -> Resolved(tracks, tracks.indexOfFirst { it.ratingKey == pick.trackKey }.coerceAtLeast(0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Hey Google, play … on StemRoller". Prefers a playlist, then an artist (shuffled), then an
|
||||
* album whose name matches; otherwise plays the song hits. Away from home it searches downloads.
|
||||
*/
|
||||
suspend fun resolveSearch(query: String): Resolved? = withContext(Dispatchers.IO) {
|
||||
if (query.isBlank()) {
|
||||
val dl = tracksOf(CarIds.DOWNLOADS)
|
||||
return@withContext if (dl.isEmpty()) null else Resolved(dl, shuffle = true)
|
||||
}
|
||||
if (away()) {
|
||||
val hits = offlineSearch(query)
|
||||
return@withContext if (hits.isEmpty()) null else Resolved(hits)
|
||||
}
|
||||
val r = plex.search(query)
|
||||
r.playlists.firstOrNull { CarTree.matches(it.title, query) }?.let { p ->
|
||||
tracksOf(CarIds.playlist(p.ratingKey)).takeIf { it.isNotEmpty() }?.let { return@withContext Resolved(it) }
|
||||
}
|
||||
r.artists.firstOrNull { CarTree.matches(it.title, query) }?.let { a ->
|
||||
tracksOf(CarIds.artist(a.ratingKey)).takeIf { it.isNotEmpty() }?.let { return@withContext Resolved(it, shuffle = true) }
|
||||
}
|
||||
r.albums.firstOrNull { CarTree.matches(it.title, query) }?.let { a ->
|
||||
tracksOf(CarIds.album(a.ratingKey)).takeIf { it.isNotEmpty() }?.let { return@withContext Resolved(it) }
|
||||
}
|
||||
if (r.tracks.isNotEmpty()) return@withContext Resolved(r.tracks)
|
||||
r.playlists.firstOrNull()?.let { return@withContext Resolved(tracksOf(CarIds.playlist(it.ratingKey))) }
|
||||
r.artists.firstOrNull()?.let { return@withContext Resolved(tracksOf(CarIds.artist(it.ratingKey)), shuffle = true) }
|
||||
r.albums.firstOrNull()?.let { return@withContext Resolved(tracksOf(CarIds.album(it.ratingKey))) }
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun searchTracks(query: String): List<Track> = if (away()) offlineSearch(query) else plex.search(query).tracks
|
||||
|
||||
private fun offlineSearch(query: String): List<Track> {
|
||||
val q = CarTree.norm(query)
|
||||
if (q.isEmpty()) return emptyList()
|
||||
val dl = downloadedTracks()
|
||||
return dl.filter { CarTree.norm(it.title).contains(q) } +
|
||||
dl.filter { !CarTree.norm(it.title).contains(q) && (CarTree.norm(it.artist).contains(q) || CarTree.norm(it.album).contains(q)) }
|
||||
}
|
||||
|
||||
/** Results for Auto's search screen (voice input; there's no keyboard while driving). */
|
||||
suspend fun searchItems(query: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val container = CarIds.search(query)
|
||||
if (away()) return@withContext songs(container, tracksOf(container), shuffleRow = false, showArtist = true, group = "Downloaded songs")
|
||||
val r = plex.search(query)
|
||||
songs(container, tracksOf(container), shuffleRow = false, showArtist = true, group = "Songs") +
|
||||
r.artists.map { artistItem(it, group = "Artists") } +
|
||||
r.albums.map { albumItem(it, group = "Albums") } +
|
||||
r.playlists.map { playlistItem(it, group = "Playlists") }
|
||||
}
|
||||
|
||||
// ---- items -----------------------------------------------------------------------
|
||||
|
||||
private fun songs(container: String, tracks: List<Track>, shuffleRow: Boolean, showArtist: Boolean, group: String? = null): List<MediaItem> {
|
||||
val rows = tracks.map { t -> songItem(container, t, showArtist, group) }
|
||||
return if (shuffleRow && tracks.size > 1) listOf(shuffleItem(container, "Shuffle all", group)) + rows else rows
|
||||
}
|
||||
|
||||
private fun songItem(container: String, t: Track, showArtist: Boolean, group: String?): MediaItem {
|
||||
// Stems are the norm, so only the exceptions are marked.
|
||||
val noStems = g.stems.index.value.size > 0 && !pm.hasStems(t)
|
||||
val subtitle = listOfNotNull(if (noStems) "No stems" else null, if (showArtist) t.artist else null).joinToString(" · ")
|
||||
val extras = Bundle().apply {
|
||||
group?.let { putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it) }
|
||||
if (pm.playableOffline(t)) putLong(MediaConstants.EXTRAS_KEY_DOWNLOAD_STATUS, MediaConstants.EXTRAS_VALUE_STATUS_DOWNLOADED)
|
||||
}
|
||||
return item(
|
||||
CarIds.play(container, t.ratingKey), t.title, subtitle.ifEmpty { null }, CarArtProvider.uri(t.thumb),
|
||||
MediaMetadata.MEDIA_TYPE_MUSIC, playable = true, extras = extras,
|
||||
) { setArtist(t.artist).setAlbumTitle(t.album).setDurationMs(t.durationMs.takeIf { it > 0 }) }
|
||||
}
|
||||
|
||||
private fun shuffleItem(container: String, title: String, group: String? = null) = item(
|
||||
CarIds.shuffle(container), title, null, Uri.parse("android.resource://${g.packageName}/drawable/ic_car_shuffle"),
|
||||
MediaMetadata.MEDIA_TYPE_PLAYLIST, playable = true,
|
||||
extras = Bundle().apply { group?.let { putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it) } },
|
||||
)
|
||||
|
||||
private fun playlistItem(p: Playlist, group: String? = null) =
|
||||
folder(CarIds.playlist(p.ratingKey), p.title, CarTree.countLabel(p.trackCount, "song", "songs"), MediaMetadata.MEDIA_TYPE_PLAYLIST,
|
||||
art = artOr(p.thumb, p.title), group = group)
|
||||
|
||||
private fun albumItem(a: Album) = albumItem(a, null)
|
||||
|
||||
private fun albumItem(a: Album, group: String?) =
|
||||
folder(CarIds.album(a.ratingKey), a.title, a.artist, MediaMetadata.MEDIA_TYPE_ALBUM, art = artOr(a.thumb, a.title), group = group)
|
||||
|
||||
private fun artistItem(a: Artist) = artistItem(a, null)
|
||||
|
||||
private fun artistItem(a: Artist, group: String?) =
|
||||
folder(CarIds.artist(a.ratingKey), a.title, null, MediaMetadata.MEDIA_TYPE_ARTIST, art = artOr(a.thumb, a.sortTitle),
|
||||
childStyle = MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM, group = group)
|
||||
|
||||
/** Plex art, or a letter tile so Auto never draws its missing-image triangle. */
|
||||
private fun artOr(thumb: String?, title: String) = CarArtProvider.uri(thumb) ?: CarArtProvider.letterUri(CarTree.letterOf(title))
|
||||
|
||||
private fun tab(id: String, title: String, type: Int, childStyle: Int) = folder(id, title, null, type, childStyle = childStyle)
|
||||
|
||||
private fun folder(
|
||||
id: String, title: String, subtitle: String?, type: Int,
|
||||
art: Uri? = null, childStyle: Int? = null, group: String? = null,
|
||||
) = item(id, title, subtitle, art, type, playable = false, extras = Bundle().apply {
|
||||
childStyle?.let { putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, it) }
|
||||
putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
||||
group?.let { putString(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it) }
|
||||
})
|
||||
|
||||
private fun item(
|
||||
id: String, title: String, subtitle: String?, art: Uri?, type: Int, playable: Boolean, extras: Bundle,
|
||||
more: MediaMetadata.Builder.() -> Unit = {},
|
||||
): MediaItem = MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
// Without a display title Media3 gives Auto the artist as the second line,
|
||||
// and the subtitle ("Stems · Artist", counts) never shows.
|
||||
.setDisplayTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setArtworkUri(art)
|
||||
.setMediaType(type)
|
||||
.setIsPlayable(playable)
|
||||
.setIsBrowsable(!playable)
|
||||
.setExtras(extras)
|
||||
.apply(more)
|
||||
.build(),
|
||||
).build()
|
||||
|
||||
private companion object {
|
||||
const val CACHE_NS = 120_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.stemroller.mobile.auto
|
||||
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* Media IDs for the Android Auto tree. Containers are `kind:key`; playable rows carry the
|
||||
* container they were picked from, so choosing a song queues the rest of its list after it.
|
||||
* The container goes last because search queries can contain anything.
|
||||
*/
|
||||
object CarIds {
|
||||
const val ROOT = "root"
|
||||
const val DOWNLOADS = "tab:downloads"
|
||||
const val PLAYLISTS = "tab:playlists"
|
||||
const val ALBUMS = "tab:albums"
|
||||
const val ARTISTS = "tab:artists"
|
||||
val TABS = listOf(DOWNLOADS, PLAYLISTS, ALBUMS, ARTISTS)
|
||||
|
||||
fun playlist(key: String) = "playlist:$key"
|
||||
fun album(key: String) = "album:$key"
|
||||
fun artist(key: String) = "artist:$key"
|
||||
fun albumLetter(letter: String) = "albums:$letter"
|
||||
fun artistLetter(letter: String) = "artists:$letter"
|
||||
fun search(query: String) = "search:$query"
|
||||
|
||||
fun play(container: String, trackKey: String) = "play|$trackKey|$container"
|
||||
fun shuffle(container: String) = "shuffle|$container"
|
||||
|
||||
sealed interface Pick {
|
||||
val container: String
|
||||
data class Song(override val container: String, val trackKey: String) : Pick
|
||||
data class Shuffle(override val container: String) : Pick
|
||||
}
|
||||
|
||||
fun parsePick(id: String): Pick? = when {
|
||||
id.startsWith("play|") -> {
|
||||
val rest = id.removePrefix("play|")
|
||||
val bar = rest.indexOf('|')
|
||||
if (bar <= 0 || bar == rest.lastIndex) null else Pick.Song(rest.substring(bar + 1), rest.substring(0, bar))
|
||||
}
|
||||
id.startsWith("shuffle|") -> id.removePrefix("shuffle|").takeIf { it.isNotEmpty() }?.let { Pick.Shuffle(it) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** (kind, key) of a container id such as `album:123`. */
|
||||
fun split(id: String): Pair<String, String>? {
|
||||
val c = id.indexOf(':')
|
||||
return if (c <= 0) null else id.substring(0, c) to id.substring(c + 1)
|
||||
}
|
||||
}
|
||||
|
||||
object CarTree {
|
||||
/**
|
||||
* Lists longer than this split into A–Z folders: turning the commander knob through a few
|
||||
* dozen rows is fine, through hundreds it isn't (and Auto stops long scrolls while moving).
|
||||
*/
|
||||
const val FLAT_MAX = 40
|
||||
|
||||
/** Recently added albums shown above the A–Z folders. */
|
||||
const val RECENT_ALBUMS = 12
|
||||
|
||||
/** "#" for anything that doesn't start with a letter; accents fold to their base letter. */
|
||||
fun letterOf(sortTitle: String): String {
|
||||
val c = Normalizer.normalize(sortTitle.trim(), Normalizer.Form.NFD).firstOrNull { it.isLetterOrDigit() } ?: return "#"
|
||||
val u = c.uppercaseChar()
|
||||
return if (u in 'A'..'Z') u.toString() else "#"
|
||||
}
|
||||
|
||||
/** Items by first letter, "#" first then A–Z, each keeping the incoming order. */
|
||||
fun <T> buckets(items: List<T>, sortTitle: (T) -> String): Map<String, List<T>> =
|
||||
items.groupBy { letterOf(sortTitle(it)) }.toSortedMap(compareBy { if (it == "#") "" else it })
|
||||
|
||||
fun countLabel(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"
|
||||
|
||||
/** Loose title match for voice requests: case, accents and punctuation don't matter. */
|
||||
fun norm(s: String) = Normalizer.normalize(s, Normalizer.Form.NFD)
|
||||
.lowercase()
|
||||
.filter { it.isLetterOrDigit() || it == ' ' }
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
fun matches(title: String, query: String): Boolean {
|
||||
val t = norm(title)
|
||||
val q = norm(query)
|
||||
return q.isNotEmpty() && (t == q || t.removePrefix("the ") == q.removePrefix("the "))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.stemroller.mobile.auto
|
||||
|
||||
import com.stemroller.mobile.core.Connectivity
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.core.await
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Whether Plex or stemserve answers right now. In the car the phone is usually on mobile
|
||||
* data and the LAN addresses just time out, so the car UI asks this first (a quick probe,
|
||||
* remembered briefly) and goes straight to cached data when home is out of reach.
|
||||
*/
|
||||
class HomeReach(http: OkHttpClient, private val settings: Settings, net: Connectivity, scope: CoroutineScope) {
|
||||
private val probe = http.newBuilder()
|
||||
.cache(null)
|
||||
.connectTimeout(1500, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(2000, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(2500, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
private val lock = Mutex()
|
||||
@Volatile private var checkedAt = 0L
|
||||
|
||||
private val _home = MutableStateFlow(true)
|
||||
|
||||
/** The last answer; true until a probe says otherwise. Changes as the phone joins or leaves home Wi-Fi. */
|
||||
val home: StateFlow<Boolean> = _home.asStateFlow()
|
||||
val lastKnown get() = _home.value
|
||||
|
||||
init {
|
||||
// A new network (home Wi-Fi in range, or lost) invalidates the answer; ask again once it settles,
|
||||
// so the car's lists can switch over without anyone browsing first.
|
||||
scope.launch {
|
||||
merge(net.online.drop(1), net.unmetered.drop(1)).collect {
|
||||
checkedAt = 0
|
||||
delay(1500)
|
||||
reachable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reachable(): Boolean = lock.withLock {
|
||||
if (System.nanoTime() - checkedAt < TTL_NS && checkedAt != 0L) return lastKnown
|
||||
val ok = coroutineScope {
|
||||
val plex = async { answers(settings.plexUrl.value.trimEnd('/') + "/identity") }
|
||||
val stems = async { answers(settings.stemUrl.value.trimEnd('/') + "/api/info") }
|
||||
plex.await() || stems.await()
|
||||
}
|
||||
_home.value = ok
|
||||
checkedAt = System.nanoTime()
|
||||
ok
|
||||
}
|
||||
|
||||
private suspend fun answers(url: String): Boolean = runCatching {
|
||||
probe.newCall(Request.Builder().url(url).build()).await().use { it.code < 500 }
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
const val TTL_NS = 20_000_000_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.stemroller.mobile.cache
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.MainActivity
|
||||
import com.stemroller.mobile.R
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.core.Connectivity
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.playback.PlaybackManager
|
||||
import com.stemroller.mobile.plex.PlexClient
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import com.stemroller.mobile.stems.StemServer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@Serializable
|
||||
data class DownloadRecord(
|
||||
val track: Track,
|
||||
val keys: List<String> = emptyList(),
|
||||
val stems: Boolean = false,
|
||||
val done: Boolean = false,
|
||||
val error: String? = null,
|
||||
val added: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Offline copies: the track's stems (or the original file) fully fetched into
|
||||
* [MediaCache] and pinned, plus the metadata needed to browse them with no server.
|
||||
*/
|
||||
class Downloads(
|
||||
private val context: Context,
|
||||
private val settings: Settings,
|
||||
private val plex: PlexClient,
|
||||
private val stems: StemServer,
|
||||
private val cache: MediaCache,
|
||||
private val net: Connectivity,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val file = File(context.filesDir, "downloads.json")
|
||||
private val _records = MutableStateFlow(load())
|
||||
val records: StateFlow<Map<String, DownloadRecord>> = _records.asStateFlow()
|
||||
/** ratingKey -> 0..1 for queued and running downloads. */
|
||||
private val _progress = MutableStateFlow<Map<String, Float>>(emptyMap())
|
||||
val progress: StateFlow<Map<String, Float>> = _progress.asStateFlow()
|
||||
private val queue = Channel<String>(Channel.UNLIMITED)
|
||||
private val cancelled = HashSet<String>()
|
||||
|
||||
init {
|
||||
repeat(2) { scope.launch(Dispatchers.IO) { for (key in queue) run(key) } }
|
||||
// Resume anything interrupted last time.
|
||||
_records.value.values.filter { !it.done && it.error == null }.forEach { enqueue(it.track.ratingKey) }
|
||||
}
|
||||
|
||||
fun isDownloaded(ratingKey: String) = _records.value[ratingKey]?.done == true
|
||||
|
||||
fun download(tracks: List<Track>) {
|
||||
val add = tracks.filter { _records.value[it.ratingKey]?.let { r -> !r.done && r.error == null } != true && !isDownloaded(it.ratingKey) }
|
||||
if (add.isEmpty()) return
|
||||
update { m -> m + add.associate { it.ratingKey to DownloadRecord(it) } }
|
||||
add.forEach { enqueue(it.ratingKey) }
|
||||
context.startForegroundService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
|
||||
fun remove(tracks: List<Track>) {
|
||||
val keys = tracks.map { it.ratingKey }.toSet()
|
||||
synchronized(cancelled) { cancelled += keys }
|
||||
val gone = _records.value.filterKeys { it in keys }.values
|
||||
update { m -> m - keys }
|
||||
_progress.update { it - keys }
|
||||
scope.launch(Dispatchers.IO) { gone.forEach { r -> r.keys.forEach { cache.unpin(it) } } }
|
||||
}
|
||||
|
||||
fun retryFailed() {
|
||||
val failed = _records.value.values.filter { it.error != null }
|
||||
update { m -> m + failed.associate { it.track.ratingKey to it.copy(error = null) } }
|
||||
failed.forEach { enqueue(it.track.ratingKey) }
|
||||
if (failed.isNotEmpty()) context.startForegroundService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
|
||||
private fun enqueue(key: String) {
|
||||
synchronized(cancelled) { cancelled -= key }
|
||||
_progress.update { it + (key to 0f) }
|
||||
queue.trySend(key)
|
||||
}
|
||||
|
||||
private suspend fun run(key: String) {
|
||||
val rec = _records.value[key] ?: return
|
||||
if (rec.done) return
|
||||
try {
|
||||
if (settings.downloadsWifiOnly.value) net.unmetered.first { it }
|
||||
val t = rec.track
|
||||
val entry = stems.find(t)
|
||||
val sources = if (entry != null) {
|
||||
val q = stems.effectiveQuality(settings.stemQuality.value)
|
||||
stems.prepare(entry, q)
|
||||
entry.stems.map { PlaybackManager.stemKey(entry, it.file, it.size, q) to stems.fileUrl(entry, it, q) }
|
||||
} else {
|
||||
val part = t.partKey ?: throw IOException("No file on the server")
|
||||
listOf(PlaybackManager.plexKey(t) to plex.partUrl(part))
|
||||
}
|
||||
update { m -> m + (key to rec.copy(keys = sources.map { it.first }, stems = entry != null)) }
|
||||
sources.forEachIndexed { i, (cacheKey, url) ->
|
||||
val res = cache.acquire(cacheKey) { url }
|
||||
try {
|
||||
res.pinned = true
|
||||
res.downloadAll(
|
||||
progress = { got, len ->
|
||||
val f = if (len > 0) got.toFloat() / len else 0f
|
||||
_progress.update { it + (key to (i + f) / sources.size) }
|
||||
},
|
||||
cancelled = { synchronized(cancelled) { key in cancelled } },
|
||||
)
|
||||
} finally {
|
||||
cache.release(res)
|
||||
}
|
||||
}
|
||||
if (synchronized(cancelled) { key in cancelled }) return
|
||||
if (entry != null) stems.peaks(entry, PlaybackManager.WAVE_BUCKETS) // warms the HTTP cache for offline
|
||||
plex.artUrl(t.thumb, 600)?.let { url ->
|
||||
withContext(Dispatchers.Main) {
|
||||
SingletonImageLoader.get(context).enqueue(ImageRequest.Builder(context).data(url).build())
|
||||
}
|
||||
}
|
||||
update { m -> if (key in m) m + (key to m.getValue(key).copy(done = true, error = null)) else m }
|
||||
} catch (e: Exception) {
|
||||
if (synchronized(cancelled) { key in cancelled }) return
|
||||
update { m -> if (key in m) m + (key to m.getValue(key).copy(error = e.message ?: "Download failed")) else m }
|
||||
} finally {
|
||||
_progress.update { it - key }
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(f: (Map<String, DownloadRecord>) -> Map<String, DownloadRecord>) {
|
||||
synchronized(this) { _records.value = f(_records.value) }
|
||||
val snapshot = _records.value
|
||||
scope.launch(Dispatchers.IO) {
|
||||
synchronized(file) {
|
||||
runCatching {
|
||||
val tmp = File(file.parentFile, "downloads.json.tmp")
|
||||
tmp.writeText(AppJson.encodeToString(LIST, snapshot.values.toList()))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): Map<String, DownloadRecord> = runCatching {
|
||||
AppJson.decodeFromString(LIST, file.readText()).associateBy { it.track.ratingKey }
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
companion object {
|
||||
private val LIST = kotlinx.serialization.builtins.ListSerializer(DownloadRecord.serializer())
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the process alive while downloads run, with progress in a notification. */
|
||||
class DownloadService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var job: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW))
|
||||
startForeground(ID, build(0, 0f), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
if (job == null) {
|
||||
job = scope.launch {
|
||||
App.graph.downloads.progress.collectLatest { p ->
|
||||
if (p.isEmpty()) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
nm.notify(ID, build(p.size, p.values.average().toFloat()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun build(left: Int, fraction: Float): Notification {
|
||||
val open = PendingIntent.getActivity(this, 1, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_stems)
|
||||
.setContentTitle(if (left > 0) "Downloading $left song${if (left == 1) "" else "s"}" else "Preparing downloads")
|
||||
.setProgress(1000, (fraction * 1000).toInt(), left == 0)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(open)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "downloads"
|
||||
private const val ID = 7
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package com.stemroller.mobile.cache
|
||||
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
import java.util.BitSet
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.min
|
||||
|
||||
@Serializable
|
||||
internal data class Meta(
|
||||
val length: Long = -1,
|
||||
val bits: String = "",
|
||||
val pinned: Boolean = false,
|
||||
val access: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Disk cache for stems and original tracks. Each resource is a sparse file filled in
|
||||
* BLOCK-sized pieces with HTTP range requests: reads block only on the bytes they need,
|
||||
* a background prefetcher fills the rest in order, and seeks (FLAC without a seek table
|
||||
* bisects the file) fetch out of order. Pinned resources are downloads and never evicted.
|
||||
*/
|
||||
class MediaCache(
|
||||
private val dir: File,
|
||||
internal val http: OkHttpClient,
|
||||
private val limitBytes: () -> Long,
|
||||
) {
|
||||
private val open = HashMap<String, CachedResource>()
|
||||
private val refs = HashMap<String, Int>()
|
||||
private val catalog = HashMap<String, CatalogEntry>()
|
||||
private val prefetchPool = Executors.newFixedThreadPool(6) { r -> Thread(r, "media-prefetch").apply { isDaemon = true } }
|
||||
private val trimPool = Executors.newSingleThreadExecutor { r -> Thread(r, "media-trim").apply { isDaemon = true } }
|
||||
private val _usage = MutableStateFlow(Usage(0, 0))
|
||||
val usage: StateFlow<Usage> = _usage.asStateFlow()
|
||||
|
||||
data class Usage(val cachedBytes: Long, val pinnedBytes: Long)
|
||||
private class CatalogEntry(var bytes: Long, var access: Long, var pinned: Boolean)
|
||||
|
||||
init {
|
||||
dir.mkdirs()
|
||||
trimPool.execute {
|
||||
dir.listFiles { f -> f.name.endsWith(".meta") }?.forEach { f ->
|
||||
val m = readMeta(f) ?: return@forEach
|
||||
val id = f.name.removeSuffix(".meta")
|
||||
synchronized(this) {
|
||||
if (id !in catalog) catalog[id] = CatalogEntry(bitsOf(m).cardinality().toLong() * BLOCK, m.access, m.pinned)
|
||||
}
|
||||
}
|
||||
publishUsage()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens (or reuses) a resource; pair every call with [release]. */
|
||||
@Synchronized
|
||||
fun acquire(key: String, url: () -> String): CachedResource {
|
||||
val id = idFor(key)
|
||||
refs[id] = (refs[id] ?: 0) + 1
|
||||
return open.getOrPut(id) { CachedResource(id, File(dir, "$id.data"), File(dir, "$id.meta"), url, this) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release(res: CachedResource) {
|
||||
val n = (refs[res.id] ?: 1) - 1
|
||||
if (n > 0) {
|
||||
refs[res.id] = n
|
||||
return
|
||||
}
|
||||
refs.remove(res.id)
|
||||
open.remove(res.id)
|
||||
res.close()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
fun isComplete(key: String): Boolean {
|
||||
val id = idFor(key)
|
||||
synchronized(this) { open[id]?.let { return it.isComplete } }
|
||||
val m = readMeta(File(dir, "$id.meta")) ?: return false
|
||||
return m.length >= 0 && bitsOf(m).cardinality() >= blocksFor(m.length) && File(dir, "$id.data").exists()
|
||||
}
|
||||
|
||||
fun isPinned(key: String): Boolean = synchronized(this) { catalog[idFor(key)]?.pinned == true }
|
||||
|
||||
/** Unpins a download so normal eviction can reclaim it. */
|
||||
fun unpin(key: String) {
|
||||
val id = idFor(key)
|
||||
synchronized(this) { open[id] }?.let { it.pinned = false; return }
|
||||
val f = File(dir, "$id.meta")
|
||||
val m = readMeta(f) ?: return
|
||||
writeMeta(f, m.copy(pinned = false))
|
||||
synchronized(this) { catalog[id]?.pinned = false }
|
||||
publishUsage()
|
||||
scheduleTrim()
|
||||
}
|
||||
|
||||
/** Deletes every resource that isn't pinned or in use. */
|
||||
fun clearUnpinned() {
|
||||
trimPool.execute { evict(keepBytes = 0) }
|
||||
}
|
||||
|
||||
internal fun submitPrefetch(task: Runnable) = prefetchPool.execute(task)
|
||||
|
||||
internal fun update(id: String, bytes: Long, access: Long, pinned: Boolean) {
|
||||
synchronized(this) {
|
||||
val e = catalog.getOrPut(id) { CatalogEntry(0, 0, false) }
|
||||
e.bytes = bytes; e.access = access; e.pinned = pinned
|
||||
}
|
||||
publishUsage()
|
||||
}
|
||||
|
||||
private fun publishUsage() {
|
||||
synchronized(this) {
|
||||
var all = 0L
|
||||
var pinned = 0L
|
||||
for (e in catalog.values) {
|
||||
all += e.bytes
|
||||
if (e.pinned) pinned += e.bytes
|
||||
}
|
||||
_usage.value = Usage(all, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
private var trimQueued = false
|
||||
|
||||
private fun scheduleTrim() {
|
||||
synchronized(this) {
|
||||
if (trimQueued) return
|
||||
trimQueued = true
|
||||
}
|
||||
trimPool.execute {
|
||||
synchronized(this) { trimQueued = false }
|
||||
evict(limitBytes())
|
||||
}
|
||||
}
|
||||
|
||||
private fun evict(keepBytes: Long) {
|
||||
val victims = synchronized(this) {
|
||||
var total = catalog.values.sumOf { it.bytes }
|
||||
val out = ArrayList<String>()
|
||||
for ((id, e) in catalog.entries.sortedBy { it.value.access }) {
|
||||
if (total <= keepBytes) break
|
||||
if (e.pinned || id in open) continue
|
||||
out += id
|
||||
total -= e.bytes
|
||||
}
|
||||
out.forEach { catalog.remove(it) }
|
||||
out
|
||||
}
|
||||
for (id in victims) {
|
||||
File(dir, "$id.data").delete()
|
||||
File(dir, "$id.meta").delete()
|
||||
}
|
||||
if (victims.isNotEmpty()) publishUsage()
|
||||
}
|
||||
|
||||
internal fun readMeta(f: File): Meta? =
|
||||
runCatching { AppJson.decodeFromString(Meta.serializer(), f.readText()) }.getOrNull()
|
||||
|
||||
internal fun writeMeta(f: File, m: Meta) {
|
||||
val tmp = File(f.parentFile, f.name + ".tmp")
|
||||
tmp.writeText(AppJson.encodeToString(Meta.serializer(), m))
|
||||
tmp.renameTo(f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BLOCK = 64 * 1024
|
||||
const val ON_DEMAND_RUN = 8
|
||||
const val PREFETCH_RUN = 32
|
||||
|
||||
fun idFor(key: String): String =
|
||||
MessageDigest.getInstance("SHA-1").digest(key.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
internal fun blocksFor(length: Long) = ((length + BLOCK - 1) / BLOCK).toInt()
|
||||
|
||||
internal fun bitsOf(m: Meta): BitSet =
|
||||
if (m.bits.isEmpty()) BitSet() else BitSet.valueOf(Base64.getDecoder().decode(m.bits))
|
||||
}
|
||||
|
||||
/** One cached remote file. Thread-safe; readers block until their bytes have arrived. */
|
||||
class CachedResource internal constructor(
|
||||
val id: String,
|
||||
private val dataFile: File,
|
||||
private val metaFile: File,
|
||||
private val url: () -> String,
|
||||
private val cache: MediaCache,
|
||||
) {
|
||||
private val lock = ReentrantLock()
|
||||
private val changed = lock.newCondition()
|
||||
private val bits: BitSet
|
||||
private val claimed = BitSet()
|
||||
private var raf: RandomAccessFile? = null
|
||||
private var channel: FileChannel? = null
|
||||
private var failure: IOException? = null
|
||||
private var unsaved = 0
|
||||
private var prefetching = false
|
||||
@Volatile private var lastRead = 0
|
||||
@Volatile private var closed = false
|
||||
@Volatile var length: Long = -1
|
||||
private set
|
||||
@Volatile private var pinnedFlag = false
|
||||
var pinned: Boolean
|
||||
get() = pinnedFlag
|
||||
set(v) {
|
||||
pinnedFlag = v
|
||||
saveMeta()
|
||||
}
|
||||
|
||||
init {
|
||||
val m = cache.readMeta(metaFile)
|
||||
if (m != null && dataFile.exists()) {
|
||||
length = m.length
|
||||
bits = bitsOf(m)
|
||||
pinnedFlag = m.pinned
|
||||
} else {
|
||||
bits = BitSet()
|
||||
dataFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private val blockCount get() = if (length < 0) Int.MAX_VALUE else blocksFor(length)
|
||||
val isComplete get() = lock.withLock { length >= 0 && bits.cardinality() >= blocksFor(length) }
|
||||
val cachedBytes get() = lock.withLock { min(bits.cardinality().toLong() * BLOCK, if (length < 0) Long.MAX_VALUE else length) }
|
||||
|
||||
private fun channel(): FileChannel = lock.withLock {
|
||||
if (closed) throw IOException("closed")
|
||||
channel ?: RandomAccessFile(dataFile, "rw").also { raf = it }.channel.also { channel = it }
|
||||
}
|
||||
|
||||
/** Total size, fetching the first run if it isn't known yet. */
|
||||
fun size(): Long {
|
||||
if (length < 0) awaitBlock(0)
|
||||
return length
|
||||
}
|
||||
|
||||
/** Reads up to [size] bytes at [pos]; -1 at end of file. Blocks on the network if needed. */
|
||||
fun readAt(pos: Long, buf: ByteArray, off: Int, size: Int): Int {
|
||||
val total = size()
|
||||
if (pos >= total) return -1
|
||||
val n = min(size.toLong(), total - pos).toInt()
|
||||
if (n <= 0) return 0
|
||||
val first = (pos / BLOCK).toInt()
|
||||
val last = ((pos + n - 1) / BLOCK).toInt()
|
||||
for (b in first..last) awaitBlock(b)
|
||||
lastRead = last
|
||||
val bb = ByteBuffer.wrap(buf, off, n)
|
||||
var at = pos
|
||||
val ch = channel()
|
||||
while (bb.hasRemaining()) {
|
||||
val r = ch.read(bb, at)
|
||||
if (r < 0) break
|
||||
at += r
|
||||
}
|
||||
return n - bb.remaining()
|
||||
}
|
||||
|
||||
/** Starts filling the rest of the file in the background, from wherever reading is. */
|
||||
fun prefetch() {
|
||||
lock.withLock {
|
||||
if (prefetching || closed) return
|
||||
prefetching = true
|
||||
}
|
||||
cache.submitPrefetch {
|
||||
try {
|
||||
fillAll(fromBlock = { lastRead }, cancelled = { closed })
|
||||
} catch (_: IOException) {
|
||||
// A reader that needs these bytes will retry and surface the error.
|
||||
} finally {
|
||||
lock.withLock { prefetching = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads every missing block on the calling thread. */
|
||||
fun downloadAll(progress: (Long, Long) -> Unit, cancelled: () -> Boolean) {
|
||||
fillAll(fromBlock = { 0 }, cancelled = cancelled, progress = progress)
|
||||
}
|
||||
|
||||
private fun fillAll(fromBlock: () -> Int, cancelled: () -> Boolean, progress: ((Long, Long) -> Unit)? = null) {
|
||||
size()
|
||||
while (!cancelled()) {
|
||||
var run: IntRange? = null
|
||||
var waitFor = -1
|
||||
lock.withLock {
|
||||
val count = blockCount
|
||||
if (bits.cardinality() >= count) return
|
||||
val start = fromBlock().coerceIn(0, count - 1)
|
||||
var b = bits.nextClearBit(start)
|
||||
if (b >= count) b = bits.nextClearBit(0)
|
||||
if (b >= count) return
|
||||
if (claimed[b]) {
|
||||
// Someone else is fetching here; look for a free block after it.
|
||||
var c = b
|
||||
while (c < count && (bits[c] || claimed[c])) c++
|
||||
if (c >= count) waitFor = b else run = claimRun(c, PREFETCH_RUN)
|
||||
} else {
|
||||
run = claimRun(b, PREFETCH_RUN)
|
||||
}
|
||||
}
|
||||
run?.let { fetch(it) }
|
||||
if (waitFor >= 0) awaitBlock(waitFor)
|
||||
progress?.invoke(cachedBytes, length)
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitBlock(b: Int) {
|
||||
var waited = 0L
|
||||
while (true) {
|
||||
var run: IntRange? = null
|
||||
lock.withLock {
|
||||
if (closed) throw IOException("closed")
|
||||
if (length >= 0 && b >= blockCount) return
|
||||
if (bits[b]) return
|
||||
if (!claimed[b]) {
|
||||
run = claimRun(b, ON_DEMAND_RUN)
|
||||
} else {
|
||||
val before = System.nanoTime()
|
||||
changed.await(1, TimeUnit.SECONDS)
|
||||
waited += System.nanoTime() - before
|
||||
if (waited > TimeUnit.SECONDS.toNanos(60)) throw failure ?: IOException("timed out waiting for data")
|
||||
}
|
||||
}
|
||||
run?.let { fetch(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Claims up to [max] consecutive missing, unclaimed blocks from [b]. Caller holds the lock. */
|
||||
private fun claimRun(b: Int, max: Int): IntRange {
|
||||
var end = b
|
||||
val count = blockCount
|
||||
while (end + 1 < count && end + 1 - b < max && !bits[end + 1] && !claimed[end + 1]) end++
|
||||
claimed.set(b, end + 1)
|
||||
return b..end
|
||||
}
|
||||
|
||||
private fun fetch(run: IntRange) {
|
||||
var block = run.first
|
||||
try {
|
||||
val start = run.first.toLong() * BLOCK
|
||||
val endExclusive = if (length >= 0) min((run.last + 1L) * BLOCK, length) else (run.last + 1L) * BLOCK
|
||||
val req = Request.Builder().url(url()).header("Range", "bytes=$start-${endExclusive - 1}").build()
|
||||
cache.http.newCall(req).execute().use { resp ->
|
||||
var skip = 0L
|
||||
when (resp.code) {
|
||||
206 -> {
|
||||
val total = resp.header("Content-Range")?.substringAfter('/')?.toLongOrNull()
|
||||
if (total != null) learnLength(total)
|
||||
}
|
||||
200 -> {
|
||||
// No range support: the body is the whole file.
|
||||
resp.body?.contentLength()?.takeIf { it >= 0 }?.let { learnLength(it) }
|
||||
skip = start
|
||||
}
|
||||
else -> throw IOException("HTTP ${resp.code} fetching media")
|
||||
}
|
||||
if (length < 0) throw IOException("server sent no length")
|
||||
val body = resp.body ?: throw IOException("empty response")
|
||||
val src = body.byteStream()
|
||||
val tmp = ByteArray(BLOCK)
|
||||
while (skip > 0) {
|
||||
val s = src.skip(skip)
|
||||
if (s <= 0) throw IOException("short response")
|
||||
skip -= s
|
||||
}
|
||||
val ch = channel()
|
||||
val lastBlock = min(run.last, blockCount - 1)
|
||||
while (block <= lastBlock) {
|
||||
val blockStart = block.toLong() * BLOCK
|
||||
val want = (min(blockStart + BLOCK, length) - blockStart).toInt()
|
||||
var got = 0
|
||||
while (got < want) {
|
||||
val r = src.read(tmp, got, want - got)
|
||||
if (r < 0) throw IOException("connection ended early")
|
||||
got += r
|
||||
}
|
||||
val bb = ByteBuffer.wrap(tmp, 0, want)
|
||||
var at = blockStart
|
||||
while (bb.hasRemaining()) at += ch.write(bb, at)
|
||||
lock.withLock {
|
||||
bits.set(block)
|
||||
claimed.clear(block)
|
||||
failure = null
|
||||
changed.signalAll()
|
||||
}
|
||||
block++
|
||||
if (++unsaved >= 32 || block > lastBlock && isComplete) saveMeta()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
lock.withLock {
|
||||
failure = e
|
||||
changed.signalAll()
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
lock.withLock {
|
||||
// Release whatever this run didn't deliver, including blocks past EOF.
|
||||
for (b in block..run.last) claimed.clear(b)
|
||||
changed.signalAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun learnLength(total: Long) {
|
||||
lock.withLock {
|
||||
if (length != total) {
|
||||
length = total
|
||||
val count = blocksFor(total)
|
||||
if (bits.length() > count) bits.clear(count, bits.length())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMeta() {
|
||||
val m = lock.withLock {
|
||||
unsaved = 0
|
||||
Meta(length, Base64.getEncoder().encodeToString(bits.toByteArray()), pinnedFlag, System.currentTimeMillis())
|
||||
}
|
||||
runCatching { cache.writeMeta(metaFile, m) }
|
||||
cache.update(id, min(bits.cardinality().toLong() * BLOCK, if (length < 0) 0 else length), m.access, m.pinned)
|
||||
}
|
||||
|
||||
internal fun close() {
|
||||
closed = true
|
||||
lock.withLock { changed.signalAll() }
|
||||
saveMeta()
|
||||
lock.withLock {
|
||||
runCatching { channel?.close() }
|
||||
runCatching { raf?.close() }
|
||||
channel = null
|
||||
raf = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.stemroller.mobile.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
val AppJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
isLenient = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
class HttpException(val code: Int, message: String) : IOException(message)
|
||||
|
||||
/** Runs the call on OkHttp's dispatcher; cancelling the coroutine cancels the request. */
|
||||
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
|
||||
enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
if (!cont.isCancelled) cont.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
cont.invokeOnCancellation { runCatching { cancel() } }
|
||||
}
|
||||
|
||||
/** Awaits the call and reads the body off the main thread (big bodies stream from the socket). */
|
||||
suspend fun Call.awaitBody(): String = withContext(Dispatchers.IO) {
|
||||
await().use { r ->
|
||||
if (!r.isSuccessful) throw HttpException(r.code, "HTTP ${r.code} from ${r.request.url.host}")
|
||||
r.body?.string() ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
/** Online / Wi-Fi state, for offline fallbacks and Wi-Fi-only downloads. */
|
||||
class Connectivity(context: Context) {
|
||||
private val cm = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val _online = MutableStateFlow(true)
|
||||
private val _unmetered = MutableStateFlow(true)
|
||||
val online: StateFlow<Boolean> = _online.asStateFlow()
|
||||
val unmetered: StateFlow<Boolean> = _unmetered.asStateFlow()
|
||||
|
||||
init {
|
||||
update(cm.getNetworkCapabilities(cm.activeNetwork))
|
||||
cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = update(caps)
|
||||
override fun onLost(network: Network) = update(null)
|
||||
})
|
||||
}
|
||||
|
||||
private fun update(caps: NetworkCapabilities?) {
|
||||
_online.value = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
_unmetered.value = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.stemroller.mobile.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.UUID
|
||||
|
||||
enum class ThemeMode(val label: String) { Black("Black"), Dark("Dark"), White("White") }
|
||||
|
||||
/** iOS system tints, light then dark variant. */
|
||||
enum class Accent(val label: String, val light: Long, val dark: Long) {
|
||||
Music("Music", 0xFFFA2D48, 0xFFFC3C44),
|
||||
Pink("Pink", 0xFFFF2D55, 0xFFFF375F),
|
||||
Orange("Orange", 0xFFFF9500, 0xFFFF9F0A),
|
||||
Yellow("Yellow", 0xFFFFCC00, 0xFFFFD60A),
|
||||
Green("Green", 0xFF34C759, 0xFF30D158),
|
||||
Teal("Teal", 0xFF30B0C7, 0xFF40C8E0),
|
||||
Blue("Blue", 0xFF007AFF, 0xFF0A84FF),
|
||||
Indigo("Indigo", 0xFF5856D6, 0xFF5E5CE6),
|
||||
Purple("Purple", 0xFFAF52DE, 0xFFBF5AF2),
|
||||
}
|
||||
|
||||
/** How stems travel from stemserve; Opus is transcoded (and cached) server-side. */
|
||||
enum class StemQuality(val label: String, val detail: String, val fmt: String, val kbps: Int) {
|
||||
Lossless("Lossless", "Original 24-bit FLAC, ~30 MB per stem-minute", "flac", 0),
|
||||
High("High", "Opus 256 kbps per stem", "opus", 256),
|
||||
Standard("Standard", "Opus 160 kbps per stem", "opus", 160),
|
||||
Saver("Data Saver", "Opus 96 kbps per stem", "opus", 96),
|
||||
}
|
||||
|
||||
class Pref<T>(
|
||||
private val prefs: SharedPreferences,
|
||||
private val key: String,
|
||||
initial: T,
|
||||
private val read: SharedPreferences.(String, T) -> T,
|
||||
private val write: SharedPreferences.Editor.(String, T) -> Unit,
|
||||
) {
|
||||
private val state = MutableStateFlow(prefs.read(key, initial))
|
||||
val flow: StateFlow<T> = state.asStateFlow()
|
||||
var value: T
|
||||
get() = state.value
|
||||
set(v) {
|
||||
if (v == state.value) return
|
||||
state.value = v
|
||||
prefs.edit().apply { write(key, v) }.apply()
|
||||
}
|
||||
}
|
||||
|
||||
class Settings(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
private fun string(key: String, def: String) =
|
||||
Pref(prefs, key, def, { k, d -> getString(k, d) ?: d }, { k, v -> putString(k, v) })
|
||||
|
||||
private fun bool(key: String, def: Boolean) =
|
||||
Pref(prefs, key, def, { k, d -> getBoolean(k, d) }, { k, v -> putBoolean(k, v) })
|
||||
|
||||
private fun int(key: String, def: Int) =
|
||||
Pref(prefs, key, def, { k, d -> getInt(k, d) }, { k, v -> putInt(k, v) })
|
||||
|
||||
private fun float(key: String, def: Float) =
|
||||
Pref(prefs, key, def, { k, d -> getFloat(k, d) }, { k, v -> putFloat(k, v) })
|
||||
|
||||
private inline fun <reified E : Enum<E>> enum(key: String, def: E) = Pref(
|
||||
prefs, key, def,
|
||||
{ k, d -> getString(k, null)?.let { s -> enumValues<E>().firstOrNull { it.name == s } } ?: d },
|
||||
{ k, v -> putString(k, v.name) },
|
||||
)
|
||||
|
||||
val plexUrl = string("plexUrl", "http://192.168.86.2:32400")
|
||||
val plexToken = string("plexToken", "")
|
||||
val plexUser = string("plexUser", "")
|
||||
val plexMachineId = string("plexMachineId", "")
|
||||
val musicSection = string("musicSection", "")
|
||||
val musicSectionTitle = string("musicSectionTitle", "")
|
||||
val stemUrl = string("stemUrl", "http://192.168.86.2:8765")
|
||||
val stemToken = string("stemToken", "")
|
||||
/** Plex path prefixes that matched the stems index, newline-separated. */
|
||||
val stemRoots = string("stemRoots", "")
|
||||
|
||||
val theme = enum("theme", ThemeMode.Black)
|
||||
val accent = enum("accent", Accent.Music)
|
||||
val albumColorNowPlaying = bool("albumColorNowPlaying", false)
|
||||
val highRefreshRate = bool("highRefreshRate", true)
|
||||
|
||||
val stemQuality = enum("stemQuality", StemQuality.High)
|
||||
val cacheLimitGb = int("cacheLimitGb", 10)
|
||||
val downloadsWifiOnly = bool("downloadsWifiOnly", true)
|
||||
/** Downloads page order: 0 download order, 1 artist, 2 release date. */
|
||||
val downloadSort = int("downloadSort", 0)
|
||||
val keepStemMix = bool("keepStemMix", true)
|
||||
val masterVolume = float("masterVolume", 0.85f)
|
||||
|
||||
val clientId: String = prefs.getString("clientId", null) ?: UUID.randomUUID().toString().also {
|
||||
prefs.edit().putString("clientId", it).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package com.stemroller.mobile.engine
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** ISO octave centres for the 10-band graphic EQ. */
|
||||
val EQ_BANDS = floatArrayOf(31.25f, 62.5f, 125f, 250f, 500f, 1000f, 2000f, 4000f, 8000f, 16000f)
|
||||
val EQ_LABELS = listOf("31", "62", "125", "250", "500", "1K", "2K", "4K", "8K", "16K")
|
||||
const val EQ_MAX_DB = 12f
|
||||
const val EQ_Q = 1.41f // one octave
|
||||
|
||||
/** Equalizer APO's filter types; [apo] is the name its config files use. */
|
||||
@Serializable
|
||||
enum class FilterType(val apo: String, val label: String, val hasGain: Boolean, val hasSlope: Boolean = false) {
|
||||
Peak("PK", "Peak", true),
|
||||
LowShelf("LSC", "Low Shelf", true),
|
||||
HighShelf("HSC", "High Shelf", true),
|
||||
LowPass("LPQ", "Low Pass", false, hasSlope = true),
|
||||
HighPass("HPQ", "High Pass", false, hasSlope = true),
|
||||
BandPass("BP", "Band Pass", false),
|
||||
Notch("NO", "Notch", false),
|
||||
AllPass("AP", "All Pass", false),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class EqChannel(val label: String) { Both("Both"), Left("Left"), Right("Right") }
|
||||
|
||||
/** Slopes offered for low and high pass, in dB per octave. 12 uses the filter's Q; steeper ones are Butterworth. */
|
||||
val EQ_SLOPES = listOf(6, 12, 24, 36, 48)
|
||||
|
||||
/** One parametric filter, as in Equalizer APO. Gain only matters for peaks and shelves. */
|
||||
@Serializable
|
||||
data class EqFilter(
|
||||
val id: String,
|
||||
val type: FilterType = FilterType.Peak,
|
||||
val freq: Float = 1000f,
|
||||
val gain: Float = 0f,
|
||||
val q: Float = 0.707f,
|
||||
val slope: Int = 12,
|
||||
val channel: EqChannel = EqChannel.Both,
|
||||
val on: Boolean = true,
|
||||
) {
|
||||
/** Whether it changes the sound at all (a 0 dB peak or shelf doesn't). */
|
||||
val active get() = on && (!type.hasGain || abs(gain) >= 0.01f)
|
||||
|
||||
/** Its biquad sections at [fs]: [b0, b1, b2, a1, a2] each, normalised by a0. */
|
||||
fun sections(fs: Double): List<DoubleArray> {
|
||||
if (!active) return emptyList()
|
||||
val f0 = freq.toDouble().coerceIn(1.0, fs * 0.49)
|
||||
val q = q.toDouble().coerceIn(0.025, 100.0)
|
||||
val g = gain.toDouble()
|
||||
return when (type) {
|
||||
FilterType.LowPass, FilterType.HighPass -> {
|
||||
val lp = type == FilterType.LowPass
|
||||
when {
|
||||
slope <= 6 -> listOf(firstOrder(fs, f0, lp))
|
||||
slope <= 12 -> listOf(Biquad.design(type, fs, f0, 0.0, q))
|
||||
else -> {
|
||||
// Butterworth of order slope/6, as cascaded sections (plus a first-order one when odd).
|
||||
val n = slope / 6
|
||||
(1..n / 2).map { k -> Biquad.design(type, fs, f0, 0.0, 1 / (2 * cos((2 * k - 1) * PI / (2 * n)))) } +
|
||||
(if (n % 2 == 1) listOf(firstOrder(fs, f0, lp)) else emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> listOf(Biquad.design(type, fs, f0, g, q))
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstOrder(fs: Double, f0: Double, lowPass: Boolean): DoubleArray {
|
||||
val k = tan(PI * f0 / fs)
|
||||
val a1 = (k - 1) / (k + 1)
|
||||
return if (lowPass) doubleArrayOf(k / (k + 1), k / (k + 1), 0.0, a1, 0.0)
|
||||
else doubleArrayOf(1 / (k + 1), -1 / (k + 1), 0.0, a1, 0.0)
|
||||
}
|
||||
|
||||
fun applies(left: Boolean) = channel == EqChannel.Both || (channel == EqChannel.Left) == left
|
||||
|
||||
companion object {
|
||||
/** The 10-band graphic EQ as peaking filters, ahead of the parametric ones. */
|
||||
fun graphic(gains: List<Float>) = gains.mapIndexedNotNull { i, g ->
|
||||
if (abs(g) < 0.01f || i >= EQ_BANDS.size) null else EqFilter("band$i", FilterType.Peak, EQ_BANDS[i], g, EQ_Q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the engine needs from an EQ profile; null means bypass. */
|
||||
data class EqParams(val filters: List<EqFilter>, val preampDb: Float)
|
||||
|
||||
/** A biquad section, transposed direct form II, double state per channel. */
|
||||
class Biquad {
|
||||
private var b0 = 1.0; private var b1 = 0.0; private var b2 = 0.0; private var a1 = 0.0; private var a2 = 0.0
|
||||
private var zl1 = 0.0; private var zl2 = 0.0; private var zr1 = 0.0; private var zr2 = 0.0
|
||||
|
||||
fun set(c: DoubleArray) {
|
||||
b0 = c[0]; b1 = c[1]; b2 = c[2]; a1 = c[3]; a2 = c[4]
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
zl1 = 0.0; zl2 = 0.0; zr1 = 0.0; zr2 = 0.0
|
||||
}
|
||||
|
||||
fun process(buf: FloatArray, frames: Int, left: Boolean = true, right: Boolean = true) {
|
||||
var l1 = zl1; var l2 = zl2; var r1 = zr1; var r2 = zr2
|
||||
for (i in 0 until frames) {
|
||||
if (left) {
|
||||
val xl = buf[i * 2].toDouble()
|
||||
val yl = b0 * xl + l1
|
||||
l1 = b1 * xl - a1 * yl + l2
|
||||
l2 = b2 * xl - a2 * yl
|
||||
buf[i * 2] = yl.toFloat()
|
||||
}
|
||||
if (right) {
|
||||
val xr = buf[i * 2 + 1].toDouble()
|
||||
val yr = b0 * xr + r1
|
||||
r1 = b1 * xr - a1 * yr + r2
|
||||
r2 = b2 * xr - a2 * yr
|
||||
buf[i * 2 + 1] = yr.toFloat()
|
||||
}
|
||||
}
|
||||
// Flush denormals so a silent tail doesn't cost CPU.
|
||||
zl1 = if (abs(l1) < 1e-25) 0.0 else l1; zl2 = if (abs(l2) < 1e-25) 0.0 else l2
|
||||
zr1 = if (abs(r1) < 1e-25) 0.0 else r1; zr2 = if (abs(r2) < 1e-25) 0.0 else r2
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** RBJ Audio EQ Cookbook coefficients, [b0, b1, b2, a1, a2] normalised by a0. */
|
||||
fun design(type: FilterType, fs: Double, f0: Double, gainDb: Double, q: Double): DoubleArray {
|
||||
val a = 10.0.pow(gainDb / 40.0)
|
||||
val w0 = 2 * PI * f0.coerceAtMost(fs * 0.49) / fs
|
||||
val alpha = sin(w0) / (2 * q)
|
||||
val cw = cos(w0)
|
||||
val sa = 2 * sqrt(a) * alpha
|
||||
val c = when (type) {
|
||||
FilterType.Peak -> doubleArrayOf(1 + alpha * a, -2 * cw, 1 - alpha * a, 1 + alpha / a, -2 * cw, 1 - alpha / a)
|
||||
FilterType.LowShelf -> doubleArrayOf(
|
||||
a * ((a + 1) - (a - 1) * cw + sa), 2 * a * ((a - 1) - (a + 1) * cw), a * ((a + 1) - (a - 1) * cw - sa),
|
||||
(a + 1) + (a - 1) * cw + sa, -2 * ((a - 1) + (a + 1) * cw), (a + 1) + (a - 1) * cw - sa,
|
||||
)
|
||||
FilterType.HighShelf -> doubleArrayOf(
|
||||
a * ((a + 1) + (a - 1) * cw + sa), -2 * a * ((a - 1) + (a + 1) * cw), a * ((a + 1) + (a - 1) * cw - sa),
|
||||
(a + 1) - (a - 1) * cw + sa, 2 * ((a - 1) - (a + 1) * cw), (a + 1) - (a - 1) * cw - sa,
|
||||
)
|
||||
FilterType.LowPass -> doubleArrayOf((1 - cw) / 2, 1 - cw, (1 - cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
|
||||
FilterType.HighPass -> doubleArrayOf((1 + cw) / 2, -(1 + cw), (1 + cw) / 2, 1 + alpha, -2 * cw, 1 - alpha)
|
||||
FilterType.BandPass -> doubleArrayOf(alpha, 0.0, -alpha, 1 + alpha, -2 * cw, 1 - alpha)
|
||||
FilterType.Notch -> doubleArrayOf(1.0, -2 * cw, 1.0, 1 + alpha, -2 * cw, 1 - alpha)
|
||||
FilterType.AllPass -> doubleArrayOf(1 - alpha, -2 * cw, 1 + alpha, 1 + alpha, -2 * cw, 1 - alpha)
|
||||
}
|
||||
val a0 = c[3]
|
||||
return doubleArrayOf(c[0] / a0, c[1] / a0, c[2] / a0, c[4] / a0, c[5] / a0)
|
||||
}
|
||||
|
||||
/** |H| in dB at frequency f. */
|
||||
fun magnitudeDb(c: DoubleArray, fs: Double, f: Double): Double {
|
||||
val w = 2 * PI * f / fs
|
||||
val cw = cos(w); val sw = sin(w); val c2 = cos(2 * w); val s2 = sin(2 * w)
|
||||
val nr = c[0] + c[1] * cw + c[2] * c2
|
||||
val ni = -(c[1] * sw + c[2] * s2)
|
||||
val dr = 1 + c[3] * cw + c[4] * c2
|
||||
val di = -(c[3] * sw + c[4] * s2)
|
||||
return 10 * log10(max(1e-30, nr * nr + ni * ni) / max(1e-30, dr * dr + di * di))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EqMath {
|
||||
/** Combined response of [filters] on one channel, in dB at each of [freqs]. */
|
||||
fun response(filters: List<EqFilter>, freqs: DoubleArray, left: Boolean = true, fs: Double = 48000.0): DoubleArray {
|
||||
val coeffs = filters.filter { it.applies(left) }.flatMap { it.sections(fs) }
|
||||
return DoubleArray(freqs.size) { i -> coeffs.sumOf { Biquad.magnitudeDb(it, fs, freqs[i]) } }
|
||||
}
|
||||
|
||||
fun logFreqs(n: Int, lo: Double = 20.0, hi: Double = 20000.0) =
|
||||
DoubleArray(n) { lo * (hi / lo).pow(it / (n - 1.0)) }
|
||||
|
||||
/**
|
||||
* Anti-clipping preamp: the negative of the curve's highest point (manual preamp included,
|
||||
* louder channel), so a full-scale signal at the loudest frequency still peaks at 0 dBFS.
|
||||
*/
|
||||
fun autoPreampDb(filters: List<EqFilter>, manualDb: Float = 0f): Float {
|
||||
val f = logFreqs(512)
|
||||
val peak = maxOf(response(filters, f, true).maxOrNull() ?: 0.0, response(filters, f, false).maxOrNull() ?: 0.0)
|
||||
return -max(0.0, peak + manualDb).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
class Equalizer {
|
||||
private class Stage(val filter: Biquad, val left: Boolean, val right: Boolean)
|
||||
|
||||
private var stages = emptyList<Stage>()
|
||||
private var shape = emptyList<Any>()
|
||||
private var fs = 0
|
||||
private var params: EqParams? = null
|
||||
private var preamp = 1f
|
||||
var enabled = false; private set
|
||||
|
||||
fun configure(p: EqParams?, sampleRate: Int) {
|
||||
enabled = p != null
|
||||
if (p == null || (p == params && sampleRate == fs)) return
|
||||
val rateChanged = sampleRate != fs
|
||||
fs = sampleRate
|
||||
params = p
|
||||
preamp = dbToGain(p.preampDb)
|
||||
val designed = p.filters.flatMap { f -> f.sections(fs.toDouble()).map { it to f } }
|
||||
// Coefficient changes (a slider drag) keep the filters' state; a different layout starts fresh.
|
||||
val newShape = designed.map { (_, f) -> Triple(f.id, f.channel, f.type) } + designed.size
|
||||
if (rateChanged || newShape != shape) {
|
||||
stages = designed.map { (_, f) -> Stage(Biquad(), f.channel != EqChannel.Right, f.channel != EqChannel.Left) }
|
||||
shape = newShape
|
||||
}
|
||||
designed.forEachIndexed { i, (c, _) -> stages[i].filter.set(c) }
|
||||
}
|
||||
|
||||
fun process(buf: FloatArray, frames: Int) {
|
||||
if (!enabled) return
|
||||
if (preamp != 1f) for (i in 0 until frames * 2) buf[i] *= preamp
|
||||
for (s in stages) s.filter.process(buf, frames, s.left, s.right)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peak limiter on the master bus: stems pushed past unity or an EQ boost never clip.
|
||||
* Instant attack, ~120 ms release, ceiling just under full scale.
|
||||
*/
|
||||
class Limiter(private val ceiling: Float = 0.985f) {
|
||||
private var gain = 1f
|
||||
private var release = 0f
|
||||
|
||||
fun setSampleRate(fs: Int) {
|
||||
release = 1f - exp(-1f / (0.12f * fs))
|
||||
}
|
||||
|
||||
fun process(buf: FloatArray, frames: Int) {
|
||||
var g = gain
|
||||
for (i in 0 until frames) {
|
||||
val l = buf[i * 2]; val r = buf[i * 2 + 1]
|
||||
val peak = max(abs(l), abs(r))
|
||||
val need = if (peak * g > ceiling) ceiling / peak else 1f
|
||||
g = if (need < g) need else g + (1f - g) * release
|
||||
if (peak * g > ceiling) g = ceiling / peak
|
||||
buf[i * 2] = l * g
|
||||
buf[i * 2 + 1] = r * g
|
||||
}
|
||||
gain = g
|
||||
}
|
||||
}
|
||||
|
||||
fun dbToGain(db: Float) = 10f.pow(db / 20f)
|
||||
fun gainToDb(g: Float) = if (g <= 1e-6f) -120f else 20f * log10(g)
|
||||
|
||||
/** Master slider taper (same cubic curve as the desktop app). */
|
||||
fun masterTaper(v: Float) = v.coerceIn(0f, 1f).let { it * it * it }
|
||||
|
||||
/** Stem slider: 0..1 with unity at 0.8 and +3.9 dB at the top. */
|
||||
fun stemTaper(v: Float) = (v.coerceIn(0f, 1f) / 0.8f).let { it * it }
|
||||
fun stemUntaper(g: Float) = (sqrt(g.coerceAtLeast(0f)) * 0.8f).coerceIn(0f, 1f)
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.stemroller.mobile.engine
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaDataSource
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import com.stemroller.mobile.cache.MediaCache
|
||||
import java.io.IOException
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.min
|
||||
|
||||
/** Single-producer single-consumer ring of interleaved stereo float frames. */
|
||||
class FloatRing(capacityFrames: Int) {
|
||||
private val buf = FloatArray(capacityFrames * 2)
|
||||
private val cap = buf.size
|
||||
private var readPos = 0L
|
||||
private var writePos = 0L
|
||||
private val lock = ReentrantLock()
|
||||
private val notFull = lock.newCondition()
|
||||
@Volatile var eos = false
|
||||
|
||||
val availableFrames: Int get() = lock.withLock { ((writePos - readPos) / 2).toInt() }
|
||||
|
||||
/** Blocks while full; returns false if [abort] became true first. */
|
||||
fun write(src: FloatArray, off: Int, len: Int, abort: () -> Boolean): Boolean {
|
||||
var done = 0
|
||||
while (done < len) {
|
||||
lock.withLock {
|
||||
while (writePos - readPos >= cap) {
|
||||
if (abort()) return false
|
||||
notFull.await(20, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
if (abort()) return false
|
||||
val free = (cap - (writePos - readPos)).toInt()
|
||||
val n = min(free, len - done)
|
||||
var w = (writePos % cap).toInt()
|
||||
val first = min(n, cap - w)
|
||||
System.arraycopy(src, off + done, buf, w, first)
|
||||
if (n > first) System.arraycopy(src, off + done + first, buf, 0, n - first)
|
||||
writePos += n
|
||||
done += n
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Copies up to [frames] frames into dst; returns frames copied. Never blocks on data. */
|
||||
fun read(dst: FloatArray, frames: Int): Int = lock.withLock {
|
||||
val n = min(frames * 2L, writePos - readPos).toInt()
|
||||
val r = (readPos % cap).toInt()
|
||||
val first = min(n, cap - r)
|
||||
System.arraycopy(buf, r, dst, 0, first)
|
||||
if (n > first) System.arraycopy(buf, 0, dst, first, n - first)
|
||||
readPos += n
|
||||
notFull.signalAll()
|
||||
n / 2
|
||||
}
|
||||
|
||||
fun clear() = lock.withLock {
|
||||
readPos = writePos
|
||||
eos = false
|
||||
notFull.signalAll()
|
||||
}
|
||||
|
||||
fun wake() = lock.withLock { notFull.signalAll() }
|
||||
}
|
||||
|
||||
/** MediaExtractor's view of a cached resource. Retries brief network trouble before giving up. */
|
||||
class ResourceDataSource(
|
||||
private val res: MediaCache.CachedResource,
|
||||
private val onClose: () -> Unit = {},
|
||||
) : MediaDataSource() {
|
||||
@Volatile var failed: IOException? = null
|
||||
private set
|
||||
@Volatile var closed = false
|
||||
private set
|
||||
private val closing = AtomicBoolean(false)
|
||||
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
val n = res.readAt(position, buffer, offset, size)
|
||||
failed = null
|
||||
if (n > 0 && attempt == 0) res.prefetch()
|
||||
return n
|
||||
} catch (e: IOException) {
|
||||
if (closed || ++attempt > 8) {
|
||||
failed = e
|
||||
throw e
|
||||
}
|
||||
Thread.sleep(250L * attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(): Long = try {
|
||||
res.size()
|
||||
} catch (e: IOException) {
|
||||
failed = e
|
||||
-1
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
if (closing.compareAndSet(false, true)) onClose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes one stem on its own thread into [ring]. Seeks are sample-accurate: frames
|
||||
* before the target are dropped by presentation time, so every stem of a track resumes
|
||||
* on exactly the same frame.
|
||||
*/
|
||||
class StemDecoder(val name: String, private val source: ResourceDataSource, ringFrames: Int) {
|
||||
val ring = FloatRing(ringFrames)
|
||||
private val prepared = CountDownLatch(1)
|
||||
@Volatile var sampleRate = 0; private set
|
||||
@Volatile var durationUs = 0L; private set
|
||||
@Volatile var error: Throwable? = null; private set
|
||||
/** Generation whose data is in the ring (or that hit EOS). */
|
||||
@Volatile var readyGen = -1; private set
|
||||
|
||||
private val seekLock = Object()
|
||||
private var pendingGen = -1
|
||||
private var pendingFrame = 0L
|
||||
@Volatile private var released = false
|
||||
private val thread = Thread({ run() }, "stem-$name")
|
||||
|
||||
fun start(gen: Int, frame: Long) {
|
||||
seek(gen, frame)
|
||||
thread.priority = Thread.MAX_PRIORITY - 1
|
||||
thread.start()
|
||||
}
|
||||
|
||||
/** Blocks until the stream's format is known. False on error or timeout. */
|
||||
fun awaitPrepared(timeoutMs: Long): Boolean = prepared.await(timeoutMs, TimeUnit.MILLISECONDS) && error == null
|
||||
|
||||
fun seek(gen: Int, frame: Long) {
|
||||
synchronized(seekLock) {
|
||||
pendingGen = gen
|
||||
pendingFrame = frame
|
||||
seekLock.notifyAll()
|
||||
}
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private fun seekPending(current: Int) = synchronized(seekLock) { pendingGen != current }
|
||||
|
||||
fun release() {
|
||||
released = true
|
||||
source.close()
|
||||
synchronized(seekLock) { seekLock.notifyAll() }
|
||||
ring.wake()
|
||||
}
|
||||
|
||||
private fun run() {
|
||||
var extractor: MediaExtractor? = null
|
||||
var codec: MediaCodec? = null
|
||||
try {
|
||||
extractor = MediaExtractor().apply { setDataSource(source) }
|
||||
val trackIdx = (0 until extractor.trackCount).firstOrNull {
|
||||
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: throw IOException("no audio in $name")
|
||||
extractor.selectTrack(trackIdx)
|
||||
val fmt = extractor.getTrackFormat(trackIdx)
|
||||
val mime = fmt.getString(MediaFormat.KEY_MIME)!!
|
||||
sampleRate = fmt.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
var channels = fmt.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (fmt.containsKey(MediaFormat.KEY_DURATION)) durationUs = fmt.getLong(MediaFormat.KEY_DURATION)
|
||||
// The FLAC extractor already decodes to audio/raw, whose pass-through "decoder"
|
||||
// echoes whatever encoding it's told without converting, so keep the extractor's.
|
||||
var encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
if (mime == MediaFormat.MIMETYPE_AUDIO_RAW) {
|
||||
if (fmt.containsKey(MediaFormat.KEY_PCM_ENCODING)) encoding = fmt.getInteger(MediaFormat.KEY_PCM_ENCODING)
|
||||
} else {
|
||||
fmt.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_FLOAT)
|
||||
}
|
||||
codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(fmt, null, null, 0)
|
||||
codec.start()
|
||||
codec.outputFormat.let { of ->
|
||||
if (of.containsKey(MediaFormat.KEY_PCM_ENCODING)) encoding = of.getInteger(MediaFormat.KEY_PCM_ENCODING)
|
||||
if (of.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channels = of.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
}
|
||||
prepared.countDown()
|
||||
|
||||
val info = MediaCodec.BufferInfo()
|
||||
var scratch = FloatArray(8192)
|
||||
var gen = -1
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
var firstOut = true
|
||||
var nextFrame = 0L
|
||||
var dropUntil = 0L
|
||||
var lastInputUs = 0L
|
||||
|
||||
while (!released) {
|
||||
synchronized(seekLock) {
|
||||
if (pendingGen != gen) {
|
||||
gen = pendingGen
|
||||
dropUntil = pendingFrame
|
||||
ring.clear()
|
||||
extractor.seekTo(pendingFrame * 1_000_000L / sampleRate, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
codec.flush()
|
||||
inputDone = false
|
||||
outputDone = false
|
||||
firstOut = true
|
||||
} else if (outputDone) {
|
||||
ring.eos = true
|
||||
readyGen = gen
|
||||
seekLock.wait(100)
|
||||
}
|
||||
}
|
||||
if (outputDone || released) continue
|
||||
|
||||
if (!inputDone) {
|
||||
val inIdx = codec.dequeueInputBuffer(2_000)
|
||||
if (inIdx >= 0) {
|
||||
val ib = codec.getInputBuffer(inIdx)!!
|
||||
val n = extractor.readSampleData(ib, 0)
|
||||
if (n < 0) {
|
||||
source.failed?.let { if (durationUs > 0 && lastInputUs < durationUs - 2_000_000) throw it }
|
||||
codec.queueInputBuffer(inIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputDone = true
|
||||
} else {
|
||||
lastInputUs = extractor.sampleTime
|
||||
codec.queueInputBuffer(inIdx, 0, n, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val outIdx = codec.dequeueOutputBuffer(info, 2_000)
|
||||
if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
val of = codec.outputFormat
|
||||
if (of.containsKey(MediaFormat.KEY_PCM_ENCODING)) encoding = of.getInteger(MediaFormat.KEY_PCM_ENCODING)
|
||||
if (of.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) channels = of.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (of.containsKey(MediaFormat.KEY_SAMPLE_RATE)) sampleRate = of.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
continue
|
||||
}
|
||||
if (outIdx < 0) continue
|
||||
if (info.size > 0) {
|
||||
val ob = codec.getOutputBuffer(outIdx)!!.order(ByteOrder.nativeOrder())
|
||||
ob.position(info.offset)
|
||||
ob.limit(info.offset + info.size)
|
||||
val bytesPer = when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_FLOAT, AudioFormat.ENCODING_PCM_32BIT -> 4
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 3
|
||||
else -> 2
|
||||
}
|
||||
val frames = info.size / (bytesPer * channels)
|
||||
if (scratch.size < frames * 2) scratch = FloatArray(frames * 2)
|
||||
toStereo(ob, encoding, channels, frames, scratch)
|
||||
val start = if (firstOut) info.presentationTimeUs * sampleRate / 1_000_000L else nextFrame
|
||||
firstOut = false
|
||||
nextFrame = start + frames
|
||||
val skip = (dropUntil - start).coerceIn(0, frames.toLong()).toInt()
|
||||
if (frames > skip) {
|
||||
val myGen = gen
|
||||
if (ring.write(scratch, skip * 2, (frames - skip) * 2) { released || seekPending(myGen) }) {
|
||||
readyGen = gen
|
||||
}
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(outIdx, false)
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) outputDone = true
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (!released) {
|
||||
Log.w("StemDecoder", "$name failed", t)
|
||||
error = t
|
||||
}
|
||||
ring.eos = true
|
||||
readyGen = Int.MAX_VALUE
|
||||
} finally {
|
||||
prepared.countDown()
|
||||
runCatching { codec?.stop() }
|
||||
runCatching { codec?.release() }
|
||||
runCatching { extractor?.release() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun toStereo(ob: java.nio.ByteBuffer, encoding: Int, channels: Int, frames: Int, out: FloatArray) {
|
||||
when (encoding) {
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> {
|
||||
val fb = ob.asFloatBuffer()
|
||||
if (channels == 2) {
|
||||
fb.get(out, 0, frames * 2)
|
||||
} else {
|
||||
for (i in 0 until frames) {
|
||||
val l = fb.get(i * channels)
|
||||
out[i * 2] = l
|
||||
out[i * 2 + 1] = if (channels > 1) fb.get(i * channels + 1) else l
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioFormat.ENCODING_PCM_32BIT -> {
|
||||
val ib = ob.asIntBuffer()
|
||||
for (i in 0 until frames) {
|
||||
val l = ib.get(i * channels) / 2147483648f
|
||||
out[i * 2] = l
|
||||
out[i * 2 + 1] = if (channels > 1) ib.get(i * channels + 1) / 2147483648f else l
|
||||
}
|
||||
}
|
||||
AudioFormat.ENCODING_PCM_24BIT_PACKED -> {
|
||||
val base = ob.position()
|
||||
fun s24(idx: Int): Float {
|
||||
val p = base + idx * 3
|
||||
val v = (ob.get(p).toInt() and 0xFF) or ((ob.get(p + 1).toInt() and 0xFF) shl 8) or (ob.get(p + 2).toInt() shl 16)
|
||||
return v / 8388608f
|
||||
}
|
||||
for (i in 0 until frames) {
|
||||
val l = s24(i * channels)
|
||||
out[i * 2] = l
|
||||
out[i * 2 + 1] = if (channels > 1) s24(i * channels + 1) else l
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val sb = ob.asShortBuffer()
|
||||
for (i in 0 until frames) {
|
||||
val l = sb.get(i * channels) / 32768f
|
||||
out[i * 2] = l
|
||||
out[i * 2 + 1] = if (channels > 1) sb.get(i * channels + 1) / 32768f else l
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package com.stemroller.mobile.engine
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRouting
|
||||
import android.media.AudioTimestamp
|
||||
import android.media.AudioTrack
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** One track's stems, decoding in sync. Built off the main thread, then handed to the engine. */
|
||||
class TrackSession(val token: Long, val names: List<String>, val decoders: List<StemDecoder>) {
|
||||
val sampleRate get() = decoders.first().sampleRate
|
||||
val durationFrames get() = decoders.maxOf { it.durationUs } * sampleRate / 1_000_000L
|
||||
internal var gen = 0
|
||||
internal var framePos = 0L
|
||||
internal val target = FloatArray(names.size) { 1f }
|
||||
internal val current = FloatArray(names.size) { 1f }
|
||||
internal var mixVersion = -1
|
||||
|
||||
fun error(): Throwable? = decoders.firstNotNullOfOrNull { it.error }
|
||||
|
||||
internal fun ready(need: Int) = decoders.all { d ->
|
||||
d.error != null || (d.readyGen == gen || d.readyGen == Int.MAX_VALUE) && (d.ring.eos || d.ring.availableFrames >= need)
|
||||
}
|
||||
|
||||
internal fun finished() = decoders.all { d ->
|
||||
d.error != null || (d.readyGen == gen || d.readyGen == Int.MAX_VALUE) && d.ring.eos && d.ring.availableFrames == 0
|
||||
}
|
||||
|
||||
/** Re-targets decoders; only valid before the session is handed to the engine. */
|
||||
fun startAt(frame: Long) {
|
||||
gen++
|
||||
framePos = frame
|
||||
decoders.forEach { it.seek(gen, frame) }
|
||||
}
|
||||
|
||||
fun release() = decoders.forEach { it.release() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stem RMS of short blocks as they're mixed, stamped with their track frame, so meters can
|
||||
* show what's coming out of the speaker now. Reading the newest level instead runs ahead of the
|
||||
* audio, and moves in jerks: Android's mixer drains our buffer in bursts (about 5 a second on
|
||||
* deep-buffer outputs), so writes, and with them fresh levels, arrive in clumps.
|
||||
*/
|
||||
class MeterHistory {
|
||||
private val tokens = LongArray(CAP)
|
||||
private val starts = LongArray(CAP)
|
||||
private val values = FloatArray(CAP * MAX_STEMS)
|
||||
private var head = 0
|
||||
private var count = 0
|
||||
|
||||
@Synchronized fun clear() {
|
||||
count = 0
|
||||
}
|
||||
|
||||
@Synchronized fun add(token: Long, start: Long, rms: FloatArray, offset: Int, stems: Int) {
|
||||
tokens[head] = token
|
||||
starts[head] = start
|
||||
System.arraycopy(rms, offset, values, head * MAX_STEMS, min(stems, MAX_STEMS))
|
||||
if (stems < MAX_STEMS) java.util.Arrays.fill(values, head * MAX_STEMS + stems, (head + 1) * MAX_STEMS, 0f)
|
||||
head = (head + 1) % CAP
|
||||
count = min(count + 1, CAP)
|
||||
}
|
||||
|
||||
/** Fills [out] with the levels of [token]'s block playing at [frame]; false if there's none. */
|
||||
@Synchronized fun read(token: Long, frame: Long, out: FloatArray): Boolean {
|
||||
for (k in 1..count) {
|
||||
val i = (head - k + CAP) % CAP
|
||||
if (tokens[i] != token || starts[i] > frame) continue
|
||||
if (frame - starts[i] > STALE_FRAMES) return false
|
||||
System.arraycopy(values, i * MAX_STEMS, out, 0, min(out.size, MAX_STEMS))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BLOCK = 256
|
||||
const val MAX_STEMS = 8
|
||||
/** ~5 s at 48 kHz: well past the AudioTrack's buffer. */
|
||||
private const val CAP = 1024
|
||||
private const val STALE_FRAMES = 8192L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixes a [TrackSession]'s stems into one AudioTrack: per-stem gain (ramped), EQ with
|
||||
* anti-clip preamp, master gain, then a peak limiter. All AudioTrack calls happen on the
|
||||
* engine thread; the public methods just queue commands.
|
||||
*/
|
||||
class StemEngine(private val listener: Listener) {
|
||||
interface Listener {
|
||||
fun onEngineState()
|
||||
/** [token]'s audio is now what's coming out of the speaker. */
|
||||
fun onTransition(token: Long)
|
||||
fun onEnded(token: Long)
|
||||
fun onError(token: Long, message: String)
|
||||
fun onRouted(device: AudioDeviceInfo?)
|
||||
}
|
||||
|
||||
/** Where playback is; position = frame + (now - nanos) while [running]. */
|
||||
data class Snapshot(val token: Long, val frame: Long, val rate: Int, val nanos: Long, val running: Boolean) {
|
||||
fun positionMs(now: Long = System.nanoTime()): Long {
|
||||
if (rate <= 0) return 0
|
||||
val f = if (running) frame + (now - nanos) * rate / 1_000_000_000L else frame
|
||||
return f * 1000 / rate
|
||||
}
|
||||
}
|
||||
|
||||
private class Segment(val writtenStart: Long, val token: Long, val frameStart: Long)
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val lock = Object()
|
||||
private val commands = ArrayDeque<() -> Unit>()
|
||||
@Volatile private var released = false
|
||||
|
||||
private var session: TrackSession? = null
|
||||
private var next: TrackSession? = null
|
||||
private var wantPlay = false
|
||||
private var track: AudioTrack? = null
|
||||
private var trackRate = 0
|
||||
private var written = 0L
|
||||
private val segments = ArrayDeque<Segment>()
|
||||
private var drainUntil = -1L
|
||||
private var drainDeadline = 0L
|
||||
private var drainToken = -1L
|
||||
private var waitingSince = 0L
|
||||
private var errorReported = -1L
|
||||
private val eq = Equalizer()
|
||||
private val limiter = Limiter()
|
||||
private var masterCur = 1f
|
||||
private val ts = AudioTimestamp()
|
||||
private var tsValid = false
|
||||
private var tsQueried = 0L
|
||||
private var tsFrame = 0L
|
||||
private var tsNanos = 0L
|
||||
private var out = FloatArray(CHUNK * 2)
|
||||
private var tmp = FloatArray(CHUNK * 2)
|
||||
|
||||
@Volatile var eqParams: EqParams? = null
|
||||
@Volatile var masterGain = 1f
|
||||
@Volatile private var mix: Map<String, Float> = emptyMap()
|
||||
@Volatile private var mixVersion = 0
|
||||
|
||||
@Volatile var snapshot = Snapshot(-1, 0, 44100, 0, false); private set
|
||||
@Volatile var playing = false; private set
|
||||
@Volatile var buffering = false; private set
|
||||
/** Post-gain RMS per stem in short blocks, for the mixer's meters; see [levelsNow]. */
|
||||
private val meters = MeterHistory()
|
||||
private var meterSums = FloatArray(CHUNK / MeterHistory.BLOCK * MeterHistory.MAX_STEMS)
|
||||
|
||||
private val routing = AudioRouting.OnRoutingChangedListener { r -> listener.onRouted(r.routedDevice) }
|
||||
private val thread = Thread({ loop() }, "stem-engine").apply { start() }
|
||||
|
||||
// ---- commands (any thread) --------------------------------------------------------
|
||||
|
||||
private fun post(cmd: () -> Unit) = synchronized(lock) {
|
||||
commands.addLast(cmd)
|
||||
lock.notifyAll()
|
||||
}
|
||||
|
||||
/** Replaces whatever is playing with [s], which must already be prepared. */
|
||||
fun load(s: TrackSession, play: Boolean) = post {
|
||||
dropAll()
|
||||
session = s
|
||||
wantPlay = play
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
|
||||
/** The track to continue into gaplessly when the current one ends (or null). */
|
||||
fun queueNext(s: TrackSession?) = post {
|
||||
if (next !== s) next?.release()
|
||||
next = s
|
||||
}
|
||||
|
||||
fun setPlaying(play: Boolean) = post {
|
||||
wantPlay = play
|
||||
if (!play) track?.pause()
|
||||
publish()
|
||||
}
|
||||
|
||||
fun seekTo(ms: Long) = post {
|
||||
val s = session ?: return@post
|
||||
val frame = (ms * s.sampleRate / 1000).coerceIn(0, max(0, s.durationFrames - 1))
|
||||
s.gen++
|
||||
s.framePos = frame
|
||||
s.decoders.forEach { it.seek(s.gen, frame) }
|
||||
resetOutput(s)
|
||||
publish()
|
||||
}
|
||||
|
||||
fun stop() = post {
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
}
|
||||
|
||||
fun setMix(gains: Map<String, Float>) {
|
||||
mix = gains
|
||||
mixVersion++
|
||||
}
|
||||
|
||||
fun release() {
|
||||
released = true
|
||||
post { }
|
||||
}
|
||||
|
||||
/**
|
||||
* The meters' levels for what's audible right now, into [out] (one per stem). False when
|
||||
* nothing is playing, so the meters can fall.
|
||||
*/
|
||||
fun levelsNow(out: FloatArray): Boolean {
|
||||
val snap = snapshot
|
||||
if (!snap.running || snap.rate <= 0) return false
|
||||
val frame = snap.frame + (System.nanoTime() - snap.nanos) * snap.rate / 1_000_000_000L
|
||||
return meters.read(snap.token, frame, out)
|
||||
}
|
||||
|
||||
// ---- engine thread ----------------------------------------------------------------
|
||||
|
||||
private fun dropAll() {
|
||||
session?.release()
|
||||
next?.release()
|
||||
session = null
|
||||
next = null
|
||||
drainUntil = -1
|
||||
track?.let { it.pause(); it.flush() }
|
||||
written = 0
|
||||
segments.clear()
|
||||
}
|
||||
|
||||
private fun resetOutput(s: TrackSession) {
|
||||
track?.let { it.pause(); it.flush() }
|
||||
tsValid = false
|
||||
written = 0
|
||||
segments.clear()
|
||||
segments.addLast(Segment(0, s.token, s.framePos))
|
||||
drainUntil = -1
|
||||
waitingSince = 0
|
||||
meters.clear()
|
||||
errorReported = -1
|
||||
}
|
||||
|
||||
private fun loop() {
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
|
||||
while (!released) {
|
||||
while (true) {
|
||||
val cmd = synchronized(lock) { commands.removeFirstOrNull() } ?: break
|
||||
cmd()
|
||||
}
|
||||
try {
|
||||
step()
|
||||
} catch (t: Throwable) {
|
||||
val tok = session?.token ?: -1
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
publish()
|
||||
main.post { listener.onError(tok, t.message ?: t.javaClass.simpleName) }
|
||||
}
|
||||
}
|
||||
dropAll()
|
||||
track?.release()
|
||||
}
|
||||
|
||||
private fun idle(ms: Long) = synchronized(lock) { if (commands.isEmpty()) lock.wait(ms) }
|
||||
|
||||
private fun step() {
|
||||
val s = session
|
||||
if (s == null || !wantPlay) {
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
idle(100)
|
||||
return
|
||||
}
|
||||
s.error()?.let { e ->
|
||||
if (errorReported != s.token) {
|
||||
errorReported = s.token
|
||||
wantPlay = false
|
||||
track?.pause()
|
||||
setFlags(false, false)
|
||||
main.post { listener.onError(s.token, e.message ?: "Playback failed") }
|
||||
}
|
||||
idle(50)
|
||||
return
|
||||
}
|
||||
if (drainUntil >= 0) {
|
||||
drain(s)
|
||||
return
|
||||
}
|
||||
val need = if (written == 0L || buffering) PREFILL else CHUNK
|
||||
if (!s.ready(need) && !s.finished()) {
|
||||
val now = System.nanoTime()
|
||||
if (waitingSince == 0L) waitingSince = now
|
||||
if (!buffering && (written == 0L || now - waitingSince > 60_000_000L)) {
|
||||
track?.pause()
|
||||
setFlags(playing = false, buffering = true)
|
||||
}
|
||||
publish()
|
||||
idle(5)
|
||||
return
|
||||
}
|
||||
waitingSince = 0
|
||||
ensureTrack(s.sampleRate)
|
||||
val t = track!!
|
||||
if (t.playState != AudioTrack.PLAYSTATE_PLAYING) t.play()
|
||||
setFlags(playing = true, buffering = false)
|
||||
|
||||
val n = mixChunk(s)
|
||||
if (n == 0) {
|
||||
if (s.finished()) endOfTrack(s) else idle(2)
|
||||
return
|
||||
}
|
||||
eq.configure(eqParams, s.sampleRate)
|
||||
eq.process(out, n)
|
||||
val m = masterGain
|
||||
rampGain(out, n, masterCur, m)
|
||||
masterCur = m
|
||||
limiter.process(out, n)
|
||||
var off = 0
|
||||
while (off < n * 2 && !released) {
|
||||
val w = t.write(out, off, n * 2 - off, AudioTrack.WRITE_BLOCKING)
|
||||
if (w < 0) throw IllegalStateException("AudioTrack write failed ($w)")
|
||||
off += w
|
||||
}
|
||||
written += n
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun endOfTrack(s: TrackSession) {
|
||||
val nx = next
|
||||
if (nx != null && nx.sampleRate == s.sampleRate && nx.error() == null) {
|
||||
// Gapless: keep writing into the same AudioTrack.
|
||||
segments.addLast(Segment(written, nx.token, nx.framePos))
|
||||
session = nx
|
||||
next = null
|
||||
s.release()
|
||||
return
|
||||
}
|
||||
// Let the AudioTrack play out, then hand over.
|
||||
drainUntil = written
|
||||
drainToken = s.token
|
||||
drainDeadline = System.nanoTime() + (written * 1_000_000_000L / max(1, trackRate)) + 1_000_000_000L
|
||||
}
|
||||
|
||||
private fun drain(s: TrackSession) {
|
||||
val (presented, _) = presented()
|
||||
publish()
|
||||
if (presented < drainUntil - 64 && System.nanoTime() < drainDeadline) {
|
||||
idle(10)
|
||||
return
|
||||
}
|
||||
drainUntil = -1
|
||||
val nx = next
|
||||
if (nx != null && nx.error() == null) {
|
||||
session = nx
|
||||
next = null
|
||||
s.release()
|
||||
resetOutput(nx)
|
||||
main.post { listener.onTransition(nx.token) }
|
||||
} else {
|
||||
val tok = drainToken
|
||||
dropAll()
|
||||
wantPlay = false
|
||||
setFlags(false, false)
|
||||
publish()
|
||||
main.post { listener.onEnded(tok) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun mixChunk(s: TrackSession): Int {
|
||||
if (s.finished()) return 0
|
||||
var n = CHUNK
|
||||
for (d in s.decoders) {
|
||||
if (d.error != null || d.ring.eos && d.ring.availableFrames == 0) continue
|
||||
n = min(n, d.ring.availableFrames)
|
||||
}
|
||||
if (n <= 0) return 0
|
||||
if (s.mixVersion != mixVersion) {
|
||||
val g = mix
|
||||
for (i in s.names.indices) s.target[i] = g[s.names[i]] ?: 1f
|
||||
s.mixVersion = mixVersion
|
||||
}
|
||||
java.util.Arrays.fill(out, 0, n * 2, 0f)
|
||||
val stride = MeterHistory.MAX_STEMS
|
||||
val blocks = (n + MeterHistory.BLOCK - 1) / MeterHistory.BLOCK
|
||||
if (meterSums.size < blocks * stride) meterSums = FloatArray(blocks * stride)
|
||||
java.util.Arrays.fill(meterSums, 0f)
|
||||
for ((i, d) in s.decoders.withIndex()) {
|
||||
val got = d.ring.read(tmp, n)
|
||||
if (got < n) java.util.Arrays.fill(tmp, got * 2, n * 2, 0f)
|
||||
val g0 = s.current[i]
|
||||
val g1 = s.target[i]
|
||||
s.current[i] = g1
|
||||
if (g0 == 0f && g1 == 0f) continue
|
||||
val step = (g1 - g0) / n
|
||||
var g = g0
|
||||
for (b in 0 until blocks) {
|
||||
val end = min(n, (b + 1) * MeterHistory.BLOCK)
|
||||
var sum = 0f
|
||||
for (f in b * MeterHistory.BLOCK until end) {
|
||||
val l = tmp[f * 2] * g
|
||||
val r = tmp[f * 2 + 1] * g
|
||||
out[f * 2] += l
|
||||
out[f * 2 + 1] += r
|
||||
sum += l * l + r * r
|
||||
g += step
|
||||
}
|
||||
if (i < stride) meterSums[b * stride + i] = sqrt(sum / (2 * (end - b * MeterHistory.BLOCK)))
|
||||
}
|
||||
}
|
||||
for (b in 0 until blocks) meters.add(s.token, s.framePos + b * MeterHistory.BLOCK, meterSums, b * stride, s.names.size)
|
||||
s.framePos += n
|
||||
return n
|
||||
}
|
||||
|
||||
private fun rampGain(buf: FloatArray, n: Int, from: Float, to: Float) {
|
||||
if (from == 1f && to == 1f) return
|
||||
val step = (to - from) / n
|
||||
var g = from
|
||||
for (f in 0 until n) {
|
||||
buf[f * 2] *= g
|
||||
buf[f * 2 + 1] *= g
|
||||
g += step
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureTrack(rate: Int) {
|
||||
if (track != null && trackRate == rate) return
|
||||
track?.let {
|
||||
it.removeOnRoutingChangedListener(routing)
|
||||
it.release()
|
||||
}
|
||||
val minBuf = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT)
|
||||
val bytes = max(minBuf * 2, rate * 8 * BUFFER_MS / 1000)
|
||||
val t = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_FLOAT).setSampleRate(rate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(),
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bytes)
|
||||
.build()
|
||||
t.addOnRoutingChangedListener(routing, main)
|
||||
track = t
|
||||
trackRate = rate
|
||||
limiter.setSampleRate(rate)
|
||||
val routed = t.routedDevice
|
||||
main.post { listener.onRouted(routed) }
|
||||
}
|
||||
|
||||
/**
|
||||
* (frames presented since the last flush, at nanoTime). Timestamps are polled every
|
||||
* half second, as AudioTrack recommends, and extrapolated in between.
|
||||
*/
|
||||
private fun presented(): Pair<Long, Long> {
|
||||
val now = System.nanoTime()
|
||||
val t = track ?: return 0L to now
|
||||
if (t.playState != AudioTrack.PLAYSTATE_PLAYING) {
|
||||
tsValid = false
|
||||
} else {
|
||||
if (!tsValid || now - tsQueried > 500_000_000L) {
|
||||
tsQueried = now
|
||||
tsValid = t.getTimestamp(ts) && ts.framePosition in 0..written
|
||||
if (tsValid) {
|
||||
tsFrame = ts.framePosition
|
||||
tsNanos = ts.nanoTime
|
||||
}
|
||||
}
|
||||
if (tsValid) return min(tsFrame + (now - tsNanos) * trackRate / 1_000_000_000L, written) to now
|
||||
}
|
||||
return min(t.playbackHeadPosition.toLong() and 0xFFFFFFFFL, written) to now
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val (p, nanos) = presented()
|
||||
while (segments.size > 1 && segments[1].writtenStart <= p) {
|
||||
segments.removeFirst()
|
||||
val tok = segments.first().token
|
||||
main.post { listener.onTransition(tok) }
|
||||
}
|
||||
val seg = segments.firstOrNull()
|
||||
val s = session
|
||||
val rate = if (s != null && s.decoders.isNotEmpty()) s.sampleRate else trackRate
|
||||
snapshot = if (seg == null) {
|
||||
Snapshot(s?.token ?: -1, s?.framePos ?: 0, rate, nanos, false)
|
||||
} else {
|
||||
val running = playing && drainUntil < 0 || drainUntil >= 0 && p < drainUntil
|
||||
Snapshot(seg.token, seg.frameStart + max(0, p - seg.writtenStart), rate, nanos, running)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setFlags(playing: Boolean, buffering: Boolean) {
|
||||
if (playing == this.playing && buffering == this.buffering) return
|
||||
this.playing = playing
|
||||
this.buffering = buffering
|
||||
main.post { listener.onEngineState() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHUNK = 1024
|
||||
/** Per stem, decoded ahead of the mixer. */
|
||||
const val RING_SECONDS = 3
|
||||
/** Frames each stem must have before (re)starting output. */
|
||||
const val PREFILL = 8192
|
||||
const val BUFFER_MS = 120
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.stemroller.mobile.eq
|
||||
|
||||
import com.stemroller.mobile.engine.EQ_Q
|
||||
import com.stemroller.mobile.engine.EqChannel
|
||||
import com.stemroller.mobile.engine.EqFilter
|
||||
import com.stemroller.mobile.engine.FilterType
|
||||
import java.util.Locale
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Equalizer APO's config text, the format AutoEQ publishes headphone corrections in
|
||||
* (ParametricEQ.txt):
|
||||
*
|
||||
* Preamp: -6.2 dB
|
||||
* Filter 1: ON LSC Fc 105 Hz Gain 6.0 dB Q 0.70
|
||||
* Filter 2: ON PK Fc 2300 Hz Gain -3.1 dB Q 1.41
|
||||
*
|
||||
* Also understood: "Channel: L" / "R" / "all" lines, BW Oct instead of Q, and the older
|
||||
* type names (LS, HS, LP, HP, PEQ ...). Other commands (GraphicEQ, Delay, Copy) are skipped.
|
||||
*/
|
||||
object ApoConfig {
|
||||
class Result(val filters: List<EqFilter>, val preamp: Float?, val skipped: Int)
|
||||
|
||||
private val TYPES = mapOf(
|
||||
"PK" to FilterType.Peak, "PEQ" to FilterType.Peak, "MODAL" to FilterType.Peak,
|
||||
"LS" to FilterType.LowShelf, "LSC" to FilterType.LowShelf, "LSQ" to FilterType.LowShelf,
|
||||
"HS" to FilterType.HighShelf, "HSC" to FilterType.HighShelf, "HSQ" to FilterType.HighShelf,
|
||||
"LP" to FilterType.LowPass, "LPQ" to FilterType.LowPass,
|
||||
"HP" to FilterType.HighPass, "HPQ" to FilterType.HighPass,
|
||||
"BP" to FilterType.BandPass, "NO" to FilterType.Notch, "AP" to FilterType.AllPass,
|
||||
)
|
||||
|
||||
/** Null when the text has no preamp or filter lines at all. */
|
||||
fun parse(text: String): Result? {
|
||||
val filters = ArrayList<EqFilter>()
|
||||
var preamp: Float? = null
|
||||
var channel = EqChannel.Both
|
||||
var skipped = 0
|
||||
for (raw in text.lines()) {
|
||||
val line = raw.substringBefore('#').trim()
|
||||
if (line.isEmpty()) continue
|
||||
val colon = line.indexOf(':')
|
||||
if (colon < 0) continue
|
||||
val cmd = line.substring(0, colon).trim().lowercase(Locale.ROOT)
|
||||
val args = line.substring(colon + 1).trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
when {
|
||||
cmd == "preamp" -> preamp = (preamp ?: 0f) + (args.firstOrNull()?.toFloatOrNull() ?: continue)
|
||||
cmd == "channel" -> channel = when (args.map { it.uppercase(Locale.ROOT) }.toSet()) {
|
||||
setOf("L") -> EqChannel.Left
|
||||
setOf("R") -> EqChannel.Right
|
||||
else -> EqChannel.Both
|
||||
}
|
||||
cmd.startsWith("filter") -> {
|
||||
val f = parseFilter(args, channel)
|
||||
if (f != null) filters += f else skipped++
|
||||
}
|
||||
else -> skipped++
|
||||
}
|
||||
}
|
||||
if (filters.isEmpty() && preamp == null) return null
|
||||
return Result(filters, preamp, skipped)
|
||||
}
|
||||
|
||||
private fun parseFilter(args: List<String>, channel: EqChannel): EqFilter? {
|
||||
var i = 0
|
||||
var on = true
|
||||
when (args.getOrNull(0)?.uppercase(Locale.ROOT)) {
|
||||
"ON" -> i++
|
||||
"OFF" -> { on = false; i++ }
|
||||
}
|
||||
val type = TYPES[args.getOrNull(i)?.uppercase(Locale.ROOT)] ?: return null
|
||||
i++
|
||||
var freq: Float? = null
|
||||
var gain = 0f
|
||||
var q: Float? = null
|
||||
var slope = 12
|
||||
while (i < args.size) {
|
||||
val key = args[i].lowercase(Locale.ROOT)
|
||||
val num = args.getOrNull(i + 1)?.toFloatOrNull()
|
||||
when {
|
||||
key == "fc" && num != null -> { freq = num; i += 2 }
|
||||
key == "gain" && num != null -> { gain = num; i += 2 }
|
||||
key == "q" && num != null -> { q = num; i += 2 }
|
||||
key == "bw" && args.getOrNull(i + 1)?.lowercase(Locale.ROOT) == "oct" -> {
|
||||
args.getOrNull(i + 2)?.toFloatOrNull()?.let { bw -> q = (sqrt(2.0.pow(bw.toDouble())) / (2.0.pow(bw.toDouble()) - 1)).toFloat() }
|
||||
i += 3
|
||||
}
|
||||
// "LSC 12dB Fc ...": a shelf or pass slope in dB per octave.
|
||||
key.endsWith("db") && key.removeSuffix("db").toFloatOrNull() != null -> {
|
||||
slope = key.removeSuffix("db").toFloat().toInt(); i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
val fc = freq ?: return null
|
||||
val defaultQ = when (type) {
|
||||
FilterType.Peak -> EQ_Q
|
||||
else -> 0.707f
|
||||
}
|
||||
return EqFilter(
|
||||
EqController.newFilterId(), type, fc, if (type.hasGain) gain else 0f, q ?: defaultQ,
|
||||
slope = if (type.hasSlope) slope.coerceIn(6, 48) else 12, channel = channel, on = on,
|
||||
)
|
||||
}
|
||||
|
||||
/** The profile as an Equalizer APO config: preamp, graphic bands as peaks, then the filters. */
|
||||
fun format(p: EqProfile): String = buildString {
|
||||
fun n(v: Float) = String.format(Locale.ROOT, "%.2f", v).trimEnd('0').trimEnd('.')
|
||||
appendLine("# ${p.name}, from StemRoller")
|
||||
appendLine("Preamp: ${n(p.preamp)} dB")
|
||||
var channel = EqChannel.Both
|
||||
var k = 1
|
||||
for (f in EqFilter.graphic(p.gains) + p.filters) {
|
||||
if (f.channel != channel) {
|
||||
channel = f.channel
|
||||
appendLine("Channel: " + when (channel) { EqChannel.Left -> "L"; EqChannel.Right -> "R"; EqChannel.Both -> "all" })
|
||||
}
|
||||
val head = "${if (f.on) "ON" else "OFF"} ${f.type.apo} Fc ${n(f.freq)} Hz"
|
||||
when {
|
||||
f.type.hasGain -> appendLine("Filter ${k++}: $head Gain ${n(f.gain)} dB Q ${n(f.q)}")
|
||||
!f.type.hasSlope || f.slope == 12 -> appendLine("Filter ${k++}: $head Q ${n(f.q)}")
|
||||
f.slope < 12 -> {
|
||||
appendLine("# 6 dB/oct ${f.type.label.lowercase(Locale.ROOT)}: APO has no first-order filter, so this is a gentle 12 dB/oct one")
|
||||
appendLine("Filter ${k++}: $head Q 0.5")
|
||||
}
|
||||
else -> {
|
||||
// Steeper slopes are Butterworth: one APO filter per second-order section.
|
||||
val order = f.slope / 6
|
||||
for (j in 1..order / 2) {
|
||||
val q = (1 / (2 * kotlin.math.cos((2 * j - 1) * Math.PI / (2 * order)))).toFloat()
|
||||
appendLine("Filter ${k++}: $head Q ${n(q)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.stemroller.mobile.eq
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.engine.EQ_BANDS
|
||||
import com.stemroller.mobile.engine.EQ_MAX_DB
|
||||
import com.stemroller.mobile.engine.EqFilter
|
||||
import com.stemroller.mobile.engine.EqMath
|
||||
import com.stemroller.mobile.engine.EqParams
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
enum class DeviceKind { Speaker, Wired, Bluetooth, Usb, Hdmi, Other }
|
||||
|
||||
/** An output route, identified by type + product name (BT addresses are anonymised). */
|
||||
@Serializable
|
||||
data class OutputDevice(val type: Int, val name: String) {
|
||||
val id get() = "$type|$name"
|
||||
val kind get() = kindOf(type)
|
||||
val label: String
|
||||
get() = when (kind) {
|
||||
DeviceKind.Speaker -> "Phone Speaker"
|
||||
DeviceKind.Wired -> if (name.isBlank() || name == "USB-C to 3.5mm") "Wired Headphones" else name
|
||||
else -> name.ifBlank { kind.name }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SPEAKER = OutputDevice(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, "")
|
||||
|
||||
fun of(info: AudioDeviceInfo): OutputDevice {
|
||||
val kind = kindOf(info.type)
|
||||
// The built-in speaker's product name is the phone model; one speaker profile is enough.
|
||||
val name = if (kind == DeviceKind.Speaker) "" else info.productName?.toString().orEmpty()
|
||||
val type = if (kind == DeviceKind.Speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else info.type
|
||||
return OutputDevice(type, name)
|
||||
}
|
||||
|
||||
fun kindOf(type: Int) = when (type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> DeviceKind.Speaker
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_LINE_ANALOG -> DeviceKind.Wired
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO, AudioDeviceInfo.TYPE_BLE_HEADSET,
|
||||
AudioDeviceInfo.TYPE_BLE_SPEAKER, AudioDeviceInfo.TYPE_BLE_BROADCAST, AudioDeviceInfo.TYPE_HEARING_AID -> DeviceKind.Bluetooth
|
||||
AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY,
|
||||
AudioDeviceInfo.TYPE_DOCK, AudioDeviceInfo.TYPE_DOCK_ANALOG -> DeviceKind.Usb
|
||||
AudioDeviceInfo.TYPE_HDMI, AudioDeviceInfo.TYPE_HDMI_ARC, AudioDeviceInfo.TYPE_HDMI_EARC -> DeviceKind.Hdmi
|
||||
else -> DeviceKind.Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EqProfile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val gains: List<Float> = List(EQ_BANDS.size) { 0f },
|
||||
val devices: List<OutputDevice> = emptyList(),
|
||||
/** Parametric filters, after the graphic bands. */
|
||||
val filters: List<EqFilter> = emptyList(),
|
||||
/** Manual preamp in dB, ahead of the anti-clip one (Equalizer APO's "Preamp:"). */
|
||||
val preamp: Float = 0f,
|
||||
) {
|
||||
/** Everything the engine runs: graphic bands as peaks, then the filters that are on. */
|
||||
val chain get() = EqFilter.graphic(gains) + filters.filter { it.active }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EqState(
|
||||
val enabled: Boolean = true,
|
||||
val automatic: Boolean = true,
|
||||
/** Picked by hand; wins over automatic switching until cleared. */
|
||||
val manualId: String? = null,
|
||||
val defaultId: String = "flat",
|
||||
val antiClip: Boolean = true,
|
||||
val profiles: List<EqProfile> = listOf(EqProfile("flat", "Flat")),
|
||||
val seenDevices: List<OutputDevice> = emptyList(),
|
||||
)
|
||||
|
||||
/** iOS Music's EQ list, approximated on ten octave bands. */
|
||||
val EQ_PRESETS: List<Pair<String, List<Float>>> = listOf(
|
||||
"Flat" to listOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f),
|
||||
"Acoustic" to listOf(4.5f, 4.5f, 3.5f, 1f, 1.5f, 1.5f, 3f, 3.5f, 3f, 2f),
|
||||
"Bass Booster" to listOf(5.5f, 4.5f, 3.5f, 2.5f, 1.5f, 0f, 0f, 0f, 0f, 0f),
|
||||
"Bass Reducer" to listOf(-5.5f, -4.5f, -3.5f, -2.5f, -1.5f, 0f, 0f, 0f, 0f, 0f),
|
||||
"Classical" to listOf(4.5f, 3.5f, 3f, 2.5f, -1.5f, -1.5f, 0f, 2f, 3f, 3.5f),
|
||||
"Dance" to listOf(3.5f, 6.5f, 5f, 0f, 2f, 3.5f, 5f, 4.5f, 3.5f, 0f),
|
||||
"Deep" to listOf(5f, 3.5f, 1.5f, 1f, 3f, 2.5f, 1.5f, -2f, -3.5f, -4.5f),
|
||||
"Electronic" to listOf(4f, 3.5f, 1f, 0f, -2f, 2f, 1f, 1.5f, 4f, 5f),
|
||||
"Hip-Hop" to listOf(5f, 4f, 1.5f, 3f, -1f, -1f, 1.5f, -0.5f, 2f, 3f),
|
||||
"Jazz" to listOf(4f, 3f, 1.5f, 2f, -1.5f, -1.5f, 0f, 1.5f, 3f, 3.5f),
|
||||
"Late Night" to listOf(4.5f, 3f, 2f, -0.5f, 1.5f, 1.5f, 2f, 3f, 3.5f, 4f),
|
||||
"Loudness" to listOf(6f, 4f, 0f, 0f, -2f, 0f, -1f, -5f, 5f, 1f),
|
||||
"Piano" to listOf(3f, 2f, 0f, 2.5f, 3f, 1.5f, 3.5f, 4.5f, 3f, 3.5f),
|
||||
"Pop" to listOf(-1.5f, -1f, 0f, 2f, 4f, 4f, 2f, 0f, -1f, -1.5f),
|
||||
"R&B" to listOf(2.5f, 7f, 5.5f, 1.5f, -2.5f, -1.5f, 2f, 2.5f, 3f, 3.5f),
|
||||
"Rock" to listOf(5f, 4f, 3f, 1.5f, -0.5f, -1f, 0.5f, 2.5f, 3.5f, 4.5f),
|
||||
"Small Speakers" to listOf(5.5f, 4.5f, 3.5f, 2.5f, 1.5f, 0f, -1.5f, -2.5f, -3.5f, -4f),
|
||||
"Spoken Word" to listOf(-3.5f, -0.5f, 0f, 0.5f, 3.5f, 4.5f, 4.5f, 4f, 2f, 0f),
|
||||
"Treble Booster" to listOf(0f, 0f, 0f, 0f, 0f, 1.5f, 2.5f, 3.5f, 4.5f, 5.5f),
|
||||
"Treble Reducer" to listOf(0f, 0f, 0f, 0f, 0f, -1.5f, -2.5f, -3.5f, -4.5f, -5.5f),
|
||||
"Vocal Booster" to listOf(-1.5f, -3f, -3f, 1.5f, 3.5f, 3.5f, 3f, 1.5f, 0f, -1.5f),
|
||||
)
|
||||
|
||||
/**
|
||||
* EQ profiles and the current output route. In automatic mode the active profile is the
|
||||
* one linked to the route (else the default); a hand-picked profile overrides that until
|
||||
* the user goes back to automatic.
|
||||
*/
|
||||
class EqController(context: Context) {
|
||||
private val file = File(context.filesDir, "eq.json")
|
||||
private val am = context.getSystemService(AudioManager::class.java)
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
private val saveTask = Runnable {
|
||||
runCatching {
|
||||
val tmp = File(file.parentFile, "eq.json.tmp")
|
||||
tmp.writeText(AppJson.encodeToString(EqState.serializer(), state.value))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val _state = MutableStateFlow(load())
|
||||
val state: StateFlow<EqState> = _state.asStateFlow()
|
||||
private val _device = MutableStateFlow(OutputDevice.SPEAKER)
|
||||
val device: StateFlow<OutputDevice> = _device.asStateFlow()
|
||||
private val _active = MutableStateFlow(resolve(_state.value, _device.value))
|
||||
val active: StateFlow<EqProfile?> = _active.asStateFlow()
|
||||
|
||||
var onParams: ((EqParams?) -> Unit)? = null
|
||||
|
||||
init {
|
||||
am.registerAudioDeviceCallback(object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(added: Array<out AudioDeviceInfo>) = refreshRoute()
|
||||
override fun onAudioDevicesRemoved(removed: Array<out AudioDeviceInfo>) = refreshRoute()
|
||||
}, main)
|
||||
refreshRoute()
|
||||
}
|
||||
|
||||
/** Where media would go right now, for when no AudioTrack is open. */
|
||||
fun refreshRoute() {
|
||||
val attrs = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build()
|
||||
am.getAudioDevicesForAttributes(attrs).firstOrNull()?.let { setDevice(OutputDevice.of(it)) }
|
||||
}
|
||||
|
||||
/** From the engine's AudioTrack routing callback: the authoritative route while playing. */
|
||||
fun onRouted(info: AudioDeviceInfo?) {
|
||||
if (info != null) setDevice(OutputDevice.of(info)) else refreshRoute()
|
||||
}
|
||||
|
||||
private fun setDevice(d: OutputDevice) {
|
||||
if (_device.value == d && d in _state.value.seenDevices) return
|
||||
_device.value = d
|
||||
val s = _state.value
|
||||
if (d !in s.seenDevices) update(s.copy(seenDevices = (listOf(d) + s.seenDevices).take(24))) else publish()
|
||||
}
|
||||
|
||||
// ---- edits ------------------------------------------------------------------------
|
||||
|
||||
fun setEnabled(on: Boolean) = update(_state.value.copy(enabled = on))
|
||||
fun setAntiClip(on: Boolean) = update(_state.value.copy(antiClip = on))
|
||||
|
||||
/** Automatic switching on clears the manual pick. */
|
||||
fun setAutomatic(on: Boolean) = update(_state.value.copy(automatic = on, manualId = if (on) null else _state.value.manualId ?: _active.value?.id))
|
||||
|
||||
/** Picking a profile by hand overrides automatic switching. */
|
||||
fun select(id: String) = update(_state.value.copy(manualId = id, automatic = false))
|
||||
|
||||
fun setGain(id: String, band: Int, db: Float) = edit(id) { p ->
|
||||
p.copy(gains = p.gains.toMutableList().also { it[band] = db.coerceIn(-EQ_MAX_DB, EQ_MAX_DB) })
|
||||
}
|
||||
|
||||
fun setGains(id: String, gains: List<Float>) = edit(id) { it.copy(gains = gains.map { g -> g.coerceIn(-EQ_MAX_DB, EQ_MAX_DB) }) }
|
||||
|
||||
fun rename(id: String, name: String) = edit(id) { it.copy(name = name.ifBlank { it.name }) }
|
||||
|
||||
fun setPreamp(id: String, db: Float) = edit(id) { it.copy(preamp = db.coerceIn(-PREAMP_RANGE, PREAMP_RANGE)) }
|
||||
|
||||
/** Adds or replaces (by id) a parametric filter. */
|
||||
fun putFilter(id: String, f: EqFilter) = edit(id) { p ->
|
||||
val clean = f.copy(freq = f.freq.coerceIn(FREQ_MIN, FREQ_MAX), gain = f.gain.coerceIn(-FILTER_GAIN, FILTER_GAIN), q = f.q.coerceIn(Q_MIN, Q_MAX))
|
||||
p.copy(filters = if (p.filters.any { it.id == f.id }) p.filters.map { if (it.id == f.id) clean else it } else p.filters + clean)
|
||||
}
|
||||
|
||||
fun removeFilter(id: String, filterId: String) = edit(id) { p -> p.copy(filters = p.filters.filter { it.id != filterId }) }
|
||||
|
||||
/** Replaces the filters (and the preamp, when given): an Equalizer APO / AutoEQ import. */
|
||||
fun setFilters(id: String, filters: List<EqFilter>, preamp: Float?) = edit(id) { p ->
|
||||
p.copy(filters = filters, preamp = preamp?.coerceIn(-PREAMP_RANGE, PREAMP_RANGE) ?: p.preamp)
|
||||
}
|
||||
|
||||
fun create(name: String, from: EqProfile? = null): EqProfile {
|
||||
val p = EqProfile(UUID.randomUUID().toString(), name.ifBlank { "Profile" }, from?.gains ?: List(EQ_BANDS.size) { 0f },
|
||||
filters = from?.filters?.map { it.copy(id = newFilterId()) }.orEmpty(), preamp = from?.preamp ?: 0f)
|
||||
val s = _state.value
|
||||
update(s.copy(profiles = s.profiles + p))
|
||||
return p
|
||||
}
|
||||
|
||||
fun delete(id: String) {
|
||||
val s = _state.value
|
||||
if (s.profiles.size <= 1) return
|
||||
val rest = s.profiles.filter { it.id != id }
|
||||
update(s.copy(
|
||||
profiles = rest,
|
||||
defaultId = if (s.defaultId == id) rest.first().id else s.defaultId,
|
||||
manualId = s.manualId.takeIf { it != id },
|
||||
))
|
||||
}
|
||||
|
||||
fun setDefault(id: String) = update(_state.value.copy(defaultId = id))
|
||||
|
||||
/** Links [device] to [id], unlinking it from any other profile (one profile per route). */
|
||||
fun link(id: String, device: OutputDevice, on: Boolean) {
|
||||
val s = _state.value
|
||||
update(s.copy(profiles = s.profiles.map { p ->
|
||||
val others = p.devices.filter { it != device }
|
||||
p.copy(devices = if (p.id == id && on) others + device else others)
|
||||
}))
|
||||
}
|
||||
|
||||
fun profileFor(device: OutputDevice) = _state.value.profiles.firstOrNull { device in it.devices }
|
||||
|
||||
private fun edit(id: String, f: (EqProfile) -> EqProfile) {
|
||||
val s = _state.value
|
||||
update(s.copy(profiles = s.profiles.map { if (it.id == id) f(it) else it }))
|
||||
}
|
||||
|
||||
private fun update(s: EqState) {
|
||||
_state.value = s
|
||||
publish()
|
||||
// Slider drags update many times a second; write once they settle.
|
||||
main.removeCallbacks(saveTask)
|
||||
main.postDelayed(saveTask, 400)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
val s = _state.value
|
||||
val p = resolve(s, _device.value)
|
||||
_active.value = p
|
||||
onParams?.invoke(paramsFor(s, p))
|
||||
}
|
||||
|
||||
fun currentParams(): EqParams? = paramsFor(_state.value, _active.value)
|
||||
|
||||
private fun resolve(s: EqState, d: OutputDevice): EqProfile? {
|
||||
if (!s.automatic && s.manualId != null) s.profiles.firstOrNull { it.id == s.manualId }?.let { return it }
|
||||
return s.profiles.firstOrNull { d in it.devices }
|
||||
?: s.profiles.firstOrNull { it.id == s.defaultId }
|
||||
?: s.profiles.firstOrNull()
|
||||
}
|
||||
|
||||
private fun paramsFor(s: EqState, p: EqProfile?): EqParams? {
|
||||
if (!s.enabled || p == null) return null
|
||||
val chain = p.chain
|
||||
if (chain.isEmpty() && p.preamp == 0f) return null
|
||||
return EqParams(chain, p.preamp + if (s.antiClip) EqMath.autoPreampDb(chain, p.preamp) else 0f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FREQ_MIN = 10f
|
||||
const val FREQ_MAX = 22000f
|
||||
const val FILTER_GAIN = 24f
|
||||
const val Q_MIN = 0.1f
|
||||
const val Q_MAX = 20f
|
||||
const val PREAMP_RANGE = 24f
|
||||
|
||||
fun newFilterId() = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
private fun load(): EqState = runCatching {
|
||||
AppJson.decodeFromString(EqState.serializer(), file.readText())
|
||||
}.getOrElse {
|
||||
EqState(
|
||||
profiles = listOf(
|
||||
EqProfile("flat", "Flat"),
|
||||
EqProfile("speaker", "Small Speaker Tune", EQ_PRESETS.first { it.first == "Small Speakers" }.second, listOf(OutputDevice.SPEAKER)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.stemroller.mobile.playback
|
||||
|
||||
/** A one-tap stem mix. [build] returns null when the track lacks the stems it needs. */
|
||||
class MixPreset(val label: String, val build: (List<String>) -> Map<String, StemSetting>?)
|
||||
|
||||
object MixPresets {
|
||||
val FULL = MixPreset("Full Mix") { emptyMap() }
|
||||
val KARAOKE = MixPreset("Karaoke") { n -> if ("vocals" in n) mapOf("vocals" to StemSetting(muted = true)) else null }
|
||||
|
||||
val ALL = listOf(
|
||||
FULL,
|
||||
KARAOKE,
|
||||
MixPreset("A Cappella") { n -> if ("vocals" in n) mapOf("vocals" to StemSetting(solo = true)) else null },
|
||||
MixPreset("No Drums") { n -> if ("drums" in n) mapOf("drums" to StemSetting(muted = true)) else null },
|
||||
MixPreset("Rhythm") { n -> if ("drums" in n && "bass" in n) mapOf("drums" to StemSetting(solo = true), "bass" to StemSetting(solo = true)) else null },
|
||||
MixPreset("No Guitar") { n -> if ("guitar" in n) mapOf("guitar" to StemSetting(muted = true)) else null },
|
||||
MixPreset("No Bass") { n -> if ("bass" in n) mapOf("bass" to StemSetting(muted = true)) else null },
|
||||
)
|
||||
|
||||
/** Drops stems left at their defaults, so mixes compare by what they actually change. */
|
||||
fun normalized(m: Map<String, StemSetting>) = m.filterValues { it != StemSetting() }
|
||||
|
||||
/** The preset [mix] matches for these stems, or null for a hand-made mix. */
|
||||
fun current(names: List<String>, mix: Map<String, StemSetting>): MixPreset? {
|
||||
val m = normalized(mix)
|
||||
return ALL.firstOrNull { p -> p.build(names)?.let { normalized(it) == m } == true }
|
||||
}
|
||||
|
||||
/** The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do. */
|
||||
fun next(names: List<String>, mix: Map<String, StemSetting>): MixPreset {
|
||||
val usable = ALL.filter { it.build(names) != null }
|
||||
val i = usable.indexOf(current(names, mix))
|
||||
return if (i < 0) FULL else usable[(i + 1) % usable.size]
|
||||
}
|
||||
|
||||
/** True when the vocals stem can't be heard (muted, or another stem soloed). */
|
||||
fun vocalsSilent(names: List<String>, mix: Map<String, StemSetting>): Boolean {
|
||||
if ("vocals" !in names) return false
|
||||
val v = mix["vocals"] ?: StemSetting()
|
||||
val anySolo = names.any { mix[it]?.solo == true }
|
||||
return v.muted || anySolo && !v.solo || v.level <= 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
package com.stemroller.mobile.playback
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import com.stemroller.mobile.cache.MediaCache
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.core.StemQuality
|
||||
import com.stemroller.mobile.engine.EqParams
|
||||
import com.stemroller.mobile.engine.ResourceDataSource
|
||||
import com.stemroller.mobile.engine.StemDecoder
|
||||
import com.stemroller.mobile.engine.StemEngine
|
||||
import com.stemroller.mobile.engine.TrackSession
|
||||
import com.stemroller.mobile.engine.masterTaper
|
||||
import com.stemroller.mobile.engine.stemTaper
|
||||
import com.stemroller.mobile.eq.EqController
|
||||
import com.stemroller.mobile.plex.PlexClient
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import com.stemroller.mobile.stems.StemEntry
|
||||
import com.stemroller.mobile.stems.StemServer
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/** Display order and colours for stems, matching the desktop app. */
|
||||
object Stems {
|
||||
val ORDER = listOf("vocals", "drums", "bass", "guitar", "piano", "other", "instrumental", "mix")
|
||||
private val COLORS = mapOf(
|
||||
"vocals" to 0xFFFF7AB6, "drums" to 0xFFFFB547, "bass" to 0xFF7C8CFF, "other" to 0xFF4CD07D,
|
||||
"guitar" to 0xFFFF8A5C, "piano" to 0xFFB48CFF, "instrumental" to 0xFF3DD6C6, "mix" to 0xFF8E8E93,
|
||||
)
|
||||
|
||||
fun color(name: String) = COLORS[name] ?: 0xFF8E8E93
|
||||
fun label(name: String) = if (name == "mix") "Original Mix" else name.replaceFirstChar { it.uppercase() }
|
||||
fun sort(names: List<String>) = names.sortedBy { ORDER.indexOf(it).let { i -> if (i < 0) 99 else i } }
|
||||
}
|
||||
|
||||
/** One stem's fader. [level] is the slider position; 0.8 is unity gain. */
|
||||
@Serializable
|
||||
data class StemSetting(val level: Float = UNITY, val muted: Boolean = false, val solo: Boolean = false) {
|
||||
companion object { const val UNITY = 0.8f }
|
||||
}
|
||||
|
||||
enum class RepeatMode { Off, All, One }
|
||||
enum class SourceKind { None, Stems, Original }
|
||||
|
||||
@Serializable
|
||||
data class QueueItem(val uid: Long, val track: Track)
|
||||
|
||||
/** Waveform data for the seek bar: per-stem envelopes from stemserve, or Plex loudness in dB. */
|
||||
class Waveform(val stems: Map<String, FloatArray>?, val loudnessDb: FloatArray?)
|
||||
|
||||
@Serializable
|
||||
private data class SavedQueue(
|
||||
val items: List<QueueItem> = emptyList(),
|
||||
val unshuffled: List<QueueItem>? = null,
|
||||
val index: Int = -1,
|
||||
val positionMs: Long = 0,
|
||||
val repeat: RepeatMode = RepeatMode.Off,
|
||||
val mix: Map<String, StemSetting> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Owns the queue and the [StemEngine]. Everything public is main-thread only; the UI
|
||||
* reads the flows, and PlaybackService mirrors this into a MediaSession.
|
||||
*/
|
||||
class PlaybackManager(
|
||||
private val context: Context,
|
||||
private val settings: Settings,
|
||||
private val plex: PlexClient,
|
||||
private val stems: StemServer,
|
||||
private val cache: MediaCache,
|
||||
private val eq: EqController,
|
||||
private val scope: CoroutineScope,
|
||||
) : StemEngine.Listener {
|
||||
|
||||
val engine = StemEngine(this)
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val queueFile = File(context.filesDir, "queue.json")
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueItem>>(emptyList())
|
||||
val queue: StateFlow<List<QueueItem>> = _queue.asStateFlow()
|
||||
private val _index = MutableStateFlow(-1)
|
||||
val index: StateFlow<Int> = _index.asStateFlow()
|
||||
private val _current = MutableStateFlow<QueueItem?>(null)
|
||||
val current: StateFlow<QueueItem?> = _current.asStateFlow()
|
||||
private val _playWhenReady = MutableStateFlow(false)
|
||||
val playWhenReady: StateFlow<Boolean> = _playWhenReady.asStateFlow()
|
||||
private val _buffering = MutableStateFlow(false)
|
||||
val buffering: StateFlow<Boolean> = _buffering.asStateFlow()
|
||||
private val _shuffle = MutableStateFlow(false)
|
||||
val shuffle: StateFlow<Boolean> = _shuffle.asStateFlow()
|
||||
private val _repeat = MutableStateFlow(RepeatMode.Off)
|
||||
val repeat: StateFlow<RepeatMode> = _repeat.asStateFlow()
|
||||
private val _source = MutableStateFlow(SourceKind.None)
|
||||
val source: StateFlow<SourceKind> = _source.asStateFlow()
|
||||
private val _stemNames = MutableStateFlow<List<String>>(emptyList())
|
||||
val stemNames: StateFlow<List<String>> = _stemNames.asStateFlow()
|
||||
private val _mix = MutableStateFlow<Map<String, StemSetting>>(emptyMap())
|
||||
val mix: StateFlow<Map<String, StemSetting>> = _mix.asStateFlow()
|
||||
private val _master = MutableStateFlow(settings.masterVolume.value)
|
||||
val master: StateFlow<Float> = _master.asStateFlow()
|
||||
private val _waveform = MutableStateFlow<Waveform?>(null)
|
||||
val waveform: StateFlow<Waveform?> = _waveform.asStateFlow()
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||
private val _messages = MutableSharedFlow<String>(extraBufferCapacity = 4)
|
||||
val messages: SharedFlow<String> = _messages.asSharedFlow()
|
||||
|
||||
private val listeners = CopyOnWriteArrayList<() -> Unit>()
|
||||
private var unshuffled: List<QueueItem>? = null
|
||||
private var nextUid = 1L
|
||||
private var tokens = 0L
|
||||
private var loadJob: Job? = null
|
||||
private var nextJob: Job? = null
|
||||
private var currentToken = -1L
|
||||
private var nextToken = -1L
|
||||
private var nextIndex = -1
|
||||
private var nextEntry: StemEntry? = null
|
||||
private var currentEntry: StemEntry? = null
|
||||
private var pendingPositionMs = 0L
|
||||
private var failures = 0
|
||||
private var scrobbled = -1L
|
||||
private var duck = 1f
|
||||
|
||||
init {
|
||||
eq.onParams = { p: EqParams? -> engine.eqParams = p }
|
||||
engine.eqParams = eq.currentParams()
|
||||
engine.masterGain = masterTaper(_master.value)
|
||||
restore()
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(10_000)
|
||||
if (_playWhenReady.value) reportTimeline("playing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Surfaces a message wherever messages show (toasts on the phone, the player error in the car). */
|
||||
fun report(message: String) {
|
||||
_messages.tryEmit(message)
|
||||
}
|
||||
|
||||
fun addListener(l: () -> Unit) = listeners.add(l)
|
||||
fun removeListener(l: () -> Unit) = listeners.remove(l)
|
||||
private fun changed() = listeners.forEach { it() }
|
||||
|
||||
fun positionMs(): Long {
|
||||
val snap = engine.snapshot
|
||||
val pos = if (currentToken >= 0 && snap.token == currentToken) snap.positionMs() else pendingPositionMs
|
||||
val d = _durationMs.value
|
||||
return if (d > 0) pos.coerceIn(0, d) else pos.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
// ---- queue --------------------------------------------------------------------------
|
||||
|
||||
fun playTracks(tracks: List<Track>, start: Int = 0, shuffle: Boolean = false) {
|
||||
if (tracks.isEmpty()) return
|
||||
val items = tracks.map { QueueItem(nextUid++, it) }
|
||||
if (shuffle) {
|
||||
unshuffled = items
|
||||
val first = if (start in items.indices && start > 0) items[start] else items.random()
|
||||
_queue.value = listOf(first) + (items - first).shuffled()
|
||||
_shuffle.value = true
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
unshuffled = null
|
||||
_shuffle.value = false
|
||||
_queue.value = items
|
||||
load(start.coerceIn(0, items.lastIndex), 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun playNext(tracks: List<Track>) = insert(_index.value + 1, tracks)
|
||||
fun addToQueue(tracks: List<Track>) = insert(_queue.value.size, tracks)
|
||||
|
||||
private fun insert(at: Int, tracks: List<Track>) {
|
||||
if (tracks.isEmpty()) return
|
||||
val items = tracks.map { QueueItem(nextUid++, it) }
|
||||
val q = _queue.value.toMutableList()
|
||||
q.addAll(at.coerceIn(0, q.size), items)
|
||||
_queue.value = q
|
||||
unshuffled = unshuffled?.plus(items)
|
||||
if (_index.value < 0) {
|
||||
load(0, 0, true)
|
||||
} else {
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAt(i: Int) {
|
||||
val q = _queue.value.toMutableList()
|
||||
if (i !in q.indices) return
|
||||
val removed = q.removeAt(i)
|
||||
unshuffled = unshuffled?.filter { it.uid != removed.uid }
|
||||
_queue.value = q
|
||||
val idx = _index.value
|
||||
when {
|
||||
q.isEmpty() -> stop()
|
||||
i == idx -> load(i.coerceAtMost(q.lastIndex), 0, _playWhenReady.value)
|
||||
i < idx -> _index.value = idx - 1
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun move(from: Int, to: Int) {
|
||||
val q = _queue.value.toMutableList()
|
||||
if (from !in q.indices || to !in q.indices || from == to) return
|
||||
q.add(to, q.removeAt(from))
|
||||
_queue.value = q
|
||||
val idx = _index.value
|
||||
_index.value = when {
|
||||
from == idx -> to
|
||||
from < idx && to >= idx -> idx - 1
|
||||
from > idx && to <= idx -> idx + 1
|
||||
else -> idx
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun clearUpNext() {
|
||||
val idx = _index.value
|
||||
if (idx < 0) return
|
||||
_queue.value = _queue.value.take(idx + 1)
|
||||
unshuffled = null
|
||||
_shuffle.value = false
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
val q = _queue.value
|
||||
val idx = _index.value
|
||||
val cur = q.getOrNull(idx)
|
||||
if (!_shuffle.value) {
|
||||
unshuffled = q
|
||||
val upcoming = q.drop(idx + 1).shuffled()
|
||||
_queue.value = q.take(idx + 1) + upcoming
|
||||
_shuffle.value = true
|
||||
} else {
|
||||
val orig = unshuffled ?: q
|
||||
_queue.value = orig
|
||||
_index.value = orig.indexOfFirst { it.uid == cur?.uid }.coerceAtLeast(0)
|
||||
unshuffled = null
|
||||
_shuffle.value = false
|
||||
}
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun cycleRepeat() {
|
||||
_repeat.value = RepeatMode.entries[(_repeat.value.ordinal + 1) % RepeatMode.entries.size]
|
||||
requeueNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun setRepeat(mode: RepeatMode) {
|
||||
_repeat.value = mode
|
||||
requeueNext()
|
||||
changed()
|
||||
}
|
||||
|
||||
// ---- transport ----------------------------------------------------------------------
|
||||
|
||||
fun play() {
|
||||
if (_queue.value.isEmpty()) return
|
||||
if (!requestFocus()) return
|
||||
_playWhenReady.value = true
|
||||
if (currentToken < 0 && loadJob?.isActive != true) {
|
||||
load(_index.value.coerceAtLeast(0), pendingPositionMs, true)
|
||||
return
|
||||
}
|
||||
engine.setPlaying(true)
|
||||
holdLocks(true)
|
||||
reportTimeline("playing")
|
||||
changed()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
_playWhenReady.value = false
|
||||
engine.setPlaying(false)
|
||||
holdLocks(false)
|
||||
pendingPositionMs = positionMs()
|
||||
reportTimeline("paused")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
fun togglePlay() = if (_playWhenReady.value) pause() else play()
|
||||
|
||||
fun next() {
|
||||
val q = _queue.value
|
||||
if (q.isEmpty()) return
|
||||
val i = _index.value + 1
|
||||
when {
|
||||
i < q.size -> load(i, 0, _playWhenReady.value)
|
||||
_repeat.value != RepeatMode.Off -> load(0, 0, _playWhenReady.value)
|
||||
else -> { pause(); seekTo(0) }
|
||||
}
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
if (positionMs() > 3000 || _index.value <= 0) {
|
||||
seekTo(0)
|
||||
return
|
||||
}
|
||||
load(_index.value - 1, 0, _playWhenReady.value)
|
||||
}
|
||||
|
||||
fun skipTo(i: Int, positionMs: Long = 0) {
|
||||
if (i in _queue.value.indices) load(i, positionMs, true)
|
||||
}
|
||||
|
||||
fun seekTo(ms: Long) {
|
||||
pendingPositionMs = ms.coerceAtLeast(0)
|
||||
if (currentToken >= 0) engine.seekTo(pendingPositionMs)
|
||||
changed()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
loadJob?.cancel()
|
||||
nextJob?.cancel()
|
||||
engine.stop()
|
||||
reportTimeline("stopped")
|
||||
currentToken = -1
|
||||
nextToken = -1
|
||||
_queue.value = emptyList()
|
||||
_index.value = -1
|
||||
_current.value = null
|
||||
_playWhenReady.value = false
|
||||
_source.value = SourceKind.None
|
||||
_stemNames.value = emptyList()
|
||||
_waveform.value = null
|
||||
holdLocks(false)
|
||||
abandonFocus()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
// ---- mixer --------------------------------------------------------------------------
|
||||
|
||||
fun setStemLevel(name: String, level: Float) = editMix(name) { it.copy(level = level.coerceIn(0f, 1f)) }
|
||||
fun toggleMute(name: String) = editMix(name) { it.copy(muted = !it.muted) }
|
||||
fun toggleSolo(name: String) = editMix(name) { it.copy(solo = !it.solo) }
|
||||
|
||||
fun resetMix() {
|
||||
_mix.value = emptyMap()
|
||||
pushMix()
|
||||
}
|
||||
|
||||
/** Karaoke and friends: set every stem at once. */
|
||||
fun applyMix(m: Map<String, StemSetting>) {
|
||||
_mix.value = m
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private fun editMix(name: String, f: (StemSetting) -> StemSetting) {
|
||||
_mix.value = _mix.value + (name to f(_mix.value[name] ?: StemSetting()))
|
||||
pushMix()
|
||||
}
|
||||
|
||||
private fun pushMix() {
|
||||
val m = _mix.value
|
||||
val anySolo = _stemNames.value.any { m[it]?.solo == true }
|
||||
engine.setMix(_stemNames.value.associateWith { n ->
|
||||
val s = m[n] ?: StemSetting()
|
||||
when {
|
||||
anySolo && !s.solo -> 0f
|
||||
s.muted -> 0f
|
||||
else -> stemTaper(s.level)
|
||||
}
|
||||
})
|
||||
// The media session shows the mix (artist line, Vocals/Mix buttons), so it needs to hear about it.
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
/** True when [track] would play without the home network: its stems (or, lacking stems, its file) are fully cached. */
|
||||
fun playableOffline(track: Track): Boolean {
|
||||
val entry = stems.find(track)
|
||||
if (entry != null) {
|
||||
return StemQuality.entries.any { q -> entry.stems.all { cache.isComplete(stemKey(entry, it.file, it.size, q)) } }
|
||||
}
|
||||
return track.partKey != null && cache.isComplete(plexKey(track))
|
||||
}
|
||||
|
||||
fun hasStems(track: Track) = stems.find(track) != null
|
||||
|
||||
fun setMaster(v: Float) {
|
||||
_master.value = v.coerceIn(0f, 1f)
|
||||
engine.masterGain = masterTaper(_master.value) * duck
|
||||
settings.masterVolume.value = _master.value
|
||||
}
|
||||
|
||||
// ---- loading ------------------------------------------------------------------------
|
||||
|
||||
private fun load(i: Int, positionMs: Long, play: Boolean) {
|
||||
val item = _queue.value.getOrNull(i) ?: return
|
||||
loadJob?.cancel()
|
||||
nextJob?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(null)
|
||||
_index.value = i
|
||||
_current.value = item
|
||||
pendingPositionMs = positionMs
|
||||
_durationMs.value = item.track.durationMs
|
||||
_waveform.value = null
|
||||
currentToken = -1
|
||||
if (play && !requestFocus()) return
|
||||
_playWhenReady.value = play
|
||||
holdLocks(play)
|
||||
_buffering.value = true
|
||||
if (!settings.keepStemMix.value) _mix.value = emptyMap()
|
||||
changed()
|
||||
save()
|
||||
loadJob = scope.launch {
|
||||
var session: TrackSession? = null
|
||||
try {
|
||||
val (s, entry) = withContext(Dispatchers.IO) { open(item.track, positionMs) }
|
||||
session = s
|
||||
if (!isActive) throw CancellationException()
|
||||
currentToken = s.token
|
||||
currentEntry = entry
|
||||
applySession(s, entry)
|
||||
engine.load(s, _playWhenReady.value)
|
||||
session = null
|
||||
failures = 0
|
||||
scrobbled = -1
|
||||
reportTimeline(if (_playWhenReady.value) "playing" else "paused")
|
||||
loadWaveform(item.track, entry)
|
||||
prepareNext()
|
||||
} catch (e: CancellationException) {
|
||||
session?.release()
|
||||
} catch (e: Exception) {
|
||||
session?.release()
|
||||
_buffering.value = false
|
||||
_messages.tryEmit("Couldn't play “${item.track.title}”: ${e.message ?: e.javaClass.simpleName}")
|
||||
if (++failures < 3 && i + 1 < _queue.value.size) load(i + 1, 0, play) else {
|
||||
failures = 0
|
||||
_playWhenReady.value = false
|
||||
holdLocks(false)
|
||||
}
|
||||
changed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySession(s: TrackSession, entry: StemEntry?) {
|
||||
_source.value = if (entry != null) SourceKind.Stems else SourceKind.Original
|
||||
_stemNames.value = s.names
|
||||
val frames = s.durationFrames
|
||||
if (frames > 0) _durationMs.value = frames * 1000 / s.sampleRate
|
||||
pushMix()
|
||||
changed()
|
||||
}
|
||||
|
||||
/** Opens every stem (or the original file) and waits for the decoders to know their format. */
|
||||
private fun open(track: Track, startMs: Long): Pair<TrackSession, StemEntry?> {
|
||||
val entry = stems.find(track)
|
||||
val sources: List<Triple<String, String, String>> = if (entry != null) {
|
||||
val q = qualityFor(entry)
|
||||
stems.prepare(entry, q)
|
||||
Stems.sort(entry.stems.map { it.name }).map { name ->
|
||||
val f = entry.stems.first { it.name == name }
|
||||
Triple(name, stemKey(entry, f.file, f.size, q), stems.fileUrl(entry, f, q))
|
||||
}
|
||||
} else {
|
||||
val part = track.partKey ?: throw IOException("Plex has no file for this track")
|
||||
listOf(Triple("mix", plexKey(track), plex.partUrl(part)))
|
||||
}
|
||||
val decoders = sources.map { (name, key, url) ->
|
||||
val res = cache.acquire(key) { url }
|
||||
StemDecoder(name, ResourceDataSource(res) { cache.release(res) }, RING_FRAMES)
|
||||
}
|
||||
try {
|
||||
decoders.forEach { it.start(0, 0) }
|
||||
for (d in decoders) {
|
||||
if (!d.awaitPrepared(45_000)) throw IOException(d.error?.message ?: "Timed out opening ${d.name}")
|
||||
}
|
||||
val rate = decoders.first().sampleRate
|
||||
if (decoders.any { it.sampleRate != rate }) throw IOException("Stems have mixed sample rates")
|
||||
val s = TrackSession(++tokens, decoders.map { it.name }, decoders)
|
||||
if (startMs > 0) s.startAt(startMs * rate / 1000)
|
||||
return s to entry
|
||||
} catch (e: Exception) {
|
||||
decoders.forEach { it.release() }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Plays a downloaded copy if there is one, whatever quality it was saved at. */
|
||||
private fun qualityFor(entry: StemEntry): StemQuality {
|
||||
val want = stems.effectiveQuality(settings.stemQuality.value)
|
||||
val f = entry.stems.first()
|
||||
if (cache.isComplete(stemKey(entry, f.file, f.size, want))) return want
|
||||
return StemQuality.entries.firstOrNull { cache.isComplete(stemKey(entry, f.file, f.size, it)) } ?: want
|
||||
}
|
||||
|
||||
/** Builds the next track's session now so the switch can be gapless. */
|
||||
private fun prepareNext() {
|
||||
nextJob?.cancel()
|
||||
nextToken = -1
|
||||
engine.queueNext(null)
|
||||
val i = upcomingIndex() ?: return
|
||||
val item = _queue.value[i]
|
||||
nextJob = scope.launch {
|
||||
delay(4_000) // let the current track's stems get a head start
|
||||
var session: TrackSession? = null
|
||||
try {
|
||||
val (s, entry) = withContext(Dispatchers.IO) { open(item.track, 0) }
|
||||
session = s
|
||||
if (!isActive) throw CancellationException()
|
||||
nextToken = s.token
|
||||
nextIndex = i
|
||||
nextEntry = entry
|
||||
engine.queueNext(s)
|
||||
session = null
|
||||
} catch (e: CancellationException) {
|
||||
session?.release()
|
||||
} catch (e: Exception) {
|
||||
session?.release() // the normal load path will report it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requeueNext() {
|
||||
if (currentToken >= 0) prepareNext()
|
||||
}
|
||||
|
||||
private fun upcomingIndex(): Int? {
|
||||
val q = _queue.value
|
||||
val i = _index.value
|
||||
return when {
|
||||
q.isEmpty() || i < 0 -> null
|
||||
_repeat.value == RepeatMode.One -> i
|
||||
i + 1 < q.size -> i + 1
|
||||
_repeat.value == RepeatMode.All -> 0
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadWaveform(track: Track, entry: StemEntry?) {
|
||||
val uid = _current.value?.uid
|
||||
scope.launch {
|
||||
val w = if (entry != null) {
|
||||
stems.peaks(entry, WAVE_BUCKETS)?.let { Waveform(it, null) }
|
||||
} else {
|
||||
runCatching { plex.loudness(track.ratingKey, WAVE_BUCKETS) }.getOrNull()?.let { Waveform(null, it) }
|
||||
}
|
||||
if (_current.value?.uid == uid) _waveform.value = w
|
||||
}
|
||||
}
|
||||
|
||||
// ---- engine callbacks (main thread) ---------------------------------------------------
|
||||
|
||||
override fun onEngineState() {
|
||||
_buffering.value = engine.buffering || (loadJob?.isActive == true)
|
||||
changed()
|
||||
}
|
||||
|
||||
override fun onTransition(token: Long) {
|
||||
if (token == currentToken || token != nextToken) return
|
||||
scrobble()
|
||||
currentToken = token
|
||||
nextToken = -1
|
||||
_index.value = nextIndex
|
||||
_current.value = _queue.value.getOrNull(nextIndex)
|
||||
currentEntry = nextEntry
|
||||
pendingPositionMs = 0
|
||||
scrobbled = -1
|
||||
val item = _current.value ?: return
|
||||
_durationMs.value = item.track.durationMs
|
||||
_waveform.value = null
|
||||
if (!settings.keepStemMix.value) _mix.value = emptyMap()
|
||||
_source.value = if (currentEntry != null) SourceKind.Stems else SourceKind.Original
|
||||
_stemNames.value = Stems.sort((currentEntry?.stems?.map { it.name }) ?: listOf("mix"))
|
||||
pushMix()
|
||||
loadWaveform(item.track, currentEntry)
|
||||
reportTimeline("playing")
|
||||
prepareNext()
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
override fun onEnded(token: Long) {
|
||||
if (token != currentToken) return
|
||||
scrobble()
|
||||
currentToken = -1
|
||||
// Normally the next song was queued for a gapless switch; if preparing it failed, try again now.
|
||||
upcomingIndex()?.let { i ->
|
||||
load(i, 0, true)
|
||||
return
|
||||
}
|
||||
_playWhenReady.value = false
|
||||
holdLocks(false)
|
||||
pendingPositionMs = 0
|
||||
reportTimeline("stopped")
|
||||
changed()
|
||||
save()
|
||||
}
|
||||
|
||||
override fun onError(token: Long, message: String) {
|
||||
if (token != currentToken) return
|
||||
_messages.tryEmit("Playback stopped: $message")
|
||||
currentToken = -1
|
||||
val i = _index.value
|
||||
if (i + 1 < _queue.value.size && ++failures < 3) load(i + 1, 0, _playWhenReady.value) else {
|
||||
_playWhenReady.value = false
|
||||
holdLocks(false)
|
||||
changed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRouted(device: AudioDeviceInfo?) = eq.onRouted(device)
|
||||
|
||||
private fun scrobble() {
|
||||
val item = _current.value ?: return
|
||||
if (scrobbled == item.uid) return
|
||||
val d = _durationMs.value
|
||||
if (d > 0 && positionMs() < d * 0.5 && engine.snapshot.token == currentToken) return
|
||||
scrobbled = item.uid
|
||||
scope.launch { plex.scrobble(item.track.ratingKey) }
|
||||
}
|
||||
|
||||
private fun reportTimeline(state: String) {
|
||||
val t = _current.value?.track ?: return
|
||||
val pos = positionMs()
|
||||
if (!plex.signedIn) return
|
||||
scope.launch { plex.timeline(t, state, pos) }
|
||||
if (state == "playing" && _durationMs.value > 0 && pos > _durationMs.value * 0.9) scrobble()
|
||||
}
|
||||
|
||||
// ---- focus, locks ---------------------------------------------------------------------
|
||||
|
||||
private val am = context.getSystemService(AudioManager::class.java)
|
||||
private var resumeOnGain = false
|
||||
private val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
|
||||
.setWillPauseWhenDucked(false)
|
||||
.setOnAudioFocusChangeListener({ change ->
|
||||
when (change) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> { resumeOnGain = false; pause() }
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { resumeOnGain = _playWhenReady.value; pause() }
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { duck = 0.25f; setMaster(_master.value) }
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
duck = 1f
|
||||
setMaster(_master.value)
|
||||
if (resumeOnGain) play()
|
||||
resumeOnGain = false
|
||||
}
|
||||
}
|
||||
}, main)
|
||||
.build()
|
||||
|
||||
private fun requestFocus() = am.requestAudioFocus(focusRequest) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
private fun abandonFocus() = am.abandonAudioFocusRequest(focusRequest)
|
||||
|
||||
private val noisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context, i: Intent) {
|
||||
if (i.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pause()
|
||||
}
|
||||
}
|
||||
private val noisyRegistered = AtomicBoolean(false)
|
||||
private val wakeLock = context.getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "StemRoller:playback").apply { setReferenceCounted(false) }
|
||||
@Suppress("DEPRECATION")
|
||||
private val wifiLock = context.applicationContext.getSystemService(WifiManager::class.java)
|
||||
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "StemRoller:stream").apply { setReferenceCounted(false) }
|
||||
|
||||
private fun holdLocks(on: Boolean) {
|
||||
if (on) {
|
||||
if (!wakeLock.isHeld) wakeLock.acquire(4 * 60 * 60 * 1000L)
|
||||
if (!wifiLock.isHeld) wifiLock.acquire()
|
||||
if (noisyRegistered.compareAndSet(false, true)) {
|
||||
context.registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), Context.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
} else {
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
if (wifiLock.isHeld) wifiLock.release()
|
||||
if (noisyRegistered.compareAndSet(true, false)) runCatching { context.unregisterReceiver(noisy) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- persistence ------------------------------------------------------------------------
|
||||
|
||||
private var saveJob: Job? = null
|
||||
|
||||
private fun save() {
|
||||
saveJob?.cancel()
|
||||
saveJob = scope.launch {
|
||||
delay(500)
|
||||
val snap = SavedQueue(_queue.value, unshuffled, _index.value, positionMs(), _repeat.value, _mix.value)
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val tmp = File(queueFile.parentFile, "queue.json.tmp")
|
||||
tmp.writeText(AppJson.encodeToString(SavedQueue.serializer(), snap))
|
||||
tmp.renameTo(queueFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restore() {
|
||||
val saved = runCatching { AppJson.decodeFromString(SavedQueue.serializer(), queueFile.readText()) }.getOrNull() ?: return
|
||||
_queue.value = saved.items
|
||||
unshuffled = saved.unshuffled
|
||||
_shuffle.value = saved.unshuffled != null
|
||||
_repeat.value = saved.repeat
|
||||
_mix.value = saved.mix
|
||||
nextUid = (saved.items.maxOfOrNull { it.uid } ?: 0) + 1
|
||||
val i = saved.index.coerceIn(-1, saved.items.lastIndex)
|
||||
_index.value = i
|
||||
_current.value = saved.items.getOrNull(i)
|
||||
_durationMs.value = _current.value?.track?.durationMs ?: 0
|
||||
pendingPositionMs = saved.positionMs
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val RING_FRAMES = StemEngine.RING_SECONDS * 48_000
|
||||
const val WAVE_BUCKETS = 256
|
||||
|
||||
fun stemKey(entry: StemEntry, file: String, size: Long, q: StemQuality) = "stem|${entry.key}|$file|$size|${q.name}"
|
||||
fun plexKey(track: Track) = "plex|${track.partId}|${track.partSize}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
package com.stemroller.mobile.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.SimpleBasePlayer
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.LibraryResult
|
||||
import androidx.media3.session.MediaConstants
|
||||
import androidx.media3.session.MediaLibraryService
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionError
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.MainActivity
|
||||
import com.stemroller.mobile.R
|
||||
import com.stemroller.mobile.auto.CarArtProvider
|
||||
import com.stemroller.mobile.auto.CarIds
|
||||
import com.stemroller.mobile.auto.CarLibrary
|
||||
import com.stemroller.mobile.auto.Resolved
|
||||
import com.stemroller.mobile.plex.NotSignedInException
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.guava.future
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Rides on a MediaItem from the library callback to [StemPlayer.handleSetMediaItems]. */
|
||||
private class PlayTag(val track: Track, val shuffle: Boolean)
|
||||
|
||||
/** Marks the items [MediaLibraryService] gets back from playback resumption: just resume the saved queue. */
|
||||
private object ResumeTag
|
||||
|
||||
/**
|
||||
* Presents [PlaybackManager] to Media3 so the notification, lock screen, Bluetooth
|
||||
* buttons, Android Auto and other controllers drive the same queue the UI does.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class StemPlayer(private val pm: PlaybackManager, scope: CoroutineScope) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||
private val onChange: () -> Unit = { invalidateState() }
|
||||
private val items = HashMap<String, MediaItem>()
|
||||
private var error: PlaybackException? = null
|
||||
private var errorAt = 0L
|
||||
|
||||
init {
|
||||
pm.addListener(onChange)
|
||||
// Auto has no toasts: a failed song shows as a player error until something plays.
|
||||
scope.launch {
|
||||
pm.messages.collect { msg ->
|
||||
error = PlaybackException(msg, null, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)
|
||||
errorAt = System.nanoTime()
|
||||
invalidateState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Karaoke", "Custom mix"… for the playing song when it isn't the full mix; null otherwise. */
|
||||
fun mixLabel(): String? {
|
||||
if (pm.source.value != SourceKind.Stems) return null
|
||||
val p = MixPresets.current(pm.stemNames.value, pm.mix.value)
|
||||
return when {
|
||||
p == null -> "Custom mix"
|
||||
p === MixPresets.FULL -> null
|
||||
else -> p.label
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaItem(item: QueueItem, label: String?): MediaItem = items.getOrPut("${item.uid}|$label") {
|
||||
val t = item.track
|
||||
MediaItem.Builder()
|
||||
.setMediaId("${item.uid}:${t.ratingKey}")
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(t.title)
|
||||
// The mix rides on the artist line: in the car it's the only feedback the mix button gets.
|
||||
.setArtist(if (label != null) "${t.artist} · $label" else t.artist)
|
||||
.setAlbumTitle(t.album)
|
||||
.setTrackNumber(t.index)
|
||||
.setDurationMs(t.durationMs)
|
||||
.setArtworkUri(CarArtProvider.uri(t.thumb))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.setIsPlayable(true)
|
||||
.setIsBrowsable(false)
|
||||
.build(),
|
||||
).build()
|
||||
}
|
||||
|
||||
override fun getState(): State {
|
||||
val q = pm.queue.value
|
||||
val idx = pm.index.value
|
||||
if (items.size > q.size * 2 + 64) {
|
||||
val keep = q.map { it.uid.toString() }.toSet()
|
||||
items.keys.retainAll { it.substringBefore('|') in keep }
|
||||
}
|
||||
val commands = Player.Commands.Builder().addAll(
|
||||
COMMAND_PLAY_PAUSE, COMMAND_STOP, COMMAND_PREPARE, COMMAND_RELEASE,
|
||||
COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, COMMAND_SEEK_TO_DEFAULT_POSITION,
|
||||
COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
|
||||
COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||
COMMAND_GET_CURRENT_MEDIA_ITEM, COMMAND_GET_TIMELINE, COMMAND_GET_METADATA,
|
||||
COMMAND_SET_SHUFFLE_MODE, COMMAND_SET_REPEAT_MODE, COMMAND_SET_MEDIA_ITEM, COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
).build()
|
||||
if (error != null && (System.nanoTime() - errorAt > ERROR_NS || pm.playWhenReady.value && !pm.buffering.value)) error = null
|
||||
val b = State.Builder().setAvailableCommands(commands)
|
||||
.setPlayWhenReady(pm.playWhenReady.value, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
||||
.setShuffleModeEnabled(pm.shuffle.value)
|
||||
.setRepeatMode(
|
||||
when (pm.repeat.value) {
|
||||
RepeatMode.Off -> REPEAT_MODE_OFF
|
||||
RepeatMode.All -> REPEAT_MODE_ALL
|
||||
RepeatMode.One -> REPEAT_MODE_ONE
|
||||
},
|
||||
)
|
||||
if (q.isEmpty()) return b.setPlaybackState(STATE_IDLE).setPlayerError(error).build()
|
||||
val cur = idx.coerceIn(0, q.lastIndex)
|
||||
val label = mixLabel()
|
||||
val playlist = q.mapIndexed { i, item ->
|
||||
MediaItemData.Builder(item.uid)
|
||||
.setMediaItem(mediaItem(item, if (i == cur) label else null))
|
||||
.setDurationUs(if (item.track.durationMs > 0) item.track.durationMs * 1000 else C.TIME_UNSET)
|
||||
.setIsSeekable(true)
|
||||
.build()
|
||||
}
|
||||
val state = when {
|
||||
error != null && !pm.playWhenReady.value -> STATE_IDLE
|
||||
pm.buffering.value && pm.playWhenReady.value -> STATE_BUFFERING
|
||||
else -> STATE_READY
|
||||
}
|
||||
return b.setPlaylist(playlist)
|
||||
.setCurrentMediaItemIndex(cur)
|
||||
.setPlaybackState(state)
|
||||
// Media3 only allows an error alongside STATE_IDLE.
|
||||
.setPlayerError(if (state == STATE_IDLE) error else null)
|
||||
.setIsLoading(pm.buffering.value)
|
||||
.setContentPositionMs { pm.positionMs() }
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun done(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||
|
||||
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||
if (playWhenReady) pm.play() else pm.pause()
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handlePrepare(): ListenableFuture<*> = done()
|
||||
|
||||
override fun handleStop(): ListenableFuture<*> {
|
||||
pm.pause()
|
||||
return done()
|
||||
}
|
||||
|
||||
/** Items come from [PlaybackService]'s library callback, which tags each with its track. */
|
||||
override fun handleSetMediaItems(mediaItems: MutableList<MediaItem>, startIndex: Int, startPositionMs: Long): ListenableFuture<*> {
|
||||
val tags = mediaItems.map { it.localConfiguration?.tag }
|
||||
if (tags.firstOrNull() === ResumeTag) return done()
|
||||
val picks = tags.filterIsInstance<PlayTag>()
|
||||
if (picks.isEmpty()) return done()
|
||||
val start = if (startIndex == C.INDEX_UNSET) 0 else startIndex
|
||||
error = null
|
||||
pm.playTracks(picks.map { it.track }, start, shuffle = picks.first().shuffle)
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleAddMediaItems(index: Int, mediaItems: MutableList<MediaItem>): ListenableFuture<*> {
|
||||
val tracks = mediaItems.mapNotNull { (it.localConfiguration?.tag as? PlayTag)?.track }
|
||||
when {
|
||||
tracks.isEmpty() -> Unit
|
||||
index >= pm.queue.value.size -> pm.addToQueue(tracks)
|
||||
index == pm.index.value + 1 -> pm.playNext(tracks)
|
||||
else -> pm.addToQueue(tracks)
|
||||
}
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleRemoveMediaItems(fromIndex: Int, toIndex: Int): ListenableFuture<*> {
|
||||
for (i in toIndex - 1 downTo fromIndex) pm.removeAt(i)
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleMoveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int): ListenableFuture<*> {
|
||||
// Media3 moves a range; the queue moves one at a time, which is all controllers send in practice.
|
||||
if (toIndex - fromIndex == 1) pm.move(fromIndex, newIndex)
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
||||
val pos = if (positionMs == C.TIME_UNSET) 0 else positionMs
|
||||
if (mediaItemIndex == C.INDEX_UNSET || mediaItemIndex == pm.index.value) pm.seekTo(pos) else pm.skipTo(mediaItemIndex, pos)
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> {
|
||||
if (shuffleModeEnabled != pm.shuffle.value) pm.toggleShuffle()
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> {
|
||||
pm.setRepeat(
|
||||
when (repeatMode) {
|
||||
REPEAT_MODE_ONE -> RepeatMode.One
|
||||
REPEAT_MODE_ALL -> RepeatMode.All
|
||||
else -> RepeatMode.Off
|
||||
},
|
||||
)
|
||||
return done()
|
||||
}
|
||||
|
||||
override fun handleRelease(): ListenableFuture<*> {
|
||||
pm.removeListener(onChange)
|
||||
return done()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ERROR_NS = 10_000_000_000L
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaLibraryService() {
|
||||
private var session: MediaLibrarySession? = null
|
||||
private val graph get() = App.graph
|
||||
private val pm get() = graph.playback
|
||||
private val library by lazy { CarLibrary(graph) }
|
||||
private var buttonsKey = ""
|
||||
private var rootLimit = 4
|
||||
private val onChange: () -> Unit = { updateButtons() }
|
||||
private var homeWatch: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = StemPlayer(pm, graph.scope)
|
||||
val open = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP).putExtra(MainActivity.EXTRA_NOW_PLAYING, true),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
session = MediaLibrarySession.Builder(this, player, Callback())
|
||||
.setSessionActivity(open)
|
||||
.setMediaButtonPreferences(buttons())
|
||||
.build()
|
||||
setMediaNotificationProvider(DefaultMediaNotificationProvider.Builder(this).build().apply { setSmallIcon(R.drawable.ic_stat_stems) })
|
||||
pm.addListener(onChange)
|
||||
// Auto loads every tab up front and keeps them; when home comes into (or out of) reach the
|
||||
// lists change (everything vs. what plays offline), so tell it to load them again.
|
||||
homeWatch = graph.scope.launch {
|
||||
graph.reach.home.drop(1).collect {
|
||||
val s = session ?: return@collect
|
||||
s.notifyChildrenChanged(CarIds.ROOT, rootLimit, null)
|
||||
for (tab in CarIds.TABS) s.notifyChildrenChanged(tab, 1, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val p = session?.player
|
||||
if (p == null || !p.playWhenReady || p.mediaItemCount == 0) stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
homeWatch?.cancel()
|
||||
pm.removeListener(onChange)
|
||||
session?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ---- buttons ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The now-playing buttons beside previous / play / next. On the Miata's 800-px-wide screen
|
||||
* Auto shows one or two of these and puts the rest behind "more", so the order is the
|
||||
* priority: vocals (the karaoke switch), then the preset cycler, then shuffle and repeat.
|
||||
* Songs without stems just get shuffle and repeat.
|
||||
*/
|
||||
private fun buttons(): List<CommandButton> {
|
||||
val names = pm.stemNames.value
|
||||
val stems = pm.source.value == SourceKind.Stems
|
||||
val list = ArrayList<CommandButton>()
|
||||
if (stems && "vocals" in names) {
|
||||
val silent = MixPresets.vocalsSilent(names, pm.mix.value)
|
||||
list += custom(ACTION_VOCALS, if (silent) "Vocals on" else "Vocals off", if (silent) R.drawable.ic_car_vocals_off else R.drawable.ic_car_vocals)
|
||||
}
|
||||
if (stems && names.size > 1) {
|
||||
val next = MixPresets.next(names, pm.mix.value)
|
||||
list += custom(ACTION_MIX, "Mix: ${next.label}", R.drawable.ic_car_mix)
|
||||
}
|
||||
val shuffled = pm.shuffle.value
|
||||
list += CommandButton.Builder(if (shuffled) CommandButton.ICON_SHUFFLE_ON else CommandButton.ICON_SHUFFLE_OFF)
|
||||
.setDisplayName(if (shuffled) "Shuffle off" else "Shuffle on")
|
||||
.setSessionCommand(SessionCommand(ACTION_SHUFFLE, Bundle.EMPTY))
|
||||
.setSlots(CommandButton.SLOT_OVERFLOW)
|
||||
.build()
|
||||
val repeat = pm.repeat.value
|
||||
list += CommandButton.Builder(
|
||||
when (repeat) {
|
||||
RepeatMode.Off -> CommandButton.ICON_REPEAT_OFF
|
||||
RepeatMode.All -> CommandButton.ICON_REPEAT_ALL
|
||||
RepeatMode.One -> CommandButton.ICON_REPEAT_ONE
|
||||
},
|
||||
)
|
||||
.setDisplayName(
|
||||
when (repeat) {
|
||||
RepeatMode.Off -> "Repeat all"
|
||||
RepeatMode.All -> "Repeat one"
|
||||
RepeatMode.One -> "Repeat off"
|
||||
},
|
||||
)
|
||||
.setSessionCommand(SessionCommand(ACTION_REPEAT, Bundle.EMPTY))
|
||||
.setSlots(CommandButton.SLOT_OVERFLOW)
|
||||
.build()
|
||||
return list
|
||||
}
|
||||
|
||||
private fun custom(action: String, name: String, icon: Int) = CommandButton.Builder(CommandButton.ICON_UNDEFINED)
|
||||
.setCustomIconResId(icon)
|
||||
.setDisplayName(name)
|
||||
.setSessionCommand(SessionCommand(action, Bundle.EMPTY))
|
||||
.setSlots(CommandButton.SLOT_OVERFLOW)
|
||||
.build()
|
||||
|
||||
private fun updateButtons() {
|
||||
val s = session ?: return
|
||||
val b = buttons()
|
||||
val key = b.joinToString { "${it.sessionCommand?.customAction}:${it.displayName}:${it.icon}" }
|
||||
if (key == buttonsKey) return
|
||||
buttonsKey = key
|
||||
s.setMediaButtonPreferences(b)
|
||||
}
|
||||
|
||||
private fun onButton(action: String): Boolean {
|
||||
val names = pm.stemNames.value
|
||||
val mix = pm.mix.value
|
||||
when (action) {
|
||||
ACTION_VOCALS -> {
|
||||
if (pm.source.value != SourceKind.Stems || "vocals" !in names) return false
|
||||
val v = mix["vocals"] ?: StemSetting()
|
||||
val anySolo = names.any { mix[it]?.solo == true }
|
||||
if (MixPresets.vocalsSilent(names, mix)) {
|
||||
// Muted on their own: unmute. Silenced by a solo elsewhere (Rhythm…): back to the full mix.
|
||||
if (v.muted && !anySolo) pm.toggleMute("vocals") else pm.applyMix(emptyMap())
|
||||
} else {
|
||||
// Muting a soloed vocal (A Cappella) would silence everything; go to Karaoke instead.
|
||||
if (anySolo) pm.applyMix(MixPresets.KARAOKE.build(names) ?: emptyMap()) else pm.toggleMute("vocals")
|
||||
}
|
||||
}
|
||||
ACTION_MIX -> {
|
||||
if (pm.source.value != SourceKind.Stems) return false
|
||||
pm.applyMix(MixPresets.next(names, mix).build(names) ?: emptyMap())
|
||||
}
|
||||
ACTION_SHUFFLE -> pm.toggleShuffle()
|
||||
ACTION_REPEAT -> pm.cycleRepeat()
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- library -----------------------------------------------------------------------
|
||||
|
||||
private inner class Callback : MediaLibrarySession.Callback {
|
||||
private val scope get() = graph.scope
|
||||
|
||||
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
|
||||
val commands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
|
||||
ACTIONS.forEach { commands.add(SessionCommand(it, Bundle.EMPTY)) }
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(commands.build())
|
||||
.setMediaButtonPreferences(buttons())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> =
|
||||
Futures.immediateFuture(
|
||||
if (onButton(customCommand.customAction)) SessionResult(SessionResult.RESULT_SUCCESS)
|
||||
else SessionResult(SessionError.ERROR_NOT_SUPPORTED),
|
||||
)
|
||||
|
||||
override fun onGetLibraryRoot(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, params: LibraryParams?): ListenableFuture<LibraryResult<MediaItem>> {
|
||||
// Auto says how many tabs it can show (4 on the Miata's screen).
|
||||
params?.extras?.getInt(MediaConstants.EXTRAS_KEY_ROOT_CHILDREN_LIMIT, 0)?.takeIf { it > 0 }?.let { rootLimit = it }
|
||||
val root = MediaItem.Builder().setMediaId(CarIds.ROOT).setMediaMetadata(
|
||||
MediaMetadata.Builder().setTitle("StemRoller").setIsBrowsable(true).setIsPlayable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED).build(),
|
||||
).build()
|
||||
val extras = Bundle().apply {
|
||||
putBoolean(SEARCH_SUPPORTED, true)
|
||||
putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
||||
putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
||||
}
|
||||
return Futures.immediateFuture(LibraryResult.ofItem(root, LibraryParams.Builder().setExtras(extras).build()))
|
||||
}
|
||||
|
||||
override fun onGetItem(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String): ListenableFuture<LibraryResult<MediaItem>> =
|
||||
Futures.immediateFuture(LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED))
|
||||
|
||||
override fun onGetChildren(
|
||||
session: MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String,
|
||||
page: Int, pageSize: Int, params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
|
||||
try {
|
||||
val all = if (parentId == CarIds.ROOT) {
|
||||
library.root(rootLimit)
|
||||
} else {
|
||||
library.children(parentId) ?: return@future LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)
|
||||
}
|
||||
LibraryResult.ofItemList(pageOf(all, page, pageSize), params)
|
||||
} catch (e: Exception) {
|
||||
LibraryResult.ofError(sessionError(e))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearch(session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, params: LibraryParams?): ListenableFuture<LibraryResult<Void>> = scope.future {
|
||||
val n = runCatching { library.searchItems(query).size }.getOrDefault(0)
|
||||
session.notifySearchResultChanged(browser, query, n, params)
|
||||
LibraryResult.ofVoid()
|
||||
}
|
||||
|
||||
override fun onGetSearchResult(
|
||||
session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String,
|
||||
page: Int, pageSize: Int, params: LibraryParams?,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
|
||||
try {
|
||||
LibraryResult.ofItemList(pageOf(library.searchItems(query), page, pageSize), params)
|
||||
} catch (e: Exception) {
|
||||
LibraryResult.ofError(sessionError(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** A tapped row (by id) or a voice request (by search query) becomes the whole queue. */
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession, controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>, startIndex: Int, startPositionMs: Long,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> = scope.future {
|
||||
val first = mediaItems.firstOrNull() ?: throw IllegalArgumentException("nothing to play")
|
||||
val query = first.requestMetadata.searchQuery
|
||||
val r: Resolved = try {
|
||||
if (query != null) library.resolveSearch(query) else library.resolve(first.mediaId)
|
||||
} catch (e: Exception) {
|
||||
pm.report(if (e is NotSignedInException) "Sign in to Plex on your phone" else "Couldn't load that: ${e.message}")
|
||||
null
|
||||
} ?: run {
|
||||
pm.report(if (query != null) "Nothing found for “$query”" else "That isn't available offline")
|
||||
throw IllegalArgumentException("unresolved ${first.mediaId}")
|
||||
}
|
||||
MediaSession.MediaItemsWithStartPosition(r.tracks.map { tagged(it, r.shuffle) }, r.start, 0)
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>): ListenableFuture<MutableList<MediaItem>> =
|
||||
scope.future {
|
||||
mediaItems.flatMap { item ->
|
||||
val r = runCatching { library.resolve(item.mediaId) }.getOrNull() ?: return@flatMap emptyList()
|
||||
r.tracks.drop(r.start).take(1).map { tagged(it, false) }
|
||||
}.toMutableList()
|
||||
}
|
||||
|
||||
/** Bluetooth / Auto "play" after the app was killed: the queue is already restored from disk, so just resume it. */
|
||||
override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val q = pm.queue.value
|
||||
if (q.isEmpty()) return Futures.immediateFailedFuture(UnsupportedOperationException("no saved queue"))
|
||||
val items = q.map { MediaItem.Builder().setMediaId("${it.uid}:${it.track.ratingKey}").setUri("stemroller://resume").setTag(ResumeTag).build() }
|
||||
return Futures.immediateFuture(MediaSession.MediaItemsWithStartPosition(items, pm.index.value.coerceIn(0, q.lastIndex), pm.positionMs()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun tagged(t: Track, shuffle: Boolean) = MediaItem.Builder()
|
||||
.setMediaId("track:${t.ratingKey}")
|
||||
.setUri("stemroller://track/${t.ratingKey}")
|
||||
.setTag(PlayTag(t, shuffle))
|
||||
.setMediaMetadata(MediaMetadata.Builder().setTitle(t.title).setArtist(t.artist).setArtworkUri(CarArtProvider.uri(t.thumb)).build())
|
||||
.build()
|
||||
|
||||
private fun pageOf(all: List<MediaItem>, page: Int, pageSize: Int): List<MediaItem> {
|
||||
if (pageSize <= 0 || pageSize == Int.MAX_VALUE) return all
|
||||
val from = page.toLong() * pageSize
|
||||
if (from >= all.size) return emptyList()
|
||||
return all.subList(from.toInt(), minOf(all.size.toLong(), from + pageSize).toInt())
|
||||
}
|
||||
|
||||
private fun sessionError(e: Exception): SessionError {
|
||||
Log.w(TAG, "car browse failed", e)
|
||||
return when (e) {
|
||||
is NotSignedInException -> SessionError(SessionError.ERROR_SESSION_SETUP_REQUIRED, "Sign in to Plex on your phone")
|
||||
else -> SessionError(SessionError.ERROR_IO, if (graph.reach.lastKnown) "Couldn't reach Plex" else "Not downloaded – connect to home Wi-Fi")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StemCar"
|
||||
private const val ACTION_VOCALS = "stemroller.VOCALS"
|
||||
private const val ACTION_MIX = "stemroller.MIX"
|
||||
private const val ACTION_SHUFFLE = "stemroller.SHUFFLE"
|
||||
private const val ACTION_REPEAT = "stemroller.REPEAT"
|
||||
private val ACTIONS = listOf(ACTION_VOCALS, ACTION_MIX, ACTION_SHUFFLE, ACTION_REPEAT)
|
||||
|
||||
/** Tells Auto to show its (voice) search button. */
|
||||
private const val SEARCH_SUPPORTED = "android.media.browse.SEARCH_SUPPORTED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.stemroller.mobile.plex
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.core.HttpException
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.core.await
|
||||
import com.stemroller.mobile.core.awaitBody
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
|
||||
class NotSignedInException : IOException("Not signed in to Plex")
|
||||
|
||||
/**
|
||||
* Plex Media Server and plex.tv calls. Library reads go through [http], whose cache
|
||||
* answers from disk when the server can't be reached (see Graph).
|
||||
*/
|
||||
class PlexClient(private val http: OkHttpClient, private val settings: Settings) {
|
||||
|
||||
val signedIn get() = settings.plexToken.value.isNotEmpty()
|
||||
private val base get() = settings.plexUrl.value.trimEnd('/')
|
||||
private val token get() = settings.plexToken.value
|
||||
|
||||
private fun Request.Builder.plexHeaders(withToken: Boolean = true): Request.Builder {
|
||||
header("Accept", "application/json")
|
||||
header("X-Plex-Product", PRODUCT)
|
||||
header("X-Plex-Version", "1.0")
|
||||
header("X-Plex-Client-Identifier", settings.clientId)
|
||||
header("X-Plex-Platform", "Android")
|
||||
header("X-Plex-Platform-Version", Build.VERSION.RELEASE)
|
||||
header("X-Plex-Device", Build.MODEL)
|
||||
header("X-Plex-Device-Name", "$PRODUCT (${Build.MODEL})")
|
||||
if (withToken && token.isNotEmpty()) header("X-Plex-Token", token)
|
||||
return this
|
||||
}
|
||||
|
||||
private fun url(path: String, vararg params: Pair<String, Any?>): HttpUrl {
|
||||
val b = (base + path).toHttpUrl().newBuilder()
|
||||
for ((k, v) in params) if (v != null) b.addQueryParameter(k, v.toString())
|
||||
return b.build()
|
||||
}
|
||||
|
||||
private suspend fun get(path: String, vararg params: Pair<String, Any?>, start: Int? = null, size: Int? = null): MediaContainer {
|
||||
if (!signedIn) throw NotSignedInException()
|
||||
val req = Request.Builder().url(url(path, *params)).plexHeaders().apply {
|
||||
if (start != null) header("X-Plex-Container-Start", start.toString())
|
||||
if (size != null) header("X-Plex-Container-Size", size.toString())
|
||||
}.build()
|
||||
val body = http.newCall(req).awaitBody()
|
||||
if (body.isBlank()) return MediaContainer()
|
||||
return AppJson.decodeFromString(PlexResponse.serializer(), body).container
|
||||
}
|
||||
|
||||
private suspend fun send(method: String, path: String, vararg params: Pair<String, Any?>): MediaContainer {
|
||||
if (!signedIn) throw NotSignedInException()
|
||||
val body = if (method == "DELETE") null else ByteArray(0).toRequestBody(null)
|
||||
val req = Request.Builder().url(url(path, *params)).plexHeaders().method(method, body).build()
|
||||
val text = http.newCall(req).awaitBody()
|
||||
return if (text.isBlank() || !text.trimStart().startsWith("{")) MediaContainer()
|
||||
else AppJson.decodeFromString(PlexResponse.serializer(), text).container
|
||||
}
|
||||
|
||||
/** Pages through a section listing; Plex caps a single response on big libraries. */
|
||||
private suspend fun all(path: String, vararg params: Pair<String, Any?>): List<PlexItem> {
|
||||
val out = ArrayList<PlexItem>()
|
||||
var start = 0
|
||||
while (true) {
|
||||
val page = get(path, *params, start = start, size = PAGE)
|
||||
out += page.metadata
|
||||
start += page.metadata.size
|
||||
val total = page.totalSize ?: page.size
|
||||
if (page.metadata.isEmpty() || start >= total) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- server -------------------------------------------------------------------
|
||||
|
||||
suspend fun machineId(): String {
|
||||
val req = Request.Builder().url(url("/identity")).plexHeaders(false).build()
|
||||
val body = http.newCall(req).awaitBody()
|
||||
val id = AppJson.decodeFromString(PlexResponse.serializer(), body).container.machineIdentifier
|
||||
return id ?: throw IOException("Not a Plex server")
|
||||
}
|
||||
|
||||
suspend fun musicSections(): List<MusicSection> = get("/library/sections").directories
|
||||
.filter { it.type == "artist" }
|
||||
.map { MusicSection(it.key, it.title, it.locations.map { l -> l.path }) }
|
||||
|
||||
private val section get() = settings.musicSection.value
|
||||
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
suspend fun artists(): List<Artist> =
|
||||
all("/library/sections/$section/all", "type" to 8, "sort" to "titleSort").map { it.toArtist() }
|
||||
|
||||
suspend fun albums(sort: String = "titleSort"): List<Album> =
|
||||
all("/library/sections/$section/all", "type" to 9, "sort" to sort).map { it.toAlbum() }
|
||||
|
||||
suspend fun recentlyAdded(count: Int = 20): List<Album> =
|
||||
get("/library/sections/$section/all", "type" to 9, "sort" to "addedAt:desc", start = 0, size = count)
|
||||
.metadata.map { it.toAlbum() }
|
||||
|
||||
/** One page of every track, sorted by title. Returns the page and the library's track count. */
|
||||
suspend fun tracksPage(start: Int, size: Int): Pair<List<Track>, Int> {
|
||||
val c = get("/library/sections/$section/all", "type" to 10, "sort" to "titleSort", start = start, size = size)
|
||||
return c.metadata.map { it.toTrack() } to (c.totalSize ?: c.size)
|
||||
}
|
||||
|
||||
suspend fun album(key: String): Album? = get("/library/metadata/$key").metadata.firstOrNull()?.toAlbum()
|
||||
|
||||
suspend fun albumTracks(key: String): List<Track> = get("/library/metadata/$key/children").metadata.map { it.toTrack() }
|
||||
|
||||
// Not /library/metadata/<artist>/children: that leaves out compilations, singles and live
|
||||
// albums (an artist with only "The Best of Kansas" came back empty).
|
||||
suspend fun artistAlbums(key: String): List<Album> =
|
||||
all("/library/sections/$section/all", "type" to 9, "artist.id" to key).map { it.toAlbum() }
|
||||
|
||||
suspend fun artistTracks(key: String): List<Track> = get("/library/metadata/$key/allLeaves").metadata.map { it.toTrack() }
|
||||
|
||||
suspend fun track(key: String): PlexItem? = get("/library/metadata/$key").metadata.firstOrNull()
|
||||
|
||||
/** Title search inside the music section; used to give stem-only entries Plex metadata. */
|
||||
suspend fun searchTracks(title: String): List<Track> =
|
||||
get("/library/sections/$section/search", "type" to 10, "query" to title).metadata.map { it.toTrack() }
|
||||
|
||||
/** Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. */
|
||||
suspend fun loudness(trackKey: String, samples: Int): FloatArray? {
|
||||
val streamId = track(trackKey)?.media?.firstOrNull()?.parts?.firstOrNull()
|
||||
?.streams?.firstOrNull { it.streamType == 2 }?.id ?: return null
|
||||
val levels = try {
|
||||
get("/library/streams/$streamId/levels", "subsample" to samples).levels
|
||||
} catch (e: HttpException) {
|
||||
return null // not analysed yet
|
||||
}
|
||||
return if (levels.isEmpty()) null else FloatArray(levels.size) { levels[it].v }
|
||||
}
|
||||
|
||||
suspend fun search(query: String): SearchResults {
|
||||
val hubs = get("/hubs/search", "query" to query, "limit" to 12, "sectionId" to section).hubs
|
||||
fun hub(type: String) = hubs.firstOrNull { it.type == type }?.metadata.orEmpty()
|
||||
return SearchResults(
|
||||
artists = hub("artist").map { it.toArtist() },
|
||||
albums = hub("album").map { it.toAlbum() },
|
||||
tracks = hub("track").map { it.toTrack() },
|
||||
playlists = hub("playlist").filter { it.playlistType == "audio" }.map { it.toPlaylist() },
|
||||
)
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------
|
||||
|
||||
suspend fun playlists(): List<Playlist> = get("/playlists", "playlistType" to "audio").metadata.map { it.toPlaylist() }
|
||||
|
||||
suspend fun playlistTracks(key: String): List<Track> = all("/playlists/$key/items").map { it.toTrack() }
|
||||
|
||||
private suspend fun libraryUri(keys: List<String>): String {
|
||||
var machine = settings.plexMachineId.value
|
||||
if (machine.isEmpty()) machine = machineId().also { settings.plexMachineId.value = it }
|
||||
return "server://$machine/com.plexapp.plugins.library/library/metadata/${keys.joinToString(",")}"
|
||||
}
|
||||
|
||||
suspend fun createPlaylist(title: String, trackKeys: List<String>): Playlist? =
|
||||
send("POST", "/playlists", "type" to "audio", "title" to title, "smart" to 0, "uri" to libraryUri(trackKeys))
|
||||
.metadata.firstOrNull()?.toPlaylist()
|
||||
|
||||
suspend fun addToPlaylist(key: String, trackKeys: List<String>) {
|
||||
send("PUT", "/playlists/$key/items", "uri" to libraryUri(trackKeys))
|
||||
}
|
||||
|
||||
suspend fun removeFromPlaylist(key: String, playlistItemId: Long) {
|
||||
send("DELETE", "/playlists/$key/items/$playlistItemId")
|
||||
}
|
||||
|
||||
/** Moves an item to just after [afterItemId], or to the top when it's null. */
|
||||
suspend fun movePlaylistItem(key: String, playlistItemId: Long, afterItemId: Long?) {
|
||||
send("PUT", "/playlists/$key/items/$playlistItemId/move", "after" to afterItemId)
|
||||
}
|
||||
|
||||
suspend fun renamePlaylist(key: String, title: String) {
|
||||
send("PUT", "/playlists/$key", "title" to title)
|
||||
}
|
||||
|
||||
suspend fun deletePlaylist(key: String) {
|
||||
send("DELETE", "/playlists/$key")
|
||||
}
|
||||
|
||||
// ---- playback reporting ---------------------------------------------------------
|
||||
|
||||
suspend fun scrobble(trackKey: String) {
|
||||
runCatching { get("/:/scrobble", "key" to trackKey, "identifier" to "com.plexapp.plugins.library") }
|
||||
}
|
||||
|
||||
suspend fun timeline(track: Track, state: String, positionMs: Long) {
|
||||
runCatching {
|
||||
get("/:/timeline", "ratingKey" to track.ratingKey, "key" to "/library/metadata/${track.ratingKey}",
|
||||
"state" to state, "time" to positionMs, "duration" to track.durationMs, "hasMDE" to 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- URLs -----------------------------------------------------------------------
|
||||
|
||||
fun artUrl(thumb: String?, px: Int): String? {
|
||||
if (thumb.isNullOrEmpty() || !signedIn) return null
|
||||
return Uri.parse("$base/photo/:/transcode").buildUpon()
|
||||
.appendQueryParameter("width", px.toString())
|
||||
.appendQueryParameter("height", px.toString())
|
||||
.appendQueryParameter("minSize", "1")
|
||||
.appendQueryParameter("upscale", "1")
|
||||
.appendQueryParameter("url", thumb)
|
||||
.appendQueryParameter("X-Plex-Token", token)
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
fun partUrl(partKey: String): String = "$base$partKey?X-Plex-Token=${Uri.encode(token)}"
|
||||
|
||||
// ---- plex.tv sign-in ------------------------------------------------------------
|
||||
|
||||
suspend fun createPin(): PlexPin {
|
||||
val req = Request.Builder().url("https://plex.tv/api/v2/pins?strong=false").plexHeaders(false)
|
||||
.post(ByteArray(0).toRequestBody(null)).build()
|
||||
return AppJson.decodeFromString(PlexPin.serializer(), http.newCall(req).awaitBody())
|
||||
}
|
||||
|
||||
suspend fun checkPin(id: Long): PlexPin {
|
||||
val req = Request.Builder().url("https://plex.tv/api/v2/pins/$id").plexHeaders(false).build()
|
||||
return AppJson.decodeFromString(PlexPin.serializer(), http.newCall(req).awaitBody())
|
||||
}
|
||||
|
||||
suspend fun user(token: String): PlexUser {
|
||||
val req = Request.Builder().url("https://plex.tv/api/v2/user").plexHeaders(false)
|
||||
.header("X-Plex-Token", token).build()
|
||||
return AppJson.decodeFromString(PlexUser.serializer(), http.newCall(req).awaitBody())
|
||||
}
|
||||
|
||||
/** True if [url] answers as a Plex server with the current token. */
|
||||
suspend fun probe(url: String, token: String): Boolean = runCatching {
|
||||
val req = Request.Builder().url(url.trimEnd('/') + "/library/sections").plexHeaders(false)
|
||||
.header("X-Plex-Token", token).build()
|
||||
withContext(Dispatchers.IO) { http.newCall(req).await().use { it.isSuccessful } }
|
||||
}.getOrDefault(false)
|
||||
|
||||
companion object {
|
||||
const val PRODUCT = "StemRoller"
|
||||
private const val PAGE = 1000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.stemroller.mobile.plex
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
|
||||
// ---- wire format (Accept: application/json) --------------------------------------
|
||||
|
||||
@Serializable
|
||||
data class PlexResponse(@SerialName("MediaContainer") val container: MediaContainer = MediaContainer())
|
||||
|
||||
@Serializable
|
||||
data class MediaContainer(
|
||||
val size: Int = 0,
|
||||
val totalSize: Int? = null,
|
||||
val machineIdentifier: String? = null,
|
||||
@SerialName("Metadata") val metadata: List<PlexItem> = emptyList(),
|
||||
@SerialName("Directory") val directories: List<PlexDirectory> = emptyList(),
|
||||
@SerialName("Hub") val hubs: List<PlexHub> = emptyList(),
|
||||
@SerialName("Level") val levels: List<PlexLevel> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexItem(
|
||||
val ratingKey: String = "",
|
||||
val key: String = "",
|
||||
val type: String = "",
|
||||
val title: String = "",
|
||||
val titleSort: String? = null,
|
||||
val originalTitle: String? = null,
|
||||
val parentTitle: String? = null,
|
||||
val grandparentTitle: String? = null,
|
||||
val parentRatingKey: String? = null,
|
||||
val grandparentRatingKey: String? = null,
|
||||
val index: Int? = null,
|
||||
val parentIndex: Int? = null,
|
||||
val duration: Long? = null,
|
||||
val thumb: String? = null,
|
||||
val parentThumb: String? = null,
|
||||
val grandparentThumb: String? = null,
|
||||
val composite: String? = null,
|
||||
val year: Int? = null,
|
||||
val parentYear: Int? = null,
|
||||
/** Release date, "1975-11-14", when Plex knows the day. */
|
||||
val originallyAvailableAt: String? = null,
|
||||
val addedAt: Long? = null,
|
||||
val leafCount: Int? = null,
|
||||
val playlistItemID: Long? = null,
|
||||
val playlistType: String? = null,
|
||||
/** Boolean in current servers, "0"/"1" in some older ones. */
|
||||
val smart: JsonPrimitive? = null,
|
||||
@SerialName("Media") val media: List<PlexMedia> = emptyList(),
|
||||
@SerialName("Genre") val genres: List<PlexTag> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexMedia(
|
||||
val id: Long = 0,
|
||||
val duration: Long? = null,
|
||||
val bitrate: Int? = null,
|
||||
val audioCodec: String? = null,
|
||||
val container: String? = null,
|
||||
@SerialName("Part") val parts: List<PlexPart> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexPart(
|
||||
val id: Long = 0,
|
||||
val key: String = "",
|
||||
val duration: Long? = null,
|
||||
val file: String? = null,
|
||||
val size: Long? = null,
|
||||
val container: String? = null,
|
||||
@SerialName("Stream") val streams: List<PlexStream> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexStream(val id: Long = 0, val streamType: Int = 0)
|
||||
|
||||
@Serializable
|
||||
data class PlexTag(val tag: String = "")
|
||||
|
||||
@Serializable
|
||||
data class PlexDirectory(
|
||||
val key: String = "",
|
||||
val type: String = "",
|
||||
val title: String = "",
|
||||
@SerialName("Location") val locations: List<PlexLocation> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexLocation(val path: String = "")
|
||||
|
||||
@Serializable
|
||||
data class PlexHub(
|
||||
val type: String = "",
|
||||
val hubIdentifier: String = "",
|
||||
val title: String = "",
|
||||
@SerialName("Metadata") val metadata: List<PlexItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlexLevel(val v: Float = 0f)
|
||||
|
||||
@Serializable
|
||||
data class PlexPin(val id: Long = 0, val code: String = "", val authToken: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class PlexUser(val username: String? = null, val title: String? = null, val email: String? = null)
|
||||
|
||||
// ---- app models ------------------------------------------------------------------
|
||||
|
||||
@Serializable
|
||||
data class Track(
|
||||
val ratingKey: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String,
|
||||
val albumKey: String? = null,
|
||||
val artistKey: String? = null,
|
||||
val index: Int? = null,
|
||||
val disc: Int? = null,
|
||||
val durationMs: Long = 0,
|
||||
val thumb: String? = null,
|
||||
val partKey: String? = null,
|
||||
val partId: Long? = null,
|
||||
val partFile: String? = null,
|
||||
val partSize: Long? = null,
|
||||
val codec: String? = null,
|
||||
val year: Int? = null,
|
||||
val playlistItemId: Long? = null,
|
||||
/** Set for tracks known only from the stems index (no Plex match): play this stem folder. */
|
||||
val stemKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Album(
|
||||
val ratingKey: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val artistKey: String? = null,
|
||||
val year: Int? = null,
|
||||
val thumb: String? = null,
|
||||
val trackCount: Int? = null,
|
||||
val genre: String? = null,
|
||||
/** "1975-11-14" when Plex knows the release day. */
|
||||
val released: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Artist(val ratingKey: String, val title: String, val sortTitle: String, val thumb: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class Playlist(
|
||||
val ratingKey: String,
|
||||
val title: String,
|
||||
val thumb: String? = null,
|
||||
val trackCount: Int = 0,
|
||||
val durationMs: Long = 0,
|
||||
val smart: Boolean = false,
|
||||
)
|
||||
|
||||
data class MusicSection(val key: String, val title: String, val locations: List<String>)
|
||||
|
||||
data class SearchResults(
|
||||
val artists: List<Artist> = emptyList(),
|
||||
val albums: List<Album> = emptyList(),
|
||||
val tracks: List<Track> = emptyList(),
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
) {
|
||||
val isEmpty get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty() && playlists.isEmpty()
|
||||
}
|
||||
|
||||
fun PlexItem.toTrack(): Track {
|
||||
val media = media.firstOrNull()
|
||||
val part = media?.parts?.firstOrNull()
|
||||
return Track(
|
||||
ratingKey = ratingKey,
|
||||
title = title,
|
||||
artist = originalTitle?.takeIf { it.isNotBlank() } ?: grandparentTitle ?: "Unknown Artist",
|
||||
album = parentTitle ?: "",
|
||||
albumKey = parentRatingKey,
|
||||
artistKey = grandparentRatingKey,
|
||||
index = index,
|
||||
disc = parentIndex,
|
||||
durationMs = duration ?: media?.duration ?: part?.duration ?: 0,
|
||||
thumb = thumb ?: parentThumb ?: grandparentThumb,
|
||||
partKey = part?.key,
|
||||
partId = part?.id,
|
||||
partFile = part?.file,
|
||||
partSize = part?.size,
|
||||
codec = media?.audioCodec ?: part?.container,
|
||||
year = parentYear ?: year,
|
||||
playlistItemId = playlistItemID,
|
||||
)
|
||||
}
|
||||
|
||||
fun PlexItem.toAlbum() = Album(
|
||||
ratingKey = ratingKey,
|
||||
title = title,
|
||||
artist = parentTitle ?: "Unknown Artist",
|
||||
artistKey = parentRatingKey,
|
||||
year = year,
|
||||
thumb = thumb,
|
||||
trackCount = leafCount,
|
||||
genre = genres.firstOrNull()?.tag,
|
||||
released = originallyAvailableAt?.takeIf { it.length >= 10 },
|
||||
)
|
||||
|
||||
fun PlexItem.toArtist() = Artist(ratingKey, title, titleSort ?: title, thumb)
|
||||
|
||||
fun PlexItem.toPlaylist() = Playlist(
|
||||
ratingKey = ratingKey,
|
||||
title = title,
|
||||
thumb = composite ?: thumb,
|
||||
trackCount = leafCount ?: 0,
|
||||
durationMs = duration ?: 0,
|
||||
smart = smart?.booleanOrNull ?: (smart?.content == "1"),
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.stemroller.mobile.stems
|
||||
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.plex.PlexClient
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
|
||||
/** How many of an album's (or artist's) songs have stems. */
|
||||
data class Coverage(val total: Int, val withStems: Int) {
|
||||
val none get() = total > 0 && withStems == 0
|
||||
val partial get() = withStems in 1 until total
|
||||
}
|
||||
|
||||
/**
|
||||
* Which albums and artists have stems. Plex albums carry no file paths, so this keeps a slim
|
||||
* copy of every track's path (fetched in pages, cached on disk, refreshed every few hours) and
|
||||
* matches it against the stems index whenever that changes. Until the first full listing
|
||||
* arrives [ready] is false and nothing is flagged.
|
||||
*/
|
||||
class StemCoverage(
|
||||
private val plex: PlexClient,
|
||||
private val stems: StemServer,
|
||||
private val settings: Settings,
|
||||
dir: File,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
@Serializable
|
||||
private data class Slim(val k: String, val al: String? = null, val ar: String? = null, val f: String? = null, val d: Long = 0)
|
||||
|
||||
@Serializable
|
||||
private data class Listing(val section: String, val at: Long, val tracks: List<Slim>)
|
||||
|
||||
private val file = File(dir, "library_tracks.json")
|
||||
private val lock = Mutex()
|
||||
@Volatile private var listing: Listing? = null
|
||||
|
||||
private val _albums = MutableStateFlow<Map<String, Coverage>>(emptyMap())
|
||||
val albums: StateFlow<Map<String, Coverage>> = _albums.asStateFlow()
|
||||
private val _artists = MutableStateFlow<Map<String, Coverage>>(emptyMap())
|
||||
val artists: StateFlow<Map<String, Coverage>> = _artists.asStateFlow()
|
||||
private val _ready = MutableStateFlow(false)
|
||||
/** True once the whole library has been listed and matched against a non-empty stems index. */
|
||||
val ready: StateFlow<Boolean> = _ready.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
listing = runCatching { AppJson.decodeFromString(Listing.serializer(), file.readText()) }.getOrNull()
|
||||
settings.musicSection.flow.collectLatest { section ->
|
||||
if (section.isEmpty()) return@collectLatest
|
||||
val l = listing
|
||||
if (l == null || l.section != section || System.currentTimeMillis() - l.at > STALE_MS) refresh()
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
stems.index.collectLatest { recompute() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists every track in the library again (every few hours). */
|
||||
suspend fun refresh() = lock.withLock {
|
||||
val section = settings.musicSection.value
|
||||
if (section.isEmpty() || !plex.signedIn) return@withLock
|
||||
val all = ArrayList<Slim>()
|
||||
runCatching {
|
||||
var start = 0
|
||||
while (true) {
|
||||
val (page, total) = plex.tracksPage(start, PAGE)
|
||||
all += page.map { Slim(it.ratingKey, it.albumKey, it.artistKey, it.partFile, it.durationMs) }
|
||||
start += page.size
|
||||
if (page.isEmpty() || start >= total) break
|
||||
}
|
||||
}.onFailure { return@withLock }
|
||||
val l = Listing(section, System.currentTimeMillis(), all)
|
||||
listing = l
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
tmp.writeText(AppJson.encodeToString(Listing.serializer(), l))
|
||||
tmp.renameTo(file)
|
||||
}
|
||||
}
|
||||
recompute()
|
||||
}
|
||||
|
||||
private fun recompute() {
|
||||
val l = listing ?: return
|
||||
if (l.section != settings.musicSection.value) return
|
||||
if (stems.index.value.size == 0) return
|
||||
val albums = HashMap<String, IntArray>()
|
||||
val artists = HashMap<String, IntArray>()
|
||||
for (t in l.tracks) {
|
||||
val has = stems.find(Track(t.k, "", "", "", partFile = t.f, durationMs = t.d)) != null
|
||||
t.al?.let { albums.getOrPut(it) { IntArray(2) }.also { c -> c[0]++; if (has) c[1]++ } }
|
||||
t.ar?.let { artists.getOrPut(it) { IntArray(2) }.also { c -> c[0]++; if (has) c[1]++ } }
|
||||
}
|
||||
_albums.value = albums.mapValues { (_, c) -> Coverage(c[0], c[1]) }
|
||||
_artists.value = artists.mapValues { (_, c) -> Coverage(c[0], c[1]) }
|
||||
_ready.value = true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PAGE = 1000
|
||||
const val STALE_MS = 6 * 3600_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.stemroller.mobile.stems
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.text.Normalizer
|
||||
import kotlin.math.abs
|
||||
|
||||
@Serializable
|
||||
data class StemFile(val name: String, val file: String, val size: Long, val mtime: Long = 0)
|
||||
|
||||
@Serializable
|
||||
data class StemEntry(
|
||||
val key: String,
|
||||
val source: String,
|
||||
val duration: Double? = null,
|
||||
val samplerate: Int? = null,
|
||||
val model: String? = null,
|
||||
val finished: Double? = null,
|
||||
val stems: List<StemFile> = emptyList(),
|
||||
) {
|
||||
/** "Artist/Album/01 Song" -> "01 Song" etc., for stem-only browsing. */
|
||||
val title get() = key.substringAfterLast('/')
|
||||
val folder get() = key.substringBeforeLast('/', "")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StemIndexFile(val version: Int = 1, val tracks: List<StemEntry> = emptyList())
|
||||
|
||||
/**
|
||||
* Maps Plex file paths to stem folders. The stems folder mirrors the music folder, so the
|
||||
* source path relative to the music root identifies a track; Plex reports absolute paths
|
||||
* as its container sees them, so the root is unknown up front.
|
||||
*
|
||||
* Matching tries every suffix of the Plex path, longest first. A suffix that is only a
|
||||
* file name, or whose duration disagrees, is accepted only under a root prefix learned
|
||||
* from an earlier, longer match, so "Intro.flac" in one album can't grab another's stems.
|
||||
*/
|
||||
class StemIndex(val entries: List<StemEntry>, roots: Collection<String> = emptyList()) {
|
||||
private val bySource = HashMap<String, StemEntry>(entries.size * 2)
|
||||
private val learned = LinkedHashSet(roots)
|
||||
|
||||
init {
|
||||
for (e in entries) bySource.putIfAbsent(norm(e.source), e)
|
||||
}
|
||||
|
||||
val size get() = entries.size
|
||||
val roots: Set<String> @Synchronized get() = learned.toSet()
|
||||
|
||||
/** Returns the entry for a Plex part path, learning the path's root on a confident match. */
|
||||
@Synchronized
|
||||
fun find(plexPath: String?, durationMs: Long = 0): StemEntry? {
|
||||
if (plexPath.isNullOrEmpty() || entries.isEmpty()) return null
|
||||
val path = norm(plexPath)
|
||||
for (root in learned) {
|
||||
if (path.startsWith(root)) bySource[path.substring(root.length)]?.let { return it }
|
||||
}
|
||||
val parts = path.split('/')
|
||||
for (i in 1 until parts.size) {
|
||||
val rel = parts.subList(i, parts.size).joinToString("/")
|
||||
val hit = bySource[rel] ?: continue
|
||||
val deep = parts.size - i >= 2
|
||||
if (deep && durationMatches(hit, durationMs)) {
|
||||
learned += parts.subList(0, i).joinToString("/", postfix = "/")
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun durationMatches(e: StemEntry, durationMs: Long): Boolean {
|
||||
val d = e.duration ?: return true
|
||||
if (durationMs <= 0) return true
|
||||
return abs(d * 1000 - durationMs) < 3000
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** NFC, forward slashes, no extension, lower case. */
|
||||
fun norm(path: String): String {
|
||||
var p = Normalizer.normalize(path, Normalizer.Form.NFC).replace('\\', '/')
|
||||
val slash = p.lastIndexOf('/')
|
||||
val dot = p.lastIndexOf('.')
|
||||
if (dot > slash + 1) p = p.substring(0, dot)
|
||||
return p.lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.stemroller.mobile.stems
|
||||
|
||||
import android.net.Uri
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.core.HttpException
|
||||
import com.stemroller.mobile.core.Settings
|
||||
import com.stemroller.mobile.core.StemQuality
|
||||
import com.stemroller.mobile.core.await
|
||||
import com.stemroller.mobile.core.awaitBody
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
|
||||
sealed interface StemServerStatus {
|
||||
data object Unknown : StemServerStatus
|
||||
data class Online(val tracks: Int, val transcode: Boolean) : StemServerStatus
|
||||
data class Offline(val reason: String, val cachedTracks: Int) : StemServerStatus
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class StemRequest(val paths: List<String>)
|
||||
|
||||
@Serializable
|
||||
private data class ServerInfo(val name: String = "", val version: Int = 0, val tracks: Int = 0, val ffmpeg: Boolean = false)
|
||||
|
||||
/** Client for server/stemserve.py: the stems index, stem file URLs and waveforms. */
|
||||
class StemServer(
|
||||
private val http: OkHttpClient,
|
||||
private val settings: Settings,
|
||||
private val dir: File,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val indexFile = File(dir, "stem_index.json")
|
||||
private val etagFile = File(dir, "stem_index.etag")
|
||||
private val refreshLock = Mutex()
|
||||
|
||||
private val _index = MutableStateFlow(StemIndex(emptyList()))
|
||||
val index: StateFlow<StemIndex> = _index.asStateFlow()
|
||||
private val _status = MutableStateFlow<StemServerStatus>(StemServerStatus.Unknown)
|
||||
val status: StateFlow<StemServerStatus> = _status.asStateFlow()
|
||||
private var transcode = false
|
||||
|
||||
private val base get() = settings.stemUrl.value.trimEnd('/')
|
||||
private fun roots() = settings.stemRoots.value.split('\n').filter { it.isNotEmpty() }
|
||||
|
||||
/** When the index was last checked against the server (epoch ms), 0 before the first check. */
|
||||
private val _checkedAt = MutableStateFlow(0L)
|
||||
val checkedAt: StateFlow<Long> = _checkedAt.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (indexFile.exists()) {
|
||||
val parsed = AppJson.decodeFromString(StemIndexFile.serializer(), indexFile.readText())
|
||||
_index.value = StemIndex(parsed.tracks, roots())
|
||||
}
|
||||
}
|
||||
// stemd on the server separates new music around the clock; look for its new stems
|
||||
// every minute. Unchanged indexes cost one 304.
|
||||
while (true) {
|
||||
refresh()
|
||||
delay(CHECK_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.Builder.auth(): Request.Builder {
|
||||
val t = settings.stemToken.value
|
||||
if (t.isNotEmpty()) header("X-Stem-Token", t)
|
||||
return this
|
||||
}
|
||||
|
||||
suspend fun refresh(force: Boolean = false) = withContext(Dispatchers.IO) {
|
||||
refreshLock.withLock {
|
||||
try {
|
||||
val info = AppJson.decodeFromString(
|
||||
ServerInfo.serializer(),
|
||||
http.newCall(Request.Builder().url("$base/api/info").auth().build()).awaitBody(),
|
||||
)
|
||||
transcode = info.ffmpeg
|
||||
val etag = if (!force && etagFile.exists() && indexFile.exists()) etagFile.readText() else null
|
||||
val req = Request.Builder().url("$base/api/index").auth()
|
||||
.apply { if (etag != null) header("If-None-Match", etag) }.build()
|
||||
http.newCall(req).await().use { r ->
|
||||
when {
|
||||
r.code == 304 -> Unit
|
||||
r.isSuccessful -> {
|
||||
val body = r.body?.string().orEmpty()
|
||||
val parsed = AppJson.decodeFromString(StemIndexFile.serializer(), body)
|
||||
_index.value = StemIndex(parsed.tracks, roots())
|
||||
indexFile.writeText(body)
|
||||
r.header("ETag")?.let { etagFile.writeText(it) }
|
||||
}
|
||||
else -> throw HttpException(r.code, "stemserve answered ${r.code}")
|
||||
}
|
||||
}
|
||||
_status.value = StemServerStatus.Online(_index.value.size, info.ffmpeg)
|
||||
_checkedAt.value = System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
_status.value = StemServerStatus.Offline(e.message ?: e.javaClass.simpleName, _index.value.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun find(track: Track): StemEntry? {
|
||||
val idx = _index.value
|
||||
track.stemKey?.let { k -> return idx.entries.firstOrNull { it.key == k } }
|
||||
val hit = idx.find(track.partFile, track.durationMs)
|
||||
val learned = idx.roots.joinToString("\n")
|
||||
if (learned != settings.stemRoots.value) settings.stemRoots.value = learned
|
||||
return hit
|
||||
}
|
||||
|
||||
/** Format the server will actually send: Opus needs ffmpeg there. */
|
||||
fun effectiveQuality(q: StemQuality) = if (q.fmt == "opus" && !transcode && _status.value is StemServerStatus.Online) StemQuality.Lossless else q
|
||||
|
||||
fun fileUrl(entry: StemEntry, stem: StemFile, quality: StemQuality): String {
|
||||
val b = Uri.parse(base).buildUpon().appendPath("file")
|
||||
entry.key.split('/').forEach { b.appendPath(it) }
|
||||
b.appendPath(stem.file)
|
||||
if (quality.fmt == "opus") b.appendQueryParameter("fmt", "opus").appendQueryParameter("br", quality.kbps.toString())
|
||||
settings.stemToken.value.takeIf { it.isNotEmpty() }?.let { b.appendQueryParameter("token", it) }
|
||||
return b.build().toString()
|
||||
}
|
||||
|
||||
/** Asks the server to start the Opus transcodes now, so the stems are ready when we need them. */
|
||||
fun prepare(entry: StemEntry, quality: StemQuality) {
|
||||
if (quality.fmt != "opus") return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val url = Uri.parse("$base/api/prepare").buildUpon().appendQueryParameter("key", entry.key)
|
||||
.appendQueryParameter("fmt", "opus").appendQueryParameter("br", quality.kbps.toString()).build()
|
||||
http.newCall(Request.Builder().url(url.toString()).auth().post(ByteArray(0).toRequestBody(null)).build())
|
||||
.await().close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Stem Now": asks stemd (through stemserve) to separate these songs before the rest of the
|
||||
* library. Returns how many it queued: the ones with no stems yet.
|
||||
*/
|
||||
suspend fun stemNow(tracks: List<Track>): Int = withContext(Dispatchers.IO) {
|
||||
val paths = tracks.filter { find(it) == null }.mapNotNull { it.partFile }.distinct()
|
||||
if (paths.isEmpty()) return@withContext 0
|
||||
val body = AppJson.encodeToString(StemRequest.serializer(), StemRequest(paths)).toRequestBody("application/json".toMediaType())
|
||||
http.newCall(Request.Builder().url("$base/api/stem").auth().post(body).build()).await().use { r ->
|
||||
if (!r.isSuccessful) {
|
||||
val msg = runCatching { AppJson.parseToJsonElement(r.body!!.string()).jsonObject["error"]!!.jsonPrimitive.content }.getOrNull()
|
||||
throw HttpException(r.code, when {
|
||||
r.code == 404 -> "stemserve needs updating for Stem Now"
|
||||
msg != null -> msg
|
||||
else -> "stemserve answered ${r.code}"
|
||||
})
|
||||
}
|
||||
}
|
||||
paths.size
|
||||
}
|
||||
|
||||
/** Per-stem RMS envelopes (0..1 of full scale), [buckets] values each. */
|
||||
suspend fun peaks(entry: StemEntry, buckets: Int): Map<String, FloatArray>? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val url = Uri.parse("$base/api/peaks").buildUpon().appendQueryParameter("key", entry.key)
|
||||
.appendQueryParameter("n", buckets.toString()).build()
|
||||
val body = http.newCall(Request.Builder().url(url.toString()).auth().build()).awaitBody()
|
||||
val stems = AppJson.parseToJsonElement(body).jsonObject["stems"] as JsonObject
|
||||
stems.mapValues { (_, arr) ->
|
||||
val a = arr.jsonArray
|
||||
FloatArray(a.size) { a[it].jsonPrimitive.int / 1000f }
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CHECK_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.stemroller.mobile.ui
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.ui.components.Hairline
|
||||
import com.stemroller.mobile.ui.components.LocalBottomInset
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.OverlayHost
|
||||
import com.stemroller.mobile.ui.components.Overlays
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.nav.NavStack
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.nav.StackHost
|
||||
import com.stemroller.mobile.ui.screens.AlbumScreen
|
||||
import com.stemroller.mobile.ui.screens.EqFilterScreen
|
||||
import com.stemroller.mobile.ui.screens.EqFiltersScreen
|
||||
import com.stemroller.mobile.ui.screens.AlbumsScreen
|
||||
import com.stemroller.mobile.ui.screens.AppActions
|
||||
import com.stemroller.mobile.ui.screens.AppearanceScreen
|
||||
import com.stemroller.mobile.ui.screens.ArtistScreen
|
||||
import com.stemroller.mobile.ui.screens.ArtistsScreen
|
||||
import com.stemroller.mobile.ui.screens.DownloadsScreen
|
||||
import com.stemroller.mobile.ui.screens.EqScreen
|
||||
import com.stemroller.mobile.ui.screens.LibraryHome
|
||||
import com.stemroller.mobile.ui.screens.LocalApp
|
||||
import com.stemroller.mobile.ui.screens.MiniPlayer
|
||||
import com.stemroller.mobile.ui.screens.NowPlayingScreen
|
||||
import com.stemroller.mobile.ui.screens.PlaylistScreen
|
||||
import com.stemroller.mobile.ui.screens.PlaylistsScreen
|
||||
import com.stemroller.mobile.ui.screens.PlexAccountScreen
|
||||
import com.stemroller.mobile.ui.screens.QualityScreen
|
||||
import com.stemroller.mobile.ui.screens.SearchScreen
|
||||
import com.stemroller.mobile.ui.screens.SettingsScreen
|
||||
import com.stemroller.mobile.ui.screens.SongsScreen
|
||||
import com.stemroller.mobile.ui.screens.StemServerScreen
|
||||
import com.stemroller.mobile.ui.screens.StorageScreen
|
||||
import com.stemroller.mobile.ui.screens.Tab
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.IosTheme
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
private fun Screen(route: Route) = when (route) {
|
||||
Route.Library -> LibraryHome()
|
||||
Route.Artists -> ArtistsScreen()
|
||||
Route.Albums -> AlbumsScreen()
|
||||
Route.Songs -> SongsScreen()
|
||||
Route.Downloads -> DownloadsScreen()
|
||||
Route.Playlists -> PlaylistsScreen()
|
||||
Route.Search -> SearchScreen()
|
||||
Route.Eq -> EqScreen()
|
||||
is Route.EqFilters -> EqFiltersScreen(route.profileId)
|
||||
is Route.EqFilterPage -> EqFilterScreen(route.profileId, route.filterId)
|
||||
Route.Settings -> SettingsScreen()
|
||||
Route.PlexAccount -> PlexAccountScreen()
|
||||
Route.StemServer -> StemServerScreen()
|
||||
Route.Quality -> QualityScreen()
|
||||
Route.Storage -> StorageScreen()
|
||||
Route.Appearance -> AppearanceScreen()
|
||||
is Route.ArtistPage -> ArtistScreen(route.artist)
|
||||
is Route.AlbumPage -> AlbumScreen(route.album)
|
||||
is Route.PlaylistPage -> PlaylistScreen(route.playlist)
|
||||
}
|
||||
|
||||
private fun rootOf(tab: Tab) = when (tab) {
|
||||
Tab.Library -> Route.Library
|
||||
Tab.Playlists -> Route.Playlists
|
||||
Tab.Search -> Route.Search
|
||||
Tab.Downloads -> Route.Downloads
|
||||
Tab.Settings -> Route.Settings
|
||||
}
|
||||
|
||||
/** The Now Playing card's position: 0 hidden, 1 fully up. Driven by springs, drags and back swipes. */
|
||||
@Stable
|
||||
class SheetState(private val scope: CoroutineScope) {
|
||||
var progress by mutableFloatStateOf(0f)
|
||||
private var job: Job? = null
|
||||
|
||||
fun animateTo(target: Float, velocity: Float = 0f, spec: AnimationSpec<Float> = spring(dampingRatio = 0.88f, stiffness = 380f)) {
|
||||
job?.cancel()
|
||||
job = scope.launch { animate(progress, target, velocity, spec) { v, _ -> progress = v.coerceIn(0f, 1f) } }
|
||||
}
|
||||
|
||||
fun snap(v: Float) {
|
||||
job?.cancel()
|
||||
progress = v.coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StemApp(openNowPlaying: Int) {
|
||||
val g = App.graph
|
||||
val theme by g.settings.theme.flow.collectAsState()
|
||||
val accent by g.settings.accent.flow.collectAsState()
|
||||
IosTheme(theme, accent) { Shell(openNowPlaying) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Shell(openNowPlaying: Int) {
|
||||
val g = App.graph
|
||||
val c = Ios.colors
|
||||
val scope = rememberCoroutineScope()
|
||||
val overlays = remember { Overlays() }
|
||||
val holder = rememberSaveableStateHolder()
|
||||
val stacks = remember { Tab.entries.associateWith { NavStack(rootOf(it)) } }
|
||||
var tab by remember { mutableStateOf(Tab.Library) }
|
||||
val sheet = remember { SheetState(scope) }
|
||||
val sheetShown by remember { derivedStateOf { sheet.progress > 0.001f } }
|
||||
val current by g.playback.current.collectAsState()
|
||||
val density = LocalDensity.current
|
||||
val navBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val statusTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val actions = remember {
|
||||
AppActions(
|
||||
openNowPlaying = { sheet.animateTo(1f) },
|
||||
closeNowPlaying = { sheet.animateTo(0f) },
|
||||
selectTab = { tab = it },
|
||||
showInLibrary = { r ->
|
||||
sheet.animateTo(0f)
|
||||
tab = Tab.Library
|
||||
stacks.getValue(Tab.Library).push(r)
|
||||
},
|
||||
showInSettings = { r ->
|
||||
sheet.animateTo(0f)
|
||||
tab = Tab.Settings
|
||||
val st = stacks.getValue(Tab.Settings)
|
||||
if (st.entries.last().route != r) st.push(r)
|
||||
},
|
||||
)
|
||||
}
|
||||
LaunchedEffect(openNowPlaying) { if (openNowPlaying > 0 && g.playback.current.value != null) sheet.animateTo(1f) }
|
||||
LaunchedEffect(Unit) { g.playback.messages.collect { overlays.hud(it, Sym.warning) } }
|
||||
|
||||
val bottomInset = navBottom + 50.dp + if (current != null) 64.dp else 0.dp
|
||||
|
||||
CompositionLocalProvider(LocalOverlays provides overlays, LocalApp provides actions, LocalBottomInset provides bottomInset) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
val heightPx = constraints.maxHeight.toFloat()
|
||||
val sheetDrag = Modifier.pointerInput(Unit) {
|
||||
val tracker = VelocityTracker()
|
||||
var y = 0f
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { tracker.resetTracking(); y = 0f },
|
||||
onDragEnd = {
|
||||
val v = tracker.calculateVelocity().y
|
||||
if (v > 900f || sheet.progress < 0.7f) sheet.animateTo(0f, -v / heightPx) else sheet.animateTo(1f, -v / heightPx)
|
||||
},
|
||||
onDragCancel = { sheet.animateTo(1f) },
|
||||
) { ch, dy ->
|
||||
// The sheet moves with the finger, so track the finger in screen terms.
|
||||
y += dy
|
||||
tracker.addPosition(ch.uptimeMillis, androidx.compose.ui.geometry.Offset(0f, y))
|
||||
ch.consume()
|
||||
sheet.snap(sheet.progress - dy / heightPx)
|
||||
}
|
||||
}
|
||||
val miniDrag = Modifier.pointerInput(Unit) {
|
||||
val tracker = VelocityTracker()
|
||||
var y = 0f
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { tracker.resetTracking(); y = 0f },
|
||||
onDragEnd = {
|
||||
val v = tracker.calculateVelocity().y
|
||||
if (v < -700f || sheet.progress > 0.3f) sheet.animateTo(1f, -v / heightPx) else sheet.animateTo(0f, -v / heightPx)
|
||||
},
|
||||
onDragCancel = { sheet.animateTo(0f) },
|
||||
) { ch, dy ->
|
||||
// The sheet moves with the finger, so track the finger in screen terms.
|
||||
y += dy
|
||||
tracker.addPosition(ch.uptimeMillis, androidx.compose.ui.geometry.Offset(0f, y))
|
||||
ch.consume()
|
||||
sheet.snap(sheet.progress - dy / heightPx)
|
||||
}
|
||||
}
|
||||
|
||||
// The app, which recedes into a card while Now Playing is up.
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val p = sheet.progress
|
||||
val s = 1f - 0.075f * p
|
||||
scaleX = s; scaleY = s
|
||||
translationY = statusTop.toPx() * 0.55f * p
|
||||
shape = RoundedCornerShape((14f * p).dp)
|
||||
clip = p > 0f
|
||||
}
|
||||
.background(c.bg)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val p = sheet.progress
|
||||
if (p > 0f) drawRect(Color.Black.copy(alpha = 0.4f * p))
|
||||
},
|
||||
) {
|
||||
key(tab) {
|
||||
holder.SaveableStateProvider("tab:${tab.name}") {
|
||||
StackHost(stacks.getValue(tab), holder, active = true) { route -> Screen(route) }
|
||||
}
|
||||
}
|
||||
Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth()) {
|
||||
if (current != null) MiniPlayer(onOpen = { sheet.animateTo(1f) }, dragModifier = miniDrag)
|
||||
TabBar(tab, navBottom) { t ->
|
||||
if (t == tab) stacks.getValue(t).popToRoot() else tab = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetShown) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().padding(top = statusTop + 6.dp)
|
||||
.graphicsLayer {
|
||||
translationY = (1f - sheet.progress) * (heightPx + 40f)
|
||||
shape = RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||
clip = true
|
||||
},
|
||||
) {
|
||||
NowPlayingScreen(onClose = { sheet.animateTo(0f) }, dragModifier = sheetDrag)
|
||||
}
|
||||
// Predictive back slides the card down with the gesture; release to dismiss.
|
||||
PredictiveBackHandler(enabled = sheetShown) { events ->
|
||||
try {
|
||||
events.collect { e -> sheet.snap(1f - 0.3f * e.progress) }
|
||||
sheet.animateTo(0f, spec = tween(220))
|
||||
} catch (e: CancellationException) {
|
||||
sheet.animateTo(1f)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OverlayHost(overlays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabBar(selected: Tab, navBottom: androidx.compose.ui.unit.Dp, onSelect: (Tab) -> Unit) {
|
||||
val c = Ios.colors
|
||||
Column(Modifier.fillMaxWidth().background(c.bar)) {
|
||||
Hairline(color = c.barBorder)
|
||||
Row(Modifier.fillMaxWidth().height(50.dp)) {
|
||||
Tab.entries.forEach { t ->
|
||||
val on = t == selected
|
||||
val col = if (on) c.accent else c.tabInactive
|
||||
Column(
|
||||
Modifier.weight(1f).height(50.dp).tappable { onSelect(t) }.padding(top = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
when (t) {
|
||||
Tab.Library -> Sym.library
|
||||
Tab.Playlists -> Sym.noteList
|
||||
Tab.Search -> Sym.search
|
||||
Tab.Downloads -> Sym.download
|
||||
Tab.Settings -> Sym.gear
|
||||
},
|
||||
col, size = 26.dp,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Txt(t.label, Type.caption2.copy(fontSize = 10.sp), color = col, weight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(navBottom))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.stemroller.mobile.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.ui.nav.LocalEntry
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlin.math.ceil
|
||||
|
||||
/** Height of the tab bar plus mini player, so lists can scroll clear of them. */
|
||||
val LocalBottomInset = compositionLocalOf { 0.dp }
|
||||
|
||||
const val MARGIN = 20
|
||||
|
||||
/**
|
||||
* A navigation-controller page: large title that collapses into the bar as you scroll,
|
||||
* back button titled after the previous page, bar background only once content is under it.
|
||||
*/
|
||||
@Composable
|
||||
fun Page(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
grouped: Boolean = false,
|
||||
largeTitle: Boolean = true,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
leading: (@Composable RowScope.() -> Unit)? = null,
|
||||
overlay: @Composable BoxScope.() -> Unit = {},
|
||||
content: LazyListScope.() -> Unit,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val nav = LocalNav.current
|
||||
val entry = LocalEntry.current
|
||||
val idx = nav.entries.indexOf(entry)
|
||||
val backTitle = if (idx > 0) nav.entries[idx - 1].route.title else null
|
||||
val top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val collapsePx = with(LocalDensity.current) { 40.dp.toPx() }
|
||||
val collapsed by remember(state, largeTitle) {
|
||||
derivedStateOf { !largeTitle || state.firstVisibleItemIndex > 0 || state.firstVisibleItemScrollOffset > collapsePx }
|
||||
}
|
||||
val under by remember(state) { derivedStateOf { state.firstVisibleItemIndex > 0 || state.firstVisibleItemScrollOffset > 0 } }
|
||||
val pastHeader by remember(state) { derivedStateOf { state.firstVisibleItemIndex > 0 } }
|
||||
val bg = if (grouped) c.groupedBg else c.bg
|
||||
|
||||
Box(modifier.fillMaxSize().background(bg)) {
|
||||
// Settings-style (grouped) pages are laid out to fit the screen; they only scroll if
|
||||
// something (a big font size, say) makes them overflow.
|
||||
val overflows by remember(state) { derivedStateOf { state.canScrollForward || state.canScrollBackward } }
|
||||
LazyColumn(
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
userScrollEnabled = !grouped || overflows,
|
||||
contentPadding = PaddingValues(top = top + 44.dp, bottom = LocalBottomInset.current + 28.dp),
|
||||
) {
|
||||
if (largeTitle) {
|
||||
item(key = "__largeTitle") {
|
||||
Box(Modifier.fillMaxWidth().height(60.dp).padding(start = MARGIN.dp, end = MARGIN.dp, bottom = 8.dp), contentAlignment = Alignment.BottomStart) {
|
||||
Txt(title, Type.largeTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
NavBar(
|
||||
title = title,
|
||||
showTitle = if (largeTitle) collapsed else pastHeader,
|
||||
showBar = if (largeTitle) collapsed else under,
|
||||
backTitle = backTitle,
|
||||
onBack = { nav.pop() },
|
||||
leading = leading,
|
||||
trailing = trailing,
|
||||
bg = bg,
|
||||
top = top,
|
||||
)
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavBar(
|
||||
title: String,
|
||||
showTitle: Boolean,
|
||||
showBar: Boolean,
|
||||
backTitle: String?,
|
||||
onBack: () -> Unit,
|
||||
leading: (@Composable RowScope.() -> Unit)?,
|
||||
trailing: @Composable RowScope.() -> Unit,
|
||||
bg: Color,
|
||||
top: Dp,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val titleAlpha by animateFloatAsState(if (showTitle) 1f else 0f, tween(160), label = "title")
|
||||
val barAlpha by animateFloatAsState(if (showBar) 1f else 0f, tween(160), label = "bar")
|
||||
val bar = c.bar
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(top + 44.dp).drawBehind {
|
||||
drawRect(bg)
|
||||
drawRect(bar.copy(alpha = barAlpha))
|
||||
drawLine(c.barBorder.copy(alpha = barAlpha), Offset(0f, size.height - 0.5f), Offset(size.width, size.height - 0.5f), 1f)
|
||||
},
|
||||
) {
|
||||
Txt(
|
||||
title, Type.headline,
|
||||
Modifier.align(Alignment.BottomCenter).padding(bottom = 11.dp).widthIn(max = 220.dp).graphicsLayer { alpha = titleAlpha },
|
||||
align = TextAlign.Center,
|
||||
)
|
||||
Row(
|
||||
Modifier.align(Alignment.BottomStart).fillMaxWidth().height(44.dp).padding(horizontal = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (backTitle != null) BackButton(backTitle, onBack) else leading?.invoke(this)
|
||||
Spacer(Modifier.weight(1f))
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackButton(title: String, onBack: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Row(Modifier.height(44.dp).pressFade(onClick = onBack).padding(end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Sym.chevronLeft, c.accent, size = 24.dp)
|
||||
Txt(if (title.length > 14) "Back" else title, Type.body, color = c.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- inset grouped lists ----------------------------------------------------------------------
|
||||
|
||||
/** One UITableView inset-grouped section; [rows] are drawn as one rounded card. */
|
||||
fun LazyListScope.section(
|
||||
key: String,
|
||||
header: String? = null,
|
||||
footer: String? = null,
|
||||
separatorInset: Dp = 16.dp,
|
||||
rows: List<@Composable () -> Unit>,
|
||||
) {
|
||||
item(key = "$key.header") {
|
||||
if (header != null) {
|
||||
Txt(header.uppercase(), Type.footnote, Modifier.padding(start = 36.dp, end = 36.dp, top = 18.dp, bottom = 6.dp), color = Ios.colors.label2)
|
||||
} else {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
}
|
||||
}
|
||||
items(rows.size, key = { "$key.$it" }) { i ->
|
||||
val last = i == rows.lastIndex
|
||||
val shape: Shape = when {
|
||||
rows.size == 1 -> RoundedCornerShape(12.dp)
|
||||
i == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
last -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
Box(Modifier.padding(horizontal = 16.dp).clip(shape).background(Ios.colors.cell)) {
|
||||
rows[i]()
|
||||
if (!last) Hairline(Modifier.align(Alignment.BottomStart).padding(start = separatorInset))
|
||||
}
|
||||
}
|
||||
if (footer != null) {
|
||||
item(key = "$key.footer") {
|
||||
Txt(footer, Type.footnote, Modifier.padding(start = 36.dp, end = 36.dp, top = 6.dp), color = Ios.colors.label2, maxLines = 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A table cell: optional icon, title/subtitle, grey value, then chevron / check / custom accessory. */
|
||||
@Composable
|
||||
fun Cell(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
value: String? = null,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
titleColor: Color = Ios.colors.label,
|
||||
chevron: Boolean = false,
|
||||
checked: Boolean = false,
|
||||
centered: Boolean = false,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
Row(
|
||||
modifier.fillMaxWidth().heightIn(min = if (subtitle != null) 56.dp else 44.dp)
|
||||
.background(if (pressed && onClick != null) c.cellPressed else Color.Transparent)
|
||||
.then(if (onClick != null) Modifier.tappable(onLongClick = onLongClick, interaction = src, onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start,
|
||||
) {
|
||||
if (icon != null) {
|
||||
icon()
|
||||
Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
Column(if (centered) Modifier else Modifier.weight(1f)) {
|
||||
Txt(title, Type.body, color = titleColor)
|
||||
if (subtitle != null) Txt(subtitle, Type.footnote, color = c.label2, maxLines = 2)
|
||||
}
|
||||
if (value != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Txt(value, Type.body, color = c.label2, modifier = Modifier.widthIn(max = 200.dp))
|
||||
}
|
||||
if (trailing != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
trailing()
|
||||
}
|
||||
if (checked) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Sym.check, c.accent, size = 18.dp)
|
||||
}
|
||||
if (chevron) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Chevron()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings.app-style coloured rounded-square icon. */
|
||||
@Composable
|
||||
fun SettingsIcon(vector: ImageVector, bg: Color) {
|
||||
Box(Modifier.size(30.dp).clip(RoundedCornerShape(7.dp)).background(bg), contentAlignment = Alignment.Center) {
|
||||
Icon(vector, Color.White, size = 19.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Section title with an optional accent action, as in Apple Music ("Recently Added"). */
|
||||
@Composable
|
||||
fun SectionTitle(title: String, action: String? = null, onAction: () -> Unit = {}) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = MARGIN.dp, end = 12.dp, top = 22.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Txt(title, Type.title2, Modifier.weight(1f))
|
||||
if (action != null) TextButton(action, onAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchField(
|
||||
value: String,
|
||||
onChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = "Search",
|
||||
onSubmit: () -> Unit = {},
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val focus = LocalFocusManager.current
|
||||
Row(
|
||||
modifier.fillMaxWidth().height(38.dp).clip(RoundedCornerShape(11.dp)).background(c.fill).padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Sym.search, c.label2, size = 18.dp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isEmpty()) Txt(placeholder, Type.body, color = c.label2)
|
||||
BasicTextField(
|
||||
value = value, onValueChange = onChange, singleLine = true,
|
||||
textStyle = Type.body.copy(color = c.label), cursorBrush = SolidColor(c.accent),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { focus.clearFocus(); onSubmit() }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (value.isNotEmpty()) {
|
||||
Box(Modifier.size(18.dp).clip(CircleShape).background(c.label3).tappable { onChange("") }, contentAlignment = Alignment.Center) {
|
||||
Icon(Sym.xmark, c.fill, size = 12.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artwork, tiles, rows ------------------------------------------------------------------------
|
||||
|
||||
/** Plex artwork through the photo transcoder, sized to the device's pixels. */
|
||||
@Composable
|
||||
fun Artwork(
|
||||
thumb: String?,
|
||||
size: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
corner: Dp = 6.dp,
|
||||
circle: Boolean = false,
|
||||
placeholder: ImageVector = Sym.note,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val density = LocalDensity.current.density
|
||||
// Round the request up so nearby sizes share cache entries.
|
||||
val px = (ceil(size.value * density / 120f) * 120).toInt().coerceIn(120, 1440)
|
||||
val url = remember(thumb, px) { App.graph.plex.artUrl(thumb, px) }
|
||||
val shape = if (circle) CircleShape else RoundedCornerShape(corner)
|
||||
Box(
|
||||
modifier.size(size).clip(shape).background(c.placeholder).border(0.5.dp, c.separator.copy(alpha = 0.6f), shape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(placeholder, c.label3, size = size * 0.38f)
|
||||
if (url != null) AsyncImage(model = url, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(icon: ImageVector, title: String, message: String, action: String? = null, onAction: () -> Unit = {}) {
|
||||
val c = Ios.colors
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 40.dp, vertical = 70.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(icon, c.label3, size = 56.dp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Txt(title, Type.title3, align = TextAlign.Center, maxLines = 2)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Txt(message, Type.subhead, color = c.label2, align = TextAlign.Center, maxLines = 6)
|
||||
if (action != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Box(Modifier.clip(CircleShape).background(c.accent).pressFade(onClick = onAction).padding(horizontal = 20.dp, vertical = 10.dp)) {
|
||||
Txt(action, Type.headline, color = Color.White, weight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingRow() {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 40.dp), contentAlignment = Alignment.Center) { Spinner(size = 26.dp) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorRow(message: String, onRetry: () -> Unit) {
|
||||
if (!App.graph.plex.signedIn) {
|
||||
val nav = LocalNav.current
|
||||
EmptyState(Sym.server, "Not Connected", "Sign in to your Plex server to see your music here.", "Connect") {
|
||||
nav.push(com.stemroller.mobile.ui.nav.Route.PlexAccount)
|
||||
}
|
||||
} else {
|
||||
EmptyState(Sym.warning, "Couldn't Load", message, "Try Again", onRetry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package com.stemroller.mobile.ui.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Tap target without a ripple. iOS dims or highlights instead; [pressed] reports it. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun Modifier.tappable(
|
||||
enabled: Boolean = true,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
interaction: MutableInteractionSource? = null,
|
||||
onClick: () -> Unit,
|
||||
): Modifier = composed {
|
||||
val src = interaction ?: remember { MutableInteractionSource() }
|
||||
combinedClickable(src, indication = null, enabled = enabled, onLongClick = onLongClick, onClick = onClick)
|
||||
}
|
||||
|
||||
/** Like [tappable] but fades to 35% while pressed, as iOS buttons do. */
|
||||
fun Modifier.pressFade(enabled: Boolean = true, onLongClick: (() -> Unit)? = null, onClick: () -> Unit): Modifier = composed {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val a by animateFloatAsState(if (pressed) 0.35f else 1f, tween(if (pressed) 0 else 220), label = "press")
|
||||
this.alpha(if (enabled) a else 0.35f).tappable(enabled, onLongClick, src, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconButton(
|
||||
vector: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Ios.colors.accent,
|
||||
size: Dp = 24.dp,
|
||||
box: Dp = 44.dp,
|
||||
enabled: Boolean = true,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(modifier.size(box).pressFade(enabled, onLongClick, onClick), contentAlignment = Alignment.Center) {
|
||||
Icon(vector, tint, size = size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, bold: Boolean = false, color: Color = Ios.colors.accent, enabled: Boolean = true) {
|
||||
Box(modifier.height(44.dp).pressFade(enabled, onClick = onClick).padding(horizontal = 4.dp), contentAlignment = Alignment.Center) {
|
||||
Txt(text, Type.body, color = color, weight = if (bold) FontWeight.SemiBold else null)
|
||||
}
|
||||
}
|
||||
|
||||
/** UISwitch: 51x31, green when on. */
|
||||
@Composable
|
||||
fun IosSwitch(checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
|
||||
val c = Ios.colors
|
||||
val track by animateColorAsState(if (checked) c.green else c.switchOff, tween(200), label = "track")
|
||||
val x by animateFloatAsState(if (checked) 1f else 0f, spring(dampingRatio = 0.75f, stiffness = 600f), label = "thumb")
|
||||
val haptic = LocalHapticFeedback.current
|
||||
Box(
|
||||
modifier.size(51.dp, 31.dp).clip(CircleShape).background(track)
|
||||
.tappable { haptic.performHapticFeedback(HapticFeedbackType.ToggleOn); onChange(!checked) },
|
||||
) {
|
||||
Box(
|
||||
Modifier.padding(2.dp).offset { IntOffset((x * 20.dp.toPx()).roundToInt(), 0) }
|
||||
.size(27.dp).clip(CircleShape).background(Color.White)
|
||||
.border(0.5.dp, c.thumbBorder, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** UISegmentedControl with a sliding selection. */
|
||||
@Composable
|
||||
fun SegmentedControl(options: List<String>, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
val c = Ios.colors
|
||||
val pos by animateFloatAsState(selected.toFloat(), spring(dampingRatio = 0.85f, stiffness = 500f), label = "seg")
|
||||
BoxWithConstraints(modifier.fillMaxWidth().height(32.dp).clip(RoundedCornerShape(9.dp)).background(c.fill).padding(2.dp)) {
|
||||
val segW = maxWidth / options.size
|
||||
Box(
|
||||
Modifier.offset { IntOffset((segW.toPx() * pos).roundToInt(), 0) }.width(segW).fillMaxHeight()
|
||||
.clip(RoundedCornerShape(7.dp)).background(if (c.dark) c.fillStrong else Color.White)
|
||||
.border(0.5.dp, if (c.dark) Color.Transparent else c.separator, RoundedCornerShape(7.dp)),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
options.forEachIndexed { i, label ->
|
||||
Box(Modifier.weight(1f).fillMaxHeight().tappable { onSelect(i) }, contentAlignment = Alignment.Center) {
|
||||
Txt(label, Type.footnote, weight = if (i == selected) FontWeight.SemiBold else FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UISlider: 4 dp track, white 28 dp thumb. [detent] snaps near a value (with a haptic tick),
|
||||
* e.g. unity gain on stem faders.
|
||||
*/
|
||||
@Composable
|
||||
fun IosSlider(
|
||||
value: Float,
|
||||
onChange: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fill: Color = Ios.colors.accent,
|
||||
track: Color = Ios.colors.fillStrong,
|
||||
detent: Float? = null,
|
||||
enabled: Boolean = true,
|
||||
thumb: Dp = 28.dp,
|
||||
onRelease: (() -> Unit)? = null,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val current by rememberUpdatedState(value)
|
||||
val change by rememberUpdatedState(onChange)
|
||||
Box(
|
||||
modifier.height(maxOf(thumb, 28.dp) + 8.dp).fillMaxWidth().alpha(if (enabled) 1f else 0.4f)
|
||||
.pointerInput(enabled, detent) {
|
||||
if (!enabled) return@pointerInput
|
||||
val t = thumb.toPx()
|
||||
fun valueAt(x: Float) = ((x - t / 2) / (size.width - t)).coerceIn(0f, 1f)
|
||||
fun snap(v: Float): Float {
|
||||
if (detent == null) return v
|
||||
return if (abs(v - detent) < 0.025f) detent else v
|
||||
}
|
||||
var start = 0f
|
||||
var acc = 0f
|
||||
detectDragGestures(
|
||||
onDragStart = { start = current; acc = 0f },
|
||||
onDragEnd = { onRelease?.invoke() },
|
||||
onDragCancel = { onRelease?.invoke() },
|
||||
) { change, drag ->
|
||||
change.consume()
|
||||
acc += drag.x
|
||||
val before = current
|
||||
val v = snap((start + acc / (size.width - t)).coerceIn(0f, 1f))
|
||||
if (detent != null && v == detent && before != detent) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
change(v)
|
||||
}
|
||||
}
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectTapGestures { o ->
|
||||
val t = thumb.toPx()
|
||||
change(((o.x - t / 2) / (size.width - t)).coerceIn(0f, 1f))
|
||||
onRelease?.invoke()
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Canvas(Modifier.fillMaxWidth().height(thumb)) {
|
||||
val t = thumb.toPx()
|
||||
val h = 4.dp.toPx()
|
||||
val y = size.height / 2
|
||||
val x = t / 2 + (size.width - t) * value.coerceIn(0f, 1f)
|
||||
drawRoundRect(track, Offset(t / 2, y - h / 2), Size(size.width - t, h), CornerRadius(h / 2))
|
||||
drawRoundRect(fill, Offset(t / 2, y - h / 2), Size(x - t / 2, h), CornerRadius(h / 2))
|
||||
if (detent != null) {
|
||||
val dx = t / 2 + (size.width - t) * detent
|
||||
drawLine(c.label3, Offset(dx, y - 7.dp.toPx()), Offset(dx, y + 7.dp.toPx()), 1.5.dp.toPx(), StrokeCap.Round)
|
||||
}
|
||||
if (c.thumbBorder.alpha > 0f) drawCircle(c.thumbBorder, t / 2 + with(density) { 0.5.dp.toPx() }, Offset(x, y))
|
||||
drawCircle(Color.White, t / 2, Offset(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A vertical UISlider for the EQ: centre-detented, value in -1..1. */
|
||||
@Composable
|
||||
fun VerticalBandSlider(value: Float, onChange: (Float) -> Unit, modifier: Modifier = Modifier, fill: Color = Ios.colors.accent) {
|
||||
val c = Ios.colors
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val current by rememberUpdatedState(value)
|
||||
val change by rememberUpdatedState(onChange)
|
||||
Canvas(
|
||||
modifier.fillMaxHeight().width(32.dp)
|
||||
.pointerInput(Unit) {
|
||||
val t = 24.dp.toPx()
|
||||
var start = 0f
|
||||
var acc = 0f
|
||||
detectDragGestures(onDragStart = { start = current; acc = 0f }) { ch, drag ->
|
||||
ch.consume()
|
||||
acc += drag.y
|
||||
val before = current
|
||||
var v = (start - 2f * acc / (size.height - t)).coerceIn(-1f, 1f)
|
||||
if (abs(v) < 0.04f) v = 0f
|
||||
if (v == 0f && before != 0f) haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
change(v)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { change(0f) }) },
|
||||
) {
|
||||
val t = 24.dp.toPx()
|
||||
val w = 4.dp.toPx()
|
||||
val x = size.width / 2
|
||||
val top = t / 2
|
||||
val bottom = size.height - t / 2
|
||||
val mid = (top + bottom) / 2
|
||||
val y = mid - (bottom - top) / 2 * value
|
||||
drawRoundRect(c.fillStrong, Offset(x - w / 2, top), Size(w, bottom - top), CornerRadius(w / 2))
|
||||
drawRoundRect(fill, Offset(x - w / 2, minOf(y, mid)), Size(w, abs(y - mid)), CornerRadius(w / 2))
|
||||
drawLine(c.label3, Offset(x - 8.dp.toPx(), mid), Offset(x + 8.dp.toPx(), mid), 1.dp.toPx())
|
||||
if (c.thumbBorder.alpha > 0f) drawCircle(c.thumbBorder, t / 2 + 0.5.dp.toPx(), Offset(x, y))
|
||||
drawCircle(Color.White, t / 2, Offset(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/** Apple Music's Play / Shuffle buttons: filled rounded rect, accent glyph and label. */
|
||||
@Composable
|
||||
fun PillButton(text: String, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val c = Ios.colors
|
||||
Row(
|
||||
modifier.height(50.dp).clip(RoundedCornerShape(12.dp)).background(c.fill).pressFade(onClick = onClick),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, c.accent, size = 20.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Txt(text, Type.headline, color = c.accent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Capsule chip, accent-filled when selected. */
|
||||
@Composable
|
||||
fun Chip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val c = Ios.colors
|
||||
Box(
|
||||
modifier.height(32.dp).clip(CircleShape).background(if (selected) c.accent else c.fill)
|
||||
.pressFade(onClick = onClick).padding(horizontal = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Txt(text, Type.footnote, color = if (selected) Color.White else c.label, weight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small capsule tag, e.g. "STEMS" next to a track. */
|
||||
@Composable
|
||||
fun Tag(text: String, color: Color = Ios.colors.label2, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.clip(RoundedCornerShape(4.dp)).border(1.dp, color, RoundedCornerShape(4.dp)).padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Txt(text, Type.caption2.copy(letterSpacing = Type.caption2.letterSpacing), color = color, weight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
/** UIActivityIndicatorView: eight spokes stepping round. */
|
||||
@Composable
|
||||
fun Spinner(modifier: Modifier = Modifier, size: Dp = 20.dp, color: Color = Ios.colors.label2) {
|
||||
val t = rememberInfiniteTransition(label = "spin")
|
||||
val step by t.animateFloat(0f, 8f, infiniteRepeatable(tween(800, easing = LinearEasing), RepeatMode.Restart), label = "step")
|
||||
Canvas(modifier.size(size)) {
|
||||
val s = step.toInt()
|
||||
val r0 = this.size.minDimension * 0.22f
|
||||
val r1 = this.size.minDimension * 0.46f
|
||||
val w = this.size.minDimension * 0.09f
|
||||
for (i in 0 until 8) {
|
||||
val a = 1f - ((s - i + 8) % 8) / 8f
|
||||
rotate(i * 45f) {
|
||||
drawLine(color.copy(alpha = color.alpha * (0.25f + 0.75f * a)), Offset(center.x, center.y - r0), Offset(center.x, center.y - r1), w, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloaded marker: filled circle with a down arrow, as in Apple Music's lists. */
|
||||
@Composable
|
||||
fun DownloadedMark(modifier: Modifier = Modifier, color: Color = Ios.colors.label2, size: Dp = 14.dp) {
|
||||
val bg = Ios.colors.bg
|
||||
Canvas(modifier.size(size)) {
|
||||
drawCircle(color)
|
||||
val w = this.size.width
|
||||
val sw = w * 0.12f
|
||||
drawLine(bg, Offset(w / 2, w * 0.26f), Offset(w / 2, w * 0.7f), sw, StrokeCap.Round)
|
||||
drawLine(bg, Offset(w * 0.32f, w * 0.52f), Offset(w / 2, w * 0.7f), sw, StrokeCap.Round)
|
||||
drawLine(bg, Offset(w * 0.68f, w * 0.52f), Offset(w / 2, w * 0.7f), sw, StrokeCap.Round)
|
||||
}
|
||||
}
|
||||
|
||||
/** Download progress ring. */
|
||||
@Composable
|
||||
fun ProgressRing(progress: Float, modifier: Modifier = Modifier, size: Dp = 18.dp, color: Color = Ios.colors.accent) {
|
||||
val track = Ios.colors.fillStrong
|
||||
Canvas(modifier.size(size)) {
|
||||
val sw = 2.dp.toPx()
|
||||
drawCircle(track, this.size.minDimension / 2 - sw / 2, style = androidx.compose.ui.graphics.drawscope.Stroke(sw))
|
||||
drawArc(color, -90f, 360f * progress.coerceIn(0f, 1f), false, Offset(sw / 2, sw / 2),
|
||||
Size(this.size.width - sw, this.size.height - sw), style = androidx.compose.ui.graphics.drawscope.Stroke(sw, cap = StrokeCap.Round))
|
||||
}
|
||||
}
|
||||
|
||||
/** Animated "now playing" bars used in place of a track number. */
|
||||
@Composable
|
||||
fun PlayingBars(playing: Boolean, modifier: Modifier = Modifier, color: Color = Ios.colors.accent) {
|
||||
val t = rememberInfiniteTransition(label = "bars")
|
||||
val phase by t.animateFloat(0f, 6.2832f, infiniteRepeatable(tween(1100, easing = LinearEasing)), label = "phase")
|
||||
Canvas(modifier.size(16.dp)) {
|
||||
val w = size.width / 4.5f
|
||||
for (i in 0 until 3) {
|
||||
val h = if (playing) (0.35f + 0.65f * abs(kotlin.math.sin(phase + i * 1.9f))) else 0.3f + i * 0.15f
|
||||
val bh = size.height * h
|
||||
drawRoundRect(color, Offset(i * w * 1.6f + w * 0.3f, size.height - bh), Size(w, bh), CornerRadius(w / 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Hairline(modifier: Modifier = Modifier, color: Color = Ios.colors.separator) {
|
||||
val px = with(LocalDensity.current) { 1f / density }
|
||||
Box(modifier.fillMaxWidth().height(px.dp).background(color))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Chevron(modifier: Modifier = Modifier) = Icon(Sym.chevronRight, Ios.colors.label3, modifier, size = 14.dp)
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.stemroller.mobile.ui.components
|
||||
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SheetAction(val title: String, val destructive: Boolean = false, val onClick: () -> Unit)
|
||||
|
||||
class AlertSpec(
|
||||
val title: String,
|
||||
val message: String? = null,
|
||||
val confirm: String = "OK",
|
||||
val cancel: String? = "Cancel",
|
||||
val destructive: Boolean = false,
|
||||
val field: String? = null,
|
||||
val placeholder: String = "",
|
||||
val onConfirm: (String) -> Unit,
|
||||
)
|
||||
|
||||
class HudSpec(val text: String, val icon: ImageVector?, val id: Long)
|
||||
|
||||
/** App-wide modal UI: action sheets, alerts and the HUD. */
|
||||
@Stable
|
||||
class Overlays {
|
||||
var sheet by mutableStateOf<Pair<String?, List<SheetAction>>?>(null)
|
||||
var alert by mutableStateOf<AlertSpec?>(null)
|
||||
var hud by mutableStateOf<HudSpec?>(null)
|
||||
private var hudId = 0L
|
||||
|
||||
fun actions(title: String?, actions: List<SheetAction>) {
|
||||
sheet = title to actions
|
||||
}
|
||||
|
||||
fun prompt(title: String, message: String?, initial: String, confirm: String, placeholder: String = "", onConfirm: (String) -> Unit) {
|
||||
alert = AlertSpec(title, message, confirm, field = initial, placeholder = placeholder, onConfirm = onConfirm)
|
||||
}
|
||||
|
||||
fun confirm(title: String, message: String?, confirm: String, destructive: Boolean = true, onConfirm: () -> Unit) {
|
||||
alert = AlertSpec(title, message, confirm, destructive = destructive, onConfirm = { onConfirm() })
|
||||
}
|
||||
|
||||
fun hud(text: String, icon: ImageVector? = Sym.check) {
|
||||
hud = HudSpec(text, icon, ++hudId)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalOverlays = staticCompositionLocalOf { Overlays() }
|
||||
|
||||
@Composable
|
||||
fun OverlayHost(o: Overlays) {
|
||||
o.sheet?.let { (title, actions) -> ActionSheet(title, actions) { o.sheet = null } }
|
||||
o.alert?.let { spec -> Alert(spec) { o.alert = null } }
|
||||
o.hud?.let { h -> Hud(h) { if (o.hud?.id == h.id) o.hud = null } }
|
||||
}
|
||||
|
||||
/** UIAlertController(.actionSheet): grouped buttons over a scrim, Cancel apart. */
|
||||
@Composable
|
||||
private fun ActionSheet(title: String?, actions: List<SheetAction>, dismiss: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val shown = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) { shown.animateTo(1f, spring(dampingRatio = 0.9f, stiffness = 500f)) }
|
||||
fun close(then: (() -> Unit)? = null) = scope.launch {
|
||||
shown.animateTo(0f, tween(180))
|
||||
dismiss()
|
||||
then?.invoke()
|
||||
}
|
||||
PredictiveBackHandler { events ->
|
||||
try {
|
||||
events.collect { shown.snapTo(1f - it.progress * 0.5f) }
|
||||
close()
|
||||
} catch (e: CancellationException) {
|
||||
scope.launch { shown.animateTo(1f) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value }.background(c.scrim).tappable { close() })
|
||||
Column(
|
||||
Modifier.align(Alignment.BottomCenter).fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.graphicsLayer { translationY = (1f - shown.value) * size.height * 1.1f },
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.elevated).heightIn(max = 560.dp).verticalScroll(rememberScrollState())) {
|
||||
if (title != null) {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), contentAlignment = Alignment.Center) {
|
||||
Txt(title, Type.footnote, color = c.label2, maxLines = 2, align = TextAlign.Center, weight = FontWeight.SemiBold)
|
||||
}
|
||||
Hairline()
|
||||
}
|
||||
actions.forEachIndexed { i, a ->
|
||||
if (i > 0) Hairline()
|
||||
SheetButton(a.title, if (a.destructive) c.red else c.accent, bold = false) { close(a.onClick) }
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.elevated)) {
|
||||
SheetButton("Cancel", c.accent, bold = true) { close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(text: String, color: Color, bold: Boolean, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(57.dp).background(if (pressed) c.cellPressed else Color.Transparent)
|
||||
.tappable(interaction = src, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Txt(text, Type.body.copy(fontSize = Type.title3.fontSize), color = color, weight = if (bold) FontWeight.SemiBold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
|
||||
/** UIAlertController(.alert), optionally with one text field. */
|
||||
@Composable
|
||||
private fun Alert(spec: AlertSpec, dismiss: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val shown = remember { Animatable(0f) }
|
||||
var text by remember(spec) { mutableStateOf(spec.field ?: "") }
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(spec) {
|
||||
shown.animateTo(1f, spring(dampingRatio = 0.7f, stiffness = 700f))
|
||||
if (spec.field != null) runCatching { focus.requestFocus() }
|
||||
}
|
||||
PredictiveBackHandler { events ->
|
||||
events.collect { }
|
||||
dismiss()
|
||||
}
|
||||
fun confirm() {
|
||||
dismiss()
|
||||
spec.onConfirm(text.trim())
|
||||
}
|
||||
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = shown.value }.background(c.scrim).tappable { })
|
||||
Column(
|
||||
Modifier.width(280.dp).graphicsLayer {
|
||||
alpha = shown.value
|
||||
val sc = 1.15f - 0.15f * shown.value
|
||||
scaleX = sc; scaleY = sc
|
||||
}.clip(RoundedCornerShape(14.dp)).background(c.elevated),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Txt(spec.title, Type.headline, maxLines = 3, align = TextAlign.Center)
|
||||
spec.message?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Txt(it, Type.footnote, maxLines = 8, align = TextAlign.Center)
|
||||
}
|
||||
if (spec.field != null) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(7.dp)).background(if (c.dark) c.fill else Color.White)
|
||||
.border(0.5.dp, c.separator, RoundedCornerShape(7.dp)).padding(horizontal = 8.dp, vertical = 7.dp),
|
||||
) {
|
||||
if (text.isEmpty()) Txt(spec.placeholder, Type.subhead, color = c.label3)
|
||||
BasicTextField(
|
||||
value = text, onValueChange = { text = it }, singleLine = true,
|
||||
textStyle = Type.subhead.copy(color = c.label),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (text.isNotBlank()) confirm() }),
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focus),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Hairline()
|
||||
Row(Modifier.fillMaxWidth().height(44.dp), horizontalArrangement = Arrangement.Center) {
|
||||
spec.cancel?.let { label ->
|
||||
AlertButton(label, c.accent, false, Modifier.weight(1f)) { dismiss() }
|
||||
Box(Modifier.width(0.5.dp).height(44.dp).background(c.separator))
|
||||
}
|
||||
AlertButton(spec.confirm, if (spec.destructive) c.red else c.accent, true, Modifier.weight(1f),
|
||||
enabled = spec.field == null || text.isNotBlank()) { confirm() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertButton(text: String, color: Color, bold: Boolean, modifier: Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
Box(
|
||||
modifier.height(44.dp).background(if (pressed) c.cellPressed else Color.Transparent).tappable(enabled, interaction = src, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Txt(text, Type.body, color = if (enabled) color else c.label3, weight = if (bold) FontWeight.SemiBold else null)
|
||||
}
|
||||
}
|
||||
|
||||
/** The square "Added to Library" style HUD. */
|
||||
@Composable
|
||||
private fun Hud(h: HudSpec, done: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val a = remember(h.id) { Animatable(0f) }
|
||||
LaunchedEffect(h.id) {
|
||||
a.animateTo(1f, tween(150))
|
||||
delay(if (h.text.length > 40) 2800 else 1400)
|
||||
a.animateTo(0f, tween(250))
|
||||
done()
|
||||
}
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.widthIn(min = 150.dp, max = 270.dp).graphicsLayer { alpha = a.value; val s = 0.9f + 0.1f * a.value; scaleX = s; scaleY = s }
|
||||
.clip(RoundedCornerShape(16.dp)).background(if (c.dark) Color(0xFF2C2C2E) else Color(0xFFE5E5EA))
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
h.icon?.let {
|
||||
Icon(it, c.label2, size = 44.dp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
Txt(h.text, Type.headline, color = c.label2, maxLines = 4, align = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.stemroller.mobile.ui.nav
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.SaveableStateHolder
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import com.stemroller.mobile.plex.Album
|
||||
import com.stemroller.mobile.plex.Artist
|
||||
import com.stemroller.mobile.plex.Playlist
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
sealed interface Route {
|
||||
val title: String
|
||||
|
||||
data object Library : Route { override val title = "Library" }
|
||||
data object Artists : Route { override val title = "Artists" }
|
||||
data object Albums : Route { override val title = "Albums" }
|
||||
data object Songs : Route { override val title = "Songs" }
|
||||
data object Downloads : Route { override val title = "Downloads" }
|
||||
data object Playlists : Route { override val title = "Playlists" }
|
||||
data object Search : Route { override val title = "Search" }
|
||||
data object Eq : Route { override val title = "Equalizer" }
|
||||
data class EqFilters(val profileId: String) : Route { override val title = "Filters" }
|
||||
data class EqFilterPage(val profileId: String, val filterId: String) : Route { override val title = "Filter" }
|
||||
data object Settings : Route { override val title = "Settings" }
|
||||
data object PlexAccount : Route { override val title = "Plex" }
|
||||
data object StemServer : Route { override val title = "Stem Server" }
|
||||
data object Quality : Route { override val title = "Stem Quality" }
|
||||
data object Storage : Route { override val title = "Storage" }
|
||||
data object Appearance : Route { override val title = "Appearance" }
|
||||
data class ArtistPage(val artist: Artist) : Route { override val title get() = artist.title }
|
||||
data class AlbumPage(val album: Album) : Route { override val title get() = album.title }
|
||||
data class PlaylistPage(val playlist: Playlist) : Route { override val title get() = playlist.title }
|
||||
}
|
||||
|
||||
/** A screen instance on a stack. [store] keeps its loaded data while it stays on the stack. */
|
||||
class Entry(val route: Route) {
|
||||
val id: Long = nextId++
|
||||
val store = HashMap<String, Any?>()
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
companion object {
|
||||
private var nextId = 1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tab's navigation stack. [progress] is how far the top entry has come in: 1 at rest,
|
||||
* 0 fully off to the right. Pushes animate it 0→1, pops 1→0, and the predictive back
|
||||
* gesture drives it by hand.
|
||||
*/
|
||||
class NavStack(root: Route) {
|
||||
val entries = mutableStateListOf(Entry(root))
|
||||
var progress by mutableFloatStateOf(1f)
|
||||
private set
|
||||
internal var scope: CoroutineScope? = null
|
||||
internal var holder: SaveableStateHolder? = null
|
||||
private val busy = Mutex()
|
||||
|
||||
private suspend fun animateTo(target: Float, spec: AnimationSpec<Float> = PUSH) {
|
||||
animate(progress, target, animationSpec = spec) { v, _ -> progress = v }
|
||||
}
|
||||
|
||||
fun push(route: Route) {
|
||||
val s = scope
|
||||
if (s == null) {
|
||||
entries.add(Entry(route))
|
||||
return
|
||||
}
|
||||
s.launch {
|
||||
busy.withLock {
|
||||
Snapshot.withMutableSnapshot {
|
||||
entries.add(Entry(route))
|
||||
progress = 0f
|
||||
}
|
||||
animateTo(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pop() {
|
||||
if (entries.size <= 1) return
|
||||
val s = scope ?: return removeTop()
|
||||
s.launch {
|
||||
busy.withLock {
|
||||
// A tap queued behind another pop may find nothing left to pop.
|
||||
if (entries.size <= 1) return@withLock
|
||||
animateTo(0f)
|
||||
finishPop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun popToRoot() {
|
||||
if (entries.size <= 1) return
|
||||
val s = scope
|
||||
if (s == null) {
|
||||
while (entries.size > 1) removeTop()
|
||||
return
|
||||
}
|
||||
s.launch {
|
||||
busy.withLock {
|
||||
// A second tap on the tab while the first is still animating lands here after it.
|
||||
if (entries.size <= 1) return@withLock
|
||||
// Drop the middle silently, then slide the top away.
|
||||
while (entries.size > 2) dispose(entries.removeAt(entries.size - 2))
|
||||
animateTo(0f)
|
||||
finishPop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the top and resets progress in one snapshot, so no frame shows a half state. */
|
||||
private fun finishPop() = Snapshot.withMutableSnapshot {
|
||||
removeTop()
|
||||
progress = 1f
|
||||
}
|
||||
|
||||
private fun removeTop() {
|
||||
if (entries.size <= 1) return // the root never leaves
|
||||
dispose(entries.removeAt(entries.lastIndex))
|
||||
}
|
||||
|
||||
private fun dispose(e: Entry) {
|
||||
e.scope.cancel()
|
||||
holder?.removeState(e.id)
|
||||
}
|
||||
|
||||
internal suspend fun backProgress(p: Float) = busy.withLock { if (entries.size > 1) progress = 1f - p }
|
||||
|
||||
internal suspend fun backCommit() = busy.withLock {
|
||||
if (entries.size <= 1) {
|
||||
progress = 1f
|
||||
return@withLock
|
||||
}
|
||||
animateTo(0f, tween(180))
|
||||
finishPop()
|
||||
}
|
||||
|
||||
internal suspend fun backCancel() = busy.withLock { animateTo(1f, spring(stiffness = 600f)) }
|
||||
|
||||
companion object {
|
||||
/** Critically damped, like UINavigationController's push. */
|
||||
val PUSH = spring<Float>(dampingRatio = 1f, stiffness = 420f)
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNav = staticCompositionLocalOf<NavStack> { error("no NavStack") }
|
||||
val LocalEntry = staticCompositionLocalOf<Entry> { error("no Entry") }
|
||||
|
||||
/**
|
||||
* Renders a [NavStack] with iOS's push/pop: the new page slides in from the right while
|
||||
* the old one parallaxes left by 30% and dims. During a predictive back swipe the same
|
||||
* animation follows the finger, and releasing commits or springs back.
|
||||
*/
|
||||
@Composable
|
||||
fun StackHost(stack: NavStack, holder: SaveableStateHolder, active: Boolean, content: @Composable (Route) -> Unit) {
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
SideEffect {
|
||||
stack.scope = scope
|
||||
stack.holder = holder
|
||||
}
|
||||
val moving by remember(stack) { derivedStateOf { stack.progress < 1f } }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize().clipToBounds()) {
|
||||
val w = constraints.maxWidth.toFloat()
|
||||
val n = stack.entries.size
|
||||
val visible = if (moving && n >= 2) listOf(stack.entries[n - 2], stack.entries[n - 1]) else listOf(stack.entries[n - 1])
|
||||
for (e in visible) {
|
||||
key(e.id) {
|
||||
val isTop = e === stack.entries.last()
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val p = stack.progress
|
||||
translationX = if (isTop) w * (1f - p) else -0.3f * w * p
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (!isTop) drawRect(Color.Black.copy(alpha = 0.18f * stack.progress))
|
||||
},
|
||||
) {
|
||||
holder.SaveableStateProvider(e.id) {
|
||||
CompositionLocalProvider(LocalNav provides stack, LocalEntry provides e) {
|
||||
content(e.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(enabled = active && stack.entries.size > 1) { events ->
|
||||
try {
|
||||
events.collect { e: BackEventCompat -> stack.backProgress(e.progress) }
|
||||
scope.launch { stack.backCommit() }
|
||||
} catch (e: CancellationException) {
|
||||
scope.launch { stack.backCancel() }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-entry loading ----------------------------------------------------------------------
|
||||
|
||||
sealed interface Load<out T> {
|
||||
data object Loading : Load<Nothing>
|
||||
data class Ok<T>(val value: T) : Load<T>
|
||||
data class Err(val message: String) : Load<Nothing>
|
||||
}
|
||||
|
||||
class Loader<T>(private val scope: CoroutineScope, private val fetch: suspend () -> T) {
|
||||
private val _state = mutableStateOf<Load<T>>(Load.Loading)
|
||||
val state: State<Load<T>> = _state
|
||||
val value: T? get() = (_state.value as? Load.Ok<T>)?.value
|
||||
|
||||
init {
|
||||
refresh(showLoading = true)
|
||||
}
|
||||
|
||||
fun refresh(showLoading: Boolean = false) {
|
||||
if (showLoading) _state.value = Load.Loading
|
||||
scope.launch {
|
||||
_state.value = try {
|
||||
Load.Ok(fetch())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (_state.value is Load.Ok && !showLoading) return@launch
|
||||
Load.Err(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Data for this screen, fetched once per stack entry and kept while it's on the stack. */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Composable
|
||||
fun <T> rememberLoader(key: String, fetch: suspend () -> T): Loader<T> {
|
||||
val entry = LocalEntry.current
|
||||
return remember(entry, key) { entry.store.getOrPut(key) { Loader(entry.scope, fetch) } as Loader<T> }
|
||||
}
|
||||
|
||||
/** Per-entry state that survives the page leaving composition during transitions. */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Composable
|
||||
fun <T> rememberInEntry(key: String, init: () -> T): T {
|
||||
val entry = LocalEntry.current
|
||||
return remember(entry, key) { entry.store.getOrPut(key) { init() } as T }
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.stems.Coverage
|
||||
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 com.stemroller.mobile.ui.components.Artwork
|
||||
import com.stemroller.mobile.ui.components.Chevron
|
||||
import com.stemroller.mobile.ui.components.DownloadedMark
|
||||
import com.stemroller.mobile.ui.components.Hairline
|
||||
import com.stemroller.mobile.ui.components.IconButton
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.MARGIN
|
||||
import com.stemroller.mobile.ui.components.Overlays
|
||||
import com.stemroller.mobile.ui.components.PillButton
|
||||
import com.stemroller.mobile.ui.components.PlayingBars
|
||||
import com.stemroller.mobile.ui.components.ProgressRing
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.Tag
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class Tab(val label: String) { Library("Library"), Playlists("Playlists"), Search("Search"), Downloads("Downloads"), Settings("Settings") }
|
||||
|
||||
/** Things any screen can ask of the shell. */
|
||||
class AppActions(
|
||||
val openNowPlaying: () -> Unit,
|
||||
val closeNowPlaying: () -> Unit,
|
||||
val selectTab: (Tab) -> Unit,
|
||||
/** Switches to the Library tab and pushes [route] there. */
|
||||
val showInLibrary: (Route) -> Unit,
|
||||
/** Switches to Settings and opens [route] (the Equalizer) there. */
|
||||
val showInSettings: (Route) -> Unit,
|
||||
)
|
||||
|
||||
val LocalApp = staticCompositionLocalOf<AppActions> { error("no AppActions") }
|
||||
|
||||
fun formatDuration(ms: Long): String {
|
||||
val s = (ms / 1000).coerceAtLeast(0)
|
||||
return if (s >= 3600) "%d:%02d:%02d".format(s / 3600, s / 60 % 60, s % 60) else "%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
|
||||
fun formatMinutes(ms: Long): String {
|
||||
val m = (ms / 60000).toInt()
|
||||
return if (m >= 60) "${m / 60} hr ${m % 60} min" else "$m minute${if (m == 1) "" else "s"}"
|
||||
}
|
||||
|
||||
fun songs(n: Int) = "$n song${if (n == 1) "" else "s"}"
|
||||
|
||||
/**
|
||||
* Stem / download status for a track, read reactively. Stems are the norm once the server has
|
||||
* worked through the library, so the badge marks the songs *without* them (and only once the
|
||||
* index has loaded, so nothing is flagged before we know).
|
||||
*/
|
||||
@Composable
|
||||
fun trackBadges(track: Track): Triple<Boolean, Boolean, Float?> {
|
||||
val g = App.graph
|
||||
val index by g.stems.index.collectAsState()
|
||||
val records by g.downloads.records.collectAsState()
|
||||
val progress by g.downloads.progress.collectAsState()
|
||||
val noStems = remember(track, index) { index.size > 0 && g.stems.find(track) == null }
|
||||
return Triple(noStems, records[track.ratingKey]?.done == true, progress[track.ratingKey])
|
||||
}
|
||||
|
||||
/** "NO STEMS" / "SOME STEMS" for an album or artist, once the whole library has been matched. */
|
||||
@Composable
|
||||
fun StemFlag(cov: Coverage?, modifier: Modifier = Modifier) {
|
||||
val ready by App.graph.coverage.ready.collectAsState()
|
||||
if (!ready || cov == null) return
|
||||
when {
|
||||
cov.none -> Tag("NO STEMS", modifier = modifier)
|
||||
cov.partial -> Tag("${cov.withStems}/${cov.total} STEMS", modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apple Music list row: art (or track number), title, artist, badges, and a ••• menu. */
|
||||
@Composable
|
||||
fun TrackRow(
|
||||
track: Track,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
number: Int? = null,
|
||||
showArt: Boolean = true,
|
||||
subtitle: String? = track.artist,
|
||||
onMore: (() -> Unit)? = null,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val pm = App.graph.playback
|
||||
val current by pm.current.collectAsState()
|
||||
val playing by pm.playWhenReady.collectAsState()
|
||||
val isCurrent = current?.track?.ratingKey == track.ratingKey
|
||||
val (noStems, downloaded, progress) = trackBadges(track)
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val textStart: Dp = (if (leading != null) 44 else 0).dp + if (showArt) 68.dp else if (number != null) 52.dp else MARGIN.dp
|
||||
Box(modifier.fillMaxWidth().background(if (pressed) c.cellPressed else Color.Transparent)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().heightIn(min = if (showArt) 64.dp else 48.dp)
|
||||
.tappable(onLongClick = onMore, interaction = src, onClick = onClick)
|
||||
.padding(start = if (leading != null) 8.dp else MARGIN.dp, end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leading != null) {
|
||||
Box(Modifier.size(36.dp), contentAlignment = Alignment.Center) { leading() }
|
||||
Spacer(Modifier.width(if (showArt) 8.dp else 0.dp))
|
||||
}
|
||||
if (showArt) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Artwork(track.thumb, 48.dp, corner = 5.dp)
|
||||
if (isCurrent) Box(Modifier.size(48.dp).background(Color.Black.copy(alpha = 0.35f)), contentAlignment = Alignment.Center) {
|
||||
PlayingBars(playing, color = Color.White)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
} else if (number != null) {
|
||||
Box(Modifier.width(32.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (isCurrent) PlayingBars(playing) else Txt("$number", Type.body, color = c.label2)
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
|
||||
Txt(track.title, Type.body, color = if (isCurrent) c.accent else c.label)
|
||||
if (subtitle != null) Txt(subtitle, Type.subhead, color = c.label2)
|
||||
}
|
||||
if (noStems) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Tag("NO STEMS")
|
||||
}
|
||||
when {
|
||||
progress != null -> {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ProgressRing(progress, size = 16.dp)
|
||||
}
|
||||
downloaded -> {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
DownloadedMark()
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
if (onMore != null) IconButton(Sym.ellipsis, onMore, tint = c.label, size = 20.dp, box = 40.dp)
|
||||
else Spacer(Modifier.width(14.dp))
|
||||
}
|
||||
Hairline(Modifier.align(Alignment.BottomStart).padding(start = textStart))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column album grid, laid out as rows so it can live in a LazyColumn. [subtitle] replaces
|
||||
* the artist (or year) line; [badge] draws over the art's corner.
|
||||
*/
|
||||
fun LazyListScope.albumGrid(
|
||||
key: String,
|
||||
albums: List<Album>,
|
||||
onClick: (Album) -> Unit,
|
||||
onLongClick: ((Album) -> Unit)? = null,
|
||||
showArtist: Boolean = true,
|
||||
subtitle: ((Album) -> String?)? = null,
|
||||
badge: (@Composable (Album) -> Unit)? = null,
|
||||
) {
|
||||
val rows = albums.chunked(2)
|
||||
items(rows.size, key = { "$key.$it" }) { r ->
|
||||
val coverage by App.graph.coverage.albums.collectAsState()
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = MARGIN.dp)) {
|
||||
val tile = (maxWidth - 16.dp) / 2
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
rows[r].forEach { a ->
|
||||
Column(
|
||||
Modifier.width(tile).padding(bottom = 18.dp)
|
||||
.tappable(onLongClick = onLongClick?.let { { it(a) } }) { onClick(a) },
|
||||
) {
|
||||
Box {
|
||||
Artwork(a.thumb, tile, corner = 8.dp, placeholder = Sym.album)
|
||||
badge?.let { Box(Modifier.align(Alignment.BottomEnd).padding(8.dp)) { it(a) } }
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Txt(a.title, Type.subhead, Modifier.weight(1f, fill = false), weight = FontWeight.Medium)
|
||||
StemFlag(coverage[a.ratingKey], Modifier.padding(start = 6.dp))
|
||||
}
|
||||
val sub = subtitle?.invoke(a) ?: if (showArtist) a.artist else a.year?.toString() ?: ""
|
||||
if (sub.isNotEmpty()) Txt(sub, Type.subhead, color = Ios.colors.label2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistRow(artist: Artist, onLongClick: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val coverage by App.graph.coverage.artists.collectAsState()
|
||||
Box(Modifier.fillMaxWidth().background(if (pressed) c.cellPressed else Color.Transparent)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(60.dp).tappable(onLongClick = onLongClick, interaction = src, onClick = onClick)
|
||||
.padding(start = MARGIN.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Artwork(artist.thumb, 44.dp, circle = true, placeholder = Sym.person)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Txt(artist.title, Type.body, Modifier.weight(1f))
|
||||
StemFlag(coverage[artist.ratingKey], Modifier.padding(horizontal = 8.dp))
|
||||
Chevron()
|
||||
}
|
||||
Hairline(Modifier.align(Alignment.BottomStart).padding(start = 78.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Play / Shuffle pair under a header. */
|
||||
@Composable
|
||||
fun PlayShuffle(onPlay: () -> Unit, onShuffle: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = MARGIN.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
PillButton("Play", Sym.play, onPlay, Modifier.weight(1f))
|
||||
PillButton("Shuffle", Sym.shuffle, onShuffle, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The long-press / ••• menu for anything playable: songs, albums, artists and playlists all
|
||||
* offer the front or end of the queue, download, and add-to-playlist (playlists included).
|
||||
*/
|
||||
class MediaMenu(private val overlays: Overlays, private val app: AppActions) {
|
||||
private val g = App.graph
|
||||
private val scope: CoroutineScope = g.scope
|
||||
|
||||
fun show(track: Track, extra: List<SheetAction> = emptyList(), allowGoTo: Boolean = true) {
|
||||
val dl = g.downloads.records.value[track.ratingKey]
|
||||
val actions = buildList {
|
||||
addAll(queueActions { listOf(track) })
|
||||
if (dl == null) add(SheetAction("Download") { g.downloads.download(listOf(track)); overlays.hud("Downloading", Sym.download) })
|
||||
else add(SheetAction("Remove Download", destructive = true) { g.downloads.remove(listOf(track)) })
|
||||
add(SheetAction("Add to Playlist…") { addToPlaylist(listOf(track)) })
|
||||
if (track.partFile != null && g.stems.index.value.size > 0 && g.stems.find(track) == null) add(stemNow { listOf(track) })
|
||||
if (allowGoTo && track.albumKey != null) add(SheetAction("Go to Album") {
|
||||
app.showInLibrary(Route.AlbumPage(Album(track.albumKey, track.album, track.artist, track.artistKey, track.year, track.thumb)))
|
||||
})
|
||||
if (allowGoTo && track.artistKey != null) add(SheetAction("Go to Artist") {
|
||||
app.showInLibrary(Route.ArtistPage(Artist(track.artistKey, track.artist, track.artist)))
|
||||
})
|
||||
addAll(extra)
|
||||
}
|
||||
overlays.actions("${track.title} — ${track.artist}", actions)
|
||||
}
|
||||
|
||||
fun album(a: Album, extra: List<SheetAction> = emptyList(), allowGoTo: Boolean = true) {
|
||||
val have = g.downloads.records.value.values.filter { it.done && it.track.albumKey == a.ratingKey }.map { it.track }
|
||||
val total = g.coverage.albums.value[a.ratingKey]?.total ?: a.trackCount ?: Int.MAX_VALUE
|
||||
collection("${a.title} — ${a.artist}", have, have.size >= total, { g.plex.albumTracks(a.ratingKey) }, null, stemmed = stemmed(g.coverage.albums.value[a.ratingKey]), extra = buildList {
|
||||
if (allowGoTo && a.artistKey != null) add(SheetAction("Go to Artist") { app.showInLibrary(Route.ArtistPage(Artist(a.artistKey, a.artist, a.artist))) })
|
||||
addAll(extra)
|
||||
})
|
||||
}
|
||||
|
||||
fun artist(a: Artist) {
|
||||
val have = g.downloads.records.value.values.filter { it.done && it.track.artistKey == a.ratingKey }.map { it.track }
|
||||
val total = g.coverage.artists.value[a.ratingKey]?.total ?: Int.MAX_VALUE
|
||||
collection(a.title, have, have.size >= total, { g.plex.artistTracks(a.ratingKey) }, null, emptyList(), confirmDownload = true,
|
||||
stemmed = stemmed(g.coverage.artists.value[a.ratingKey]))
|
||||
}
|
||||
|
||||
fun playlist(p: Playlist, extra: List<SheetAction> = emptyList()) {
|
||||
collection(p.title, emptyList(), false, { g.plex.playlistTracks(p.ratingKey) }, p.ratingKey, extra, confirmDownload = p.trackCount > 50)
|
||||
}
|
||||
|
||||
/** For songs already in hand (downloads with no Plex album behind them). */
|
||||
fun tracks(title: String, tracks: List<Track>, extra: List<SheetAction> = emptyList()) {
|
||||
val have = tracks.filter { g.downloads.isDownloaded(it.ratingKey) }
|
||||
collection(title, have, have.size == tracks.size, { tracks }, null, extra,
|
||||
stemmed = g.stems.index.value.size > 0 && tracks.all { it.partFile == null || g.stems.find(it) != null })
|
||||
}
|
||||
|
||||
/** True when every song is known to have stems, so there's nothing for Stem Now to do. */
|
||||
private fun stemmed(cov: Coverage?) = g.coverage.ready.value && cov != null && cov.withStems >= cov.total
|
||||
|
||||
private fun collection(
|
||||
title: String, downloaded: List<Track>, allDownloaded: Boolean, load: suspend () -> List<Track>,
|
||||
playlistKey: String?, extra: List<SheetAction>, confirmDownload: Boolean = false, stemmed: Boolean = false,
|
||||
) {
|
||||
overlays.actions(title, buildList {
|
||||
addAll(queueActions(load))
|
||||
if (!allDownloaded) add(SheetAction(if (downloaded.isEmpty()) "Download" else "Download the Rest") {
|
||||
withTracks(load) { tracks ->
|
||||
if (confirmDownload && tracks.size > 20) {
|
||||
overlays.confirm("Download ${songs(tracks.size)}?", "“$title” will play without your server, stems and all.", "Download", destructive = false) {
|
||||
g.downloads.download(tracks); overlays.hud("Downloading", Sym.download)
|
||||
}
|
||||
} else {
|
||||
g.downloads.download(tracks); overlays.hud("Downloading", Sym.download)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (downloaded.isNotEmpty()) add(SheetAction("Remove Download", destructive = true) {
|
||||
overlays.confirm("Remove Download?", "“$title” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||
})
|
||||
add(SheetAction("Add to Playlist…") { withTracks(load) { addToPlaylist(it, skip = playlistKey) } })
|
||||
if (!stemmed) add(stemNow(load))
|
||||
addAll(extra)
|
||||
})
|
||||
}
|
||||
|
||||
/** Front of the queue (right after this song) and the end of it. */
|
||||
private fun queueActions(load: suspend () -> List<Track>) = listOf(
|
||||
SheetAction("Add to Front of Queue") { withTracks(load) { g.playback.playNext(it); overlays.hud("Playing Next", Sym.playNext) } },
|
||||
SheetAction("Add to End of Queue") { withTracks(load) { g.playback.addToQueue(it); overlays.hud("Added to Queue", Sym.addQueue) } },
|
||||
)
|
||||
|
||||
/** Has stemd on the server separate the songs that lack stems ahead of the rest of the library. */
|
||||
private fun stemNow(load: suspend () -> List<Track>) = SheetAction("Stem Now") {
|
||||
scope.launch {
|
||||
runCatching { g.stems.stemNow(load()) }
|
||||
.onSuccess { n ->
|
||||
if (n == 0) overlays.hud("Already has stems", Sym.info)
|
||||
else overlays.hud(if (n == 1) "Stemming next" else "Stemming ${songs(n)} next", Sym.waveform)
|
||||
}
|
||||
.onFailure { overlays.hud("Couldn't queue: ${it.message}", Sym.warning) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun withTracks(load: suspend () -> List<Track>, then: (List<Track>) -> Unit) {
|
||||
scope.launch {
|
||||
runCatching { load() }
|
||||
.onSuccess { if (it.isEmpty()) overlays.hud("Nothing to add", Sym.info) else then(it) }
|
||||
.onFailure { overlays.hud("Couldn't load songs: ${it.message}", Sym.warning) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addToPlaylist(tracks: List<Track>, skip: String? = null) {
|
||||
scope.launch {
|
||||
val lists = runCatching { g.plex.playlists().filter { !it.smart && it.ratingKey != skip } }.getOrElse {
|
||||
overlays.hud("Couldn't load playlists", Sym.warning)
|
||||
return@launch
|
||||
}
|
||||
val keys = tracks.map { it.ratingKey }.filter { !it.startsWith("stem:") }
|
||||
if (keys.isEmpty()) {
|
||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", Sym.info)
|
||||
return@launch
|
||||
}
|
||||
overlays.actions("Add ${songs(keys.size)} to Playlist", listOf(SheetAction("New Playlist…") { newPlaylist(tracks) }) + lists.map { p ->
|
||||
SheetAction(p.title) {
|
||||
scope.launch {
|
||||
runCatching { g.plex.addToPlaylist(p.ratingKey, keys) }
|
||||
.onSuccess { overlays.hud("Added to ${p.title}") }
|
||||
.onFailure { overlays.hud("Couldn't add: ${it.message}", Sym.warning) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun newPlaylist(tracks: List<Track>, onCreated: () -> Unit = {}) {
|
||||
overlays.prompt("New Playlist", "Name it; you can add more songs later.", "", "Create", "Playlist name") { name ->
|
||||
scope.launch {
|
||||
runCatching { g.plex.createPlaylist(name, tracks.map { it.ratingKey }.filter { !it.startsWith("stem:") }) }
|
||||
.onSuccess { overlays.hud("Created “$name”"); onCreated() }
|
||||
.onFailure { overlays.hud("Couldn't create: ${it.message}", Sym.warning) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMediaMenu(): MediaMenu {
|
||||
val overlays = LocalOverlays.current
|
||||
val app = LocalApp.current
|
||||
return remember(overlays, app) { MediaMenu(overlays, app) }
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.engine.EQ_BANDS
|
||||
import com.stemroller.mobile.engine.EQ_SLOPES
|
||||
import com.stemroller.mobile.engine.EqChannel
|
||||
import com.stemroller.mobile.engine.EqFilter
|
||||
import com.stemroller.mobile.engine.EqMath
|
||||
import com.stemroller.mobile.engine.FilterType
|
||||
import com.stemroller.mobile.eq.ApoConfig
|
||||
import com.stemroller.mobile.eq.EqController
|
||||
import com.stemroller.mobile.ui.components.Cell
|
||||
import com.stemroller.mobile.ui.components.IconButton
|
||||
import com.stemroller.mobile.ui.components.IosSlider
|
||||
import com.stemroller.mobile.ui.components.IosSwitch
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.Page
|
||||
import com.stemroller.mobile.ui.components.SegmentedControl
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.section
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.pow
|
||||
|
||||
// ---- formatting and slider mappings --------------------------------------------------------------
|
||||
|
||||
private const val F_LO = 20.0
|
||||
private const val F_HI = 20000.0
|
||||
private val RIGHT = Color(0xFFFF9500)
|
||||
|
||||
fun hz(f: Float) = if (f >= 1000f) String.format(Locale.ROOT, if (f >= 10000f) "%.1f kHz" else "%.2f kHz", f / 1000f).replace(".00 ", " ").replace(".0 ", " ")
|
||||
else "${f.toInt()} Hz"
|
||||
|
||||
private fun signedDb(v: Float) = (if (v > 0.005f) "+" else if (v < -0.005f) "−" else "") + String.format(Locale.ROOT, "%.1f dB", abs(v))
|
||||
|
||||
private fun freqToPos(f: Float) = (ln(f / F_LO) / ln(F_HI / F_LO)).toFloat().coerceIn(0f, 1f)
|
||||
private fun posToFreq(p: Float) = (F_LO * (F_HI / F_LO).pow(p.toDouble())).toFloat()
|
||||
private fun qToPos(q: Float) = (ln(q / EqController.Q_MIN) / ln(EqController.Q_MAX / EqController.Q_MIN)).coerceIn(0f, 1f)
|
||||
private fun posToQ(p: Float) = EqController.Q_MIN * (EqController.Q_MAX / EqController.Q_MIN).pow(p)
|
||||
|
||||
/** Rounds a dragged frequency to what a person would type: 3 significant figures. */
|
||||
private fun tidyFreq(f: Float): Float {
|
||||
val mag = 10.0.pow(kotlin.math.floor(log10(f.toDouble())) - 2).toFloat()
|
||||
return (Math.round(f / mag) * mag)
|
||||
}
|
||||
|
||||
private fun summary(f: EqFilter) = buildList {
|
||||
if (f.type.hasGain) add(signedDb(f.gain))
|
||||
if (f.type.hasSlope) add("${f.slope} dB/oct")
|
||||
if (!f.type.hasSlope || f.slope == 12) add("Q " + String.format(Locale.ROOT, "%.2f", f.q))
|
||||
if (f.channel != EqChannel.Both) add(f.channel.label)
|
||||
}.joinToString(" · ")
|
||||
|
||||
/** Sensible starting points, audible but mild, for a new filter of each type. */
|
||||
private fun newFilter(type: FilterType) = EqFilter(EqController.newFilterId(), type).let {
|
||||
when (type) {
|
||||
FilterType.Peak -> it.copy(freq = 1000f, gain = 3f, q = 1.41f)
|
||||
FilterType.LowShelf -> it.copy(freq = 105f, gain = 3f, q = 0.71f)
|
||||
FilterType.HighShelf -> it.copy(freq = 8000f, gain = 3f, q = 0.71f)
|
||||
FilterType.LowPass -> it.copy(freq = 16000f, q = 0.71f)
|
||||
FilterType.HighPass -> it.copy(freq = 30f, q = 0.71f, slope = 24)
|
||||
FilterType.BandPass -> it.copy(freq = 1000f, q = 0.71f)
|
||||
FilterType.Notch -> it.copy(freq = 60f, q = 4f)
|
||||
FilterType.AllPass -> it.copy(freq = 1000f, q = 0.71f)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- response curve ------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The EQ's response, log frequency across, dB up. Left and right are drawn separately when
|
||||
* some filter applies to only one of them (right in orange). [nodes] are drawn as numbered
|
||||
* handles; [highlight] gets its own curve in the accent colour.
|
||||
*/
|
||||
@Composable
|
||||
fun EqCurve(
|
||||
chain: List<EqFilter>,
|
||||
preampDb: Float,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier,
|
||||
range: Float = 18f,
|
||||
bandMarkers: Boolean = false,
|
||||
nodes: List<EqFilter> = emptyList(),
|
||||
highlight: EqFilter? = null,
|
||||
labels: Boolean = false,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val freqs = remember { EqMath.logFreqs(200, F_LO, F_HI) }
|
||||
val left = remember(chain) { EqMath.response(chain, freqs, true) }
|
||||
val right = remember(chain) { if (chain.any { it.channel != EqChannel.Both }) EqMath.response(chain, freqs, false) else null }
|
||||
val own = remember(highlight) { highlight?.let { EqMath.response(listOf(it), freqs) } }
|
||||
val text = rememberTextMeasurer()
|
||||
val labelStyle = Type.caption2.copy(color = c.label3)
|
||||
Canvas(modifier) {
|
||||
fun y(db: Double) = (size.height / 2 - (db / range) * (size.height / 2)).toFloat()
|
||||
fun x(f: Double) = (ln(f / F_LO) / ln(F_HI / F_LO) * size.width).toFloat()
|
||||
val step = 6.0
|
||||
var d = step
|
||||
while (d < range) {
|
||||
drawLine(c.separator, Offset(0f, y(d)), Offset(size.width, y(d)), 1f)
|
||||
drawLine(c.separator, Offset(0f, y(-d)), Offset(size.width, y(-d)), 1f)
|
||||
d += step
|
||||
}
|
||||
drawLine(c.label3, Offset(0f, y(0.0)), Offset(size.width, y(0.0)), 1.5f)
|
||||
val grid = if (bandMarkers) EQ_BANDS.map { it.toDouble() } else listOf(50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0)
|
||||
for (f in grid) drawLine(c.separator, Offset(x(f), 0f), Offset(x(f), size.height), 1f)
|
||||
if (labels) {
|
||||
for ((f, l) in listOf(100.0 to "100", 1000.0 to "1k", 10000.0 to "10k")) {
|
||||
drawText(text, l, Offset(x(f) + 3.dp.toPx(), size.height - 14.dp.toPx()), labelStyle)
|
||||
}
|
||||
drawText(text, "+${range.toInt()}", Offset(3.dp.toPx(), 1.dp.toPx()), labelStyle)
|
||||
drawText(text, "−${range.toInt()}", Offset(3.dp.toPx(), size.height - 14.dp.toPx()), labelStyle)
|
||||
}
|
||||
val main = if (enabled) c.accent else c.label3
|
||||
if (own != null) curve(own, ::y, if (enabled) main.copy(alpha = 0.35f) else c.label3, 1.5.dp.toPx(), fill = y(0.0))
|
||||
curve(left, ::y, if (highlight != null) c.label else main, 2.5.dp.toPx())
|
||||
right?.let { curve(it, ::y, if (enabled) RIGHT else c.label3, 2.dp.toPx()) }
|
||||
if (preampDb < 0f && highlight == null) {
|
||||
val net = DoubleArray(left.size) { left[it] + preampDb }
|
||||
curve(net, ::y, c.label2, 1.2.dp.toPx(), dashed = true)
|
||||
}
|
||||
for ((i, f) in nodes.withIndex()) {
|
||||
val sel = f.id == highlight?.id
|
||||
val cx = x(f.freq.toDouble().coerceIn(F_LO, F_HI))
|
||||
val cy = if (f.type.hasGain) y(f.gain.toDouble().coerceIn(-range.toDouble(), range.toDouble())) else y(0.0)
|
||||
val r = (if (sel) 11 else 9).dp.toPx()
|
||||
val col = when {
|
||||
!f.on -> c.label3
|
||||
f.channel == EqChannel.Right -> RIGHT
|
||||
else -> main
|
||||
}
|
||||
drawCircle(if (sel) Color.White else col, r, Offset(cx, cy))
|
||||
if (sel) drawCircle(col, r, Offset(cx, cy), style = Stroke(2.5.dp.toPx()))
|
||||
val label = text.measure("${i + 1}", Type.caption2.copy(color = if (sel) col else Color.White, fontWeight = FontWeight.Bold))
|
||||
drawText(label, topLeft = Offset(cx - label.size.width / 2f, cy - label.size.height / 2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.curve(v: DoubleArray, y: (Double) -> Float, color: Color, width: Float, dashed: Boolean = false, fill: Float? = null) {
|
||||
val path = Path()
|
||||
v.forEachIndexed { i, d ->
|
||||
val x = i / (v.size - 1f) * size.width
|
||||
if (i == 0) path.moveTo(x, y(d)) else path.lineTo(x, y(d))
|
||||
}
|
||||
if (fill != null) {
|
||||
val area = Path().apply { addPath(path); lineTo(size.width, fill); lineTo(0f, fill); close() }
|
||||
drawPath(area, color.copy(alpha = color.alpha * 0.4f))
|
||||
}
|
||||
drawPath(path, color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round,
|
||||
pathEffect = if (dashed) PathEffect.dashPathEffect(floatArrayOf(6f, 6f)) else null))
|
||||
}
|
||||
|
||||
// ---- filter list ---------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun EqFiltersScreen(profileId: String) {
|
||||
val eq = App.graph.eq
|
||||
val c = Ios.colors
|
||||
val nav = LocalNav.current
|
||||
val overlays = LocalOverlays.current
|
||||
val context = LocalContext.current
|
||||
val state by eq.state.collectAsState()
|
||||
val profile = state.profiles.firstOrNull { it.id == profileId }
|
||||
LaunchedEffect(profile == null) { if (profile == null) nav.pop() }
|
||||
if (profile == null) return
|
||||
val chain = remember(profile) { profile.chain }
|
||||
val preamp = remember(profile, state.antiClip) { profile.preamp + if (state.antiClip) EqMath.autoPreampDb(chain, profile.preamp) else 0f }
|
||||
val clipboard = remember { context.getSystemService(ClipboardManager::class.java) }
|
||||
|
||||
fun add() {
|
||||
overlays.actions("Add Filter", FilterType.entries.map { t ->
|
||||
SheetAction(t.label) {
|
||||
val f = newFilter(t)
|
||||
eq.putFilter(profileId, f)
|
||||
nav.push(Route.EqFilterPage(profileId, f.id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun import() {
|
||||
val text = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString().orEmpty()
|
||||
val parsed = ApoConfig.parse(text)
|
||||
if (parsed == null) {
|
||||
overlays.hud("No Equalizer APO filters on the clipboard", Sym.info)
|
||||
return
|
||||
}
|
||||
val what = listOfNotNull(
|
||||
"${parsed.filters.size} filter${if (parsed.filters.size == 1) "" else "s"}".takeIf { parsed.filters.isNotEmpty() },
|
||||
parsed.preamp?.let { "preamp ${signedDb(it)}" },
|
||||
).joinToString(" and ")
|
||||
val skipped = if (parsed.skipped > 0) " ${parsed.skipped} line${if (parsed.skipped == 1) "" else "s"} it doesn't understand (GraphicEQ, Delay…) will be skipped." else ""
|
||||
overlays.confirm("Replace Filters?", "Loads $what into “${profile.name}”, replacing its ${profile.filters.size} filter${if (profile.filters.size == 1) "" else "s"}.$skipped", "Replace", destructive = false) {
|
||||
eq.setFilters(profileId, parsed.filters, parsed.preamp)
|
||||
overlays.hud("Imported $what")
|
||||
}
|
||||
}
|
||||
|
||||
Page("Filters", grouped = true, largeTitle = false, trailing = {
|
||||
IconButton(Sym.ellipsis, {
|
||||
overlays.actions("Equalizer APO / AutoEQ", buildList {
|
||||
add(SheetAction("Paste APO Config") { import() })
|
||||
add(SheetAction("Copy as APO Config") {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("${profile.name} EQ", ApoConfig.format(profile)))
|
||||
overlays.hud("Copied")
|
||||
})
|
||||
if (profile.filters.isNotEmpty()) add(SheetAction("Remove All Filters", destructive = true) {
|
||||
overlays.confirm("Remove All Filters?", "The graphic EQ bands stay as they are.", "Remove All") { eq.setFilters(profileId, emptyList(), null) }
|
||||
})
|
||||
})
|
||||
})
|
||||
IconButton(Sym.plus, { add() })
|
||||
}) {
|
||||
section("curve", header = profile.name, footer = "Tap a numbered handle to edit that filter. Right-channel filters are orange. Paste an AutoEQ ParametricEQ.txt (from the ••• menu) to load a headphone correction.", rows = listOf {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
val nodes = profile.filters
|
||||
val tap by rememberUpdatedState { o: Offset, w: Float, h: Float ->
|
||||
val hit = nodes.withIndex().minByOrNull { (_, f) ->
|
||||
val dx = freqToPos(f.freq) * w - o.x
|
||||
val dy = (if (f.type.hasGain) h / 2 - f.gain / 18f * h / 2 else h / 2) - o.y
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
if (hit != null) {
|
||||
val f = hit.value
|
||||
val dx = freqToPos(f.freq) * w - o.x
|
||||
if (abs(dx) < 40f * context.resources.displayMetrics.density) nav.push(Route.EqFilterPage(profileId, f.id))
|
||||
}
|
||||
}
|
||||
EqCurve(chain, preamp, state.enabled, Modifier.fillMaxWidth().height(170.dp).pointerInput(Unit) {
|
||||
detectTapGestures { o -> tap(o, size.width.toFloat(), size.height.toFloat()) }
|
||||
}, nodes = nodes, labels = true)
|
||||
}
|
||||
})
|
||||
|
||||
section("preamp", header = "Preamp", footer = if (state.antiClip) "Anti-clipping adds ${signedDb(preamp - profile.preamp)} on top, so the total is ${signedDb(preamp)}." else null, rows = listOf {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IosSlider((profile.preamp + EqController.PREAMP_RANGE) / (2 * EqController.PREAMP_RANGE), { v ->
|
||||
eq.setPreamp(profileId, Math.round((v * 2 - 1) * EqController.PREAMP_RANGE * 10) / 10f)
|
||||
}, Modifier.weight(1f), detent = 0.5f)
|
||||
ValueLabel(signedDb(profile.preamp), 76.dp) {
|
||||
overlays.prompt("Preamp", "In dB, −24 to +24.", String.format(Locale.ROOT, "%.1f", profile.preamp), "Set") { s ->
|
||||
s.replace(',', '.').replace('−', '-').toFloatOrNull()?.let { eq.setPreamp(profileId, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
section("list", header = "Filters", rows = profile.filters.mapIndexed<EqFilter, @Composable () -> Unit> { i, f ->
|
||||
{
|
||||
Cell("${i + 1}. ${f.type.label} · ${hz(f.freq)}", subtitle = summary(f).ifEmpty { null },
|
||||
titleColor = if (f.on) c.label else c.label2, trailing = { IosSwitch(f.on, { on -> eq.putFilter(profileId, f.copy(on = on)) }) },
|
||||
onLongClick = {
|
||||
overlays.actions("${f.type.label} · ${hz(f.freq)}", listOf(
|
||||
SheetAction("Duplicate") { eq.putFilter(profileId, f.copy(id = EqController.newFilterId())) },
|
||||
SheetAction("Delete", destructive = true) { eq.removeFilter(profileId, f.id) },
|
||||
))
|
||||
}) { nav.push(Route.EqFilterPage(profileId, f.id)) }
|
||||
}
|
||||
} + listOf<@Composable () -> Unit> { Cell("Add Filter…", titleColor = c.accent) { add() } })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValueLabel(text: String, width: Dp, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Box(Modifier.width(width).padding(start = 10.dp).pointerInput(Unit) { detectTapGestures { onClick() } }, contentAlignment = Alignment.CenterEnd) {
|
||||
Txt(text, Type.subhead.copy(fontFeatureSettings = "tnum"), color = c.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- one filter ----------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun EqFilterScreen(profileId: String, filterId: String) {
|
||||
val eq = App.graph.eq
|
||||
val c = Ios.colors
|
||||
val nav = LocalNav.current
|
||||
val overlays = LocalOverlays.current
|
||||
val state by eq.state.collectAsState()
|
||||
val profile = state.profiles.firstOrNull { it.id == profileId }
|
||||
val filter = profile?.filters?.firstOrNull { it.id == filterId }
|
||||
LaunchedEffect(filter == null) { if (filter == null) nav.pop() }
|
||||
if (profile == null || filter == null) return
|
||||
val f: EqFilter = filter
|
||||
val number = profile.filters.indexOf(f) + 1
|
||||
val chain = remember(profile) { profile.chain }
|
||||
val current by rememberUpdatedState(f)
|
||||
fun put(nf: EqFilter) = eq.putFilter(profileId, nf)
|
||||
fun number(title: String, hint: String, initial: Float, set: (Float) -> Unit) =
|
||||
overlays.prompt(title, hint, String.format(Locale.ROOT, "%.2f", initial).trimEnd('0').trimEnd('.'), "Set") { s ->
|
||||
s.replace(',', '.').replace('−', '-').filter { it.isDigit() || it == '.' || it == '-' }.toFloatOrNull()?.let(set)
|
||||
}
|
||||
|
||||
Page("$number. ${f.type.label}", grouped = true, largeTitle = false, trailing = {
|
||||
IconButton(Sym.ellipsis, {
|
||||
overlays.actions("${f.type.label} · ${hz(f.freq)}", listOf(
|
||||
SheetAction("Duplicate") {
|
||||
val copy = f.copy(id = EqController.newFilterId())
|
||||
put(copy)
|
||||
nav.push(Route.EqFilterPage(profileId, copy.id))
|
||||
},
|
||||
SheetAction("Delete Filter", destructive = true) { eq.removeFilter(profileId, f.id) },
|
||||
))
|
||||
})
|
||||
}) {
|
||||
section("curve", footer = if (f.type.hasGain) "Drag the handle: sideways for frequency, up and down for gain." else "Drag the handle sideways to change the frequency.", rows = listOf {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
EqCurve(chain, 0f, state.enabled && f.on, Modifier.fillMaxWidth().height(190.dp).pointerInput(Unit) {
|
||||
var startPos = Offset.Zero
|
||||
var acc = Offset.Zero
|
||||
detectDragGestures(onDragStart = { o -> startPos = o; acc = Offset.Zero }) { ch, drag ->
|
||||
ch.consume()
|
||||
acc += drag
|
||||
val cur = current
|
||||
val p = ((startPos.x + acc.x) / size.width).coerceIn(0f, 1f)
|
||||
val gain = if (cur.type.hasGain) (18f * (1f - 2f * (startPos.y + acc.y) / size.height)).coerceIn(-18f, 18f) else cur.gain
|
||||
put(cur.copy(freq = tidyFreq(posToFreq(p)), gain = Math.round(gain * 10) / 10f))
|
||||
}
|
||||
}, nodes = profile.filters, highlight = f, labels = true)
|
||||
}
|
||||
})
|
||||
|
||||
section("type", rows = listOf(
|
||||
{ Cell("Type", value = f.type.label, chevron = true) {
|
||||
overlays.actions("Filter Type", FilterType.entries.map { t ->
|
||||
SheetAction((if (t == f.type) "✓ " else "") + t.label) {
|
||||
put(f.copy(type = t, q = if (t == FilterType.Notch && f.type != FilterType.Notch) 4f else f.q))
|
||||
}
|
||||
})
|
||||
} },
|
||||
{ Cell("On", trailing = { IosSwitch(f.on, { put(f.copy(on = it)) }) }) },
|
||||
))
|
||||
|
||||
val rows = buildList<@Composable () -> Unit> {
|
||||
add {
|
||||
ParamRow("Frequency", hz(f.freq), { number("Frequency", "In Hz, 10 to 22000.", f.freq) { v -> put(f.copy(freq = v)) } }) {
|
||||
IosSlider(freqToPos(f.freq), { put(f.copy(freq = tidyFreq(posToFreq(it)))) })
|
||||
}
|
||||
}
|
||||
if (f.type.hasGain) add {
|
||||
ParamRow("Gain", signedDb(f.gain), { number("Gain", "In dB, −24 to +24.", f.gain) { v -> put(f.copy(gain = v)) } }) {
|
||||
IosSlider((f.gain + EqController.FILTER_GAIN) / (2 * EqController.FILTER_GAIN), {
|
||||
put(f.copy(gain = Math.round((it * 2 - 1) * EqController.FILTER_GAIN * 10) / 10f))
|
||||
}, detent = 0.5f)
|
||||
}
|
||||
}
|
||||
if (!f.type.hasSlope || f.slope == 12) add {
|
||||
ParamRow("Q", String.format(Locale.ROOT, "%.2f", f.q), { number("Q", "0.1 (broad) to 20 (narrow). One octave is 1.41.", f.q) { v -> put(f.copy(q = v)) } }) {
|
||||
IosSlider(qToPos(f.q), { put(f.copy(q = Math.round(posToQ(it) * 100) / 100f)) })
|
||||
}
|
||||
}
|
||||
if (f.type.hasSlope) add {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Txt("Slope (dB per octave)", Type.subhead, color = c.label2)
|
||||
SegmentedControl(EQ_SLOPES.map { it.toString() }, EQ_SLOPES.indexOf(f.slope).coerceAtLeast(0), { put(f.copy(slope = EQ_SLOPES[it])) },
|
||||
Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
add {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Txt("Channel", Type.subhead, color = c.label2)
|
||||
SegmentedControl(EqChannel.entries.map { it.label }, f.channel.ordinal, { put(f.copy(channel = EqChannel.entries[it])) },
|
||||
Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
section("params", footer = when {
|
||||
f.type.hasSlope && f.slope != 12 -> "Steeper than 12 dB/oct is a Butterworth response, so there's no Q."
|
||||
f.type == FilterType.AllPass -> "An all-pass changes phase around its frequency, not level, so the curve stays flat."
|
||||
else -> "Tap a value to type it."
|
||||
}, rows = rows)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParamRow(title: String, value: String, onValue: () -> Unit, slider: @Composable () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Txt(title, Type.subhead, Modifier.weight(1f), color = c.label2)
|
||||
ValueLabel(value, 110.dp, onValue)
|
||||
}
|
||||
slider()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.engine.EQ_BANDS
|
||||
import com.stemroller.mobile.engine.EQ_LABELS
|
||||
import com.stemroller.mobile.engine.EQ_MAX_DB
|
||||
import com.stemroller.mobile.engine.EqMath
|
||||
import com.stemroller.mobile.eq.DeviceKind
|
||||
import com.stemroller.mobile.eq.EQ_PRESETS
|
||||
import com.stemroller.mobile.eq.EqProfile
|
||||
import com.stemroller.mobile.eq.OutputDevice
|
||||
import com.stemroller.mobile.ui.components.Cell
|
||||
import com.stemroller.mobile.ui.components.IosSwitch
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.Page
|
||||
import com.stemroller.mobile.ui.components.SegmentedControl
|
||||
import com.stemroller.mobile.ui.components.SettingsIcon
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.VerticalBandSlider
|
||||
import com.stemroller.mobile.ui.components.section
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlin.math.abs
|
||||
|
||||
fun deviceIcon(d: OutputDevice): Pair<ImageVector, androidx.compose.ui.graphics.Color> = when (d.kind) {
|
||||
DeviceKind.Speaker -> Sym.phone to androidx.compose.ui.graphics.Color(0xFF8E8E93)
|
||||
DeviceKind.Bluetooth -> Sym.headphones to androidx.compose.ui.graphics.Color(0xFF007AFF)
|
||||
DeviceKind.Wired -> Sym.headphones to androidx.compose.ui.graphics.Color(0xFF34C759)
|
||||
DeviceKind.Usb -> Sym.usb to androidx.compose.ui.graphics.Color(0xFFFF9500)
|
||||
DeviceKind.Hdmi -> Sym.airplay to androidx.compose.ui.graphics.Color(0xFF5856D6)
|
||||
DeviceKind.Other -> Sym.speakerWave to androidx.compose.ui.graphics.Color(0xFF8E8E93)
|
||||
}
|
||||
|
||||
private fun db(v: Float) = (if (v > 0) "+" else if (v < 0) "−" else "") + "%.1f".format(abs(v))
|
||||
|
||||
@Composable
|
||||
fun EqScreen() {
|
||||
val eq = App.graph.eq
|
||||
val c = Ios.colors
|
||||
val overlays = LocalOverlays.current
|
||||
val state by eq.state.collectAsState()
|
||||
val device by eq.device.collectAsState()
|
||||
val active by eq.active.collectAsState()
|
||||
val linked = state.profiles.firstOrNull { device in it.devices }
|
||||
val profile = active
|
||||
val nav = LocalNav.current
|
||||
val chain = remember(profile) { profile?.chain.orEmpty() }
|
||||
val preamp = remember(profile, state.antiClip) {
|
||||
if (profile == null) 0f else profile.preamp + if (state.antiClip) EqMath.autoPreampDb(chain, profile.preamp) else 0f
|
||||
}
|
||||
|
||||
fun profileMenu(p: EqProfile) {
|
||||
overlays.actions(p.name, buildList {
|
||||
if (device !in p.devices) add(SheetAction("Use for ${device.label}") { eq.link(p.id, device, true) })
|
||||
else add(SheetAction("Unlink from ${device.label}") { eq.link(p.id, device, false) })
|
||||
if (state.defaultId != p.id) add(SheetAction("Make Default") { eq.setDefault(p.id) })
|
||||
add(SheetAction("Rename…") { overlays.prompt("Rename Profile", null, p.name, "Save") { eq.rename(p.id, it) } })
|
||||
add(SheetAction("Duplicate") { eq.create("${p.name} Copy", p) })
|
||||
if (state.profiles.size > 1) add(SheetAction("Delete Profile", destructive = true) {
|
||||
overlays.confirm("Delete “${p.name}”?", null, "Delete") { eq.delete(p.id) }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fun profilesSheet() {
|
||||
overlays.actions("Profiles", buildList {
|
||||
state.profiles.forEach { p ->
|
||||
val links = p.devices.joinToString { it.label }
|
||||
add(SheetAction((if (active?.id == p.id) "✓ " else "") + p.name + if (links.isNotEmpty()) " ($links)" else "") { eq.select(p.id) })
|
||||
}
|
||||
add(SheetAction("New Profile…") {
|
||||
overlays.prompt("New EQ Profile", "Starts from the current curve.", "", "Create", "e.g. Sony WH-1000XM5") { name ->
|
||||
val p = eq.create(name, profile)
|
||||
eq.link(p.id, device, true)
|
||||
}
|
||||
})
|
||||
profile?.let { p -> add(SheetAction("Manage “${p.name}”…") { profileMenu(p) }) }
|
||||
})
|
||||
}
|
||||
|
||||
// One screen, no scrolling: profile management and presets live in sheets.
|
||||
Page("Equalizer", grouped = true, largeTitle = false) {
|
||||
section("enabled", rows = listOf(
|
||||
{ Cell("Equalizer", icon = { SettingsIcon(Sym.sliders, c.accent) }, trailing = { IosSwitch(state.enabled, eq::setEnabled) }) },
|
||||
{ Cell("Anti-Clipping Preamp", trailing = { IosSwitch(state.antiClip, eq::setAntiClip) }) },
|
||||
))
|
||||
|
||||
val (icon, tint) = deviceIcon(device)
|
||||
section("device", header = "Output", separatorInset = 60.dp, rows = listOf(
|
||||
{ Cell(device.label, icon = { SettingsIcon(icon, tint) }, value = linked?.name ?: "Default") {
|
||||
overlays.actions("Profile for ${device.label}", listOf(SheetAction("Use Default") {
|
||||
state.profiles.filter { device in it.devices }.forEach { eq.link(it.id, device, false) }
|
||||
}) + state.profiles.map { p -> SheetAction(p.name) { eq.link(p.id, device, true) } })
|
||||
} },
|
||||
{
|
||||
Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
SegmentedControl(listOf("Automatic", "Manual"), if (state.automatic) 0 else 1, { eq.setAutomatic(it == 0) })
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
if (profile != null) {
|
||||
section("editor", header = "${profile.name}${if (state.automatic) "" else " (manual)"}", rows = listOf {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
EqCurve(chain, preamp, state.enabled, Modifier.fillMaxWidth().height(64.dp), range = EQ_MAX_DB + 3f, bandMarkers = true)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
profile.gains.forEachIndexed { i, g ->
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(32.dp)) {
|
||||
Txt(db(g), Type.caption2.copy(fontFeatureSettings = "tnum"), color = if (g == 0f) c.label2 else c.accent, align = TextAlign.Center)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
VerticalBandSlider(g / EQ_MAX_DB, { eq.setGain(profile.id, i, it * EQ_MAX_DB) }, Modifier.height(124.dp))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Txt(EQ_LABELS[i], Type.caption2, color = c.label2, align = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Txt("Preamp", Type.footnote, Modifier.weight(1f), color = c.label2)
|
||||
Txt(if (preamp != 0f) "${db(preamp)} dB" else "off", Type.footnote.copy(fontFeatureSettings = "tnum"), color = c.label2)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
section("profiles", rows = listOf(
|
||||
{ Cell("Profile", value = profile?.name ?: "—", chevron = true, onLongClick = { profile?.let { profileMenu(it) } }) { profilesSheet() } },
|
||||
{ Cell("Parametric Filters", value = profile?.filters?.let { f -> if (f.isEmpty()) "None" else "${f.count { it.on }} on" } ?: "—", chevron = true) {
|
||||
profile?.let { nav.push(Route.EqFilters(it.id)) }
|
||||
} },
|
||||
{ Cell("Presets", value = "Flat, Bass…", chevron = true) {
|
||||
val p = profile ?: return@Cell
|
||||
overlays.actions("Load Preset", EQ_PRESETS.map { (name, gains) -> SheetAction(name) { eq.setGains(p.id, gains) } })
|
||||
} },
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import com.stemroller.mobile.ui.components.ProgressRing
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.plex.Album
|
||||
import com.stemroller.mobile.plex.Artist
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import com.stemroller.mobile.cache.DownloadRecord
|
||||
import com.stemroller.mobile.ui.components.Artwork
|
||||
import com.stemroller.mobile.ui.components.Chevron
|
||||
import com.stemroller.mobile.ui.components.EmptyState
|
||||
import com.stemroller.mobile.ui.components.ErrorRow
|
||||
import com.stemroller.mobile.ui.components.Hairline
|
||||
import com.stemroller.mobile.ui.components.IconButton
|
||||
import com.stemroller.mobile.ui.components.LoadingRow
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.MARGIN
|
||||
import com.stemroller.mobile.ui.components.Page
|
||||
import com.stemroller.mobile.ui.components.SectionTitle
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.TextButton
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.nav.Load
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.nav.rememberInEntry
|
||||
import com.stemroller.mobile.ui.nav.rememberLoader
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun LibraryHome() {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val c = Ios.colors
|
||||
val menu = rememberMediaMenu()
|
||||
val token by g.settings.plexToken.flow.collectAsState()
|
||||
val section by g.settings.musicSection.flow.collectAsState()
|
||||
val today = remember { LocalDate.now() }
|
||||
val albums = rememberLoader("albums:$section") { g.plex.albums() }
|
||||
val days = remember(albums.value, today) { albums.value?.let { birthdays(it, today) } }
|
||||
val ready = token.isNotEmpty() && section.isNotEmpty()
|
||||
|
||||
Page("Library") {
|
||||
if (!ready) {
|
||||
item("connect") {
|
||||
EmptyState(Sym.server, "Connect to Plex", "Sign in to your Plex server to browse your music. Stems come from stemserve on the same box.", "Connect") {
|
||||
nav.push(Route.PlexAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rows: List<Triple<ImageVector, Route, String?>> = listOf(
|
||||
Triple(Sym.noteList, Route.Playlists, null),
|
||||
Triple(Sym.person, Route.Artists, null),
|
||||
Triple(Sym.album, Route.Albums, null),
|
||||
Triple(Sym.note, Route.Songs, null),
|
||||
)
|
||||
itemsIndexed(rows, key = { _, r -> r.second.title }) { i, (icon, route, value) ->
|
||||
LibraryRow(icon, route.title, value, last = i == rows.lastIndex) { nav.push(route) }
|
||||
}
|
||||
if (ready) {
|
||||
item("dayTitle") {
|
||||
Column {
|
||||
SectionTitle("On This Day")
|
||||
Txt(today.format(DateTimeFormatter.ofPattern("MMMM d")), Type.subhead, Modifier.padding(start = MARGIN.dp, bottom = 10.dp), color = c.label2)
|
||||
}
|
||||
}
|
||||
when (val s = albums.state.value) {
|
||||
Load.Loading -> item("dayLoading") { LoadingRow() }
|
||||
is Load.Err -> item("dayErr") { ErrorRow(s.message) { albums.refresh(true) } }
|
||||
is Load.Ok -> {
|
||||
val (onDay, week) = days ?: (emptyList<Birthday>() to emptyList())
|
||||
if (onDay.isNotEmpty()) {
|
||||
albumGrid("today", onDay.map { it.album }, onClick = { nav.push(Route.AlbumPage(it)) }, onLongClick = { menu.album(it) },
|
||||
subtitle = { a -> onDay.first { it.album === a }.let { "${a.artist} · ${yearsAgo(it.years)}" } })
|
||||
} else {
|
||||
item("dayNone") {
|
||||
Txt(if (week.isEmpty()) "No albums in your library came out this week in any year." else "No album birthdays today. Coming up and just past:",
|
||||
Type.subhead, Modifier.padding(horizontal = MARGIN.dp).padding(bottom = 12.dp), color = c.label2, maxLines = 3)
|
||||
}
|
||||
if (week.isNotEmpty()) {
|
||||
albumGrid("week", week.map { it.album }, onClick = { nav.push(Route.AlbumPage(it)) }, onLongClick = { menu.album(it) },
|
||||
subtitle = { a ->
|
||||
week.first { it.album === a }.let { b ->
|
||||
val verb = if (b.date.isBefore(today)) "Turned" else "Turns"
|
||||
"$verb ${b.years} · ${b.date.format(DateTimeFormatter.ofPattern("MMM d"))}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An album's anniversary: its release day this year, and how old it is then. */
|
||||
private class Birthday(val album: Album, val date: LocalDate, val years: Int)
|
||||
|
||||
private fun yearsAgo(n: Int) = if (n == 1) "1 year ago" else "$n years ago"
|
||||
|
||||
/** Albums released on [today]'s date in earlier years, and, for slow days, the three days either side. */
|
||||
private fun birthdays(albums: List<Album>, today: LocalDate): Pair<List<Birthday>, List<Birthday>> {
|
||||
val all = albums.mapNotNull { a ->
|
||||
val d = a.released?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() } ?: return@mapNotNull null
|
||||
if (d.year >= today.year) return@mapNotNull null
|
||||
val anniversary = d.withYear(today.year) // Feb 29 falls back to Feb 28
|
||||
Birthday(a, anniversary, today.year - d.year)
|
||||
}
|
||||
val onDay = all.filter { it.date == today }.sortedByDescending { it.years }
|
||||
val week = all.filter { it.date != today && kotlin.math.abs(it.date.toEpochDay() - today.toEpochDay()) <= 3 }
|
||||
.sortedWith(compareBy({ it.date }, { -it.years }))
|
||||
return onDay to week
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryRow(icon: ImageVector, title: String, value: String?, last: Boolean, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
Box(Modifier.fillMaxWidth().background(if (pressed) c.cellPressed else Color.Transparent)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(52.dp).tappable(interaction = src, onClick = onClick).padding(start = MARGIN.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, c.accent, size = 26.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Txt(title, Type.title3, Modifier.weight(1f), weight = FontWeight.Normal)
|
||||
if (value != null) Txt(value, Type.body, color = c.label2, modifier = Modifier.padding(end = 8.dp))
|
||||
Chevron()
|
||||
}
|
||||
if (!last) Hairline(Modifier.align(Alignment.BottomStart).padding(start = 60.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artists --------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun ArtistsScreen() {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val menu = rememberMediaMenu()
|
||||
val artists = rememberLoader("artists") { g.plex.artists() }
|
||||
val state = rememberLazyListState()
|
||||
Page("Artists", state = state, overlay = {
|
||||
val list = artists.value
|
||||
if (list != null && list.size > 30) SectionIndex(list.map { it.sortTitle }, state, Modifier.align(Alignment.CenterEnd))
|
||||
}) {
|
||||
when (val s = artists.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { artists.refresh(true) } }
|
||||
is Load.Ok -> items(s.value.size, key = { s.value[it].ratingKey }) { i ->
|
||||
val a = s.value[i]
|
||||
ArtistRow(a, onLongClick = { menu.artist(a) }) { nav.push(Route.ArtistPage(a)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun letterOf(s: String): Char {
|
||||
val ch = s.trimStart().removePrefix("The ").firstOrNull()?.uppercaseChar() ?: '#'
|
||||
return if (ch in 'A'..'Z') ch else '#'
|
||||
}
|
||||
|
||||
/** UITableView's section index: drag down the letters to jump. */
|
||||
@Composable
|
||||
private fun SectionIndex(titles: List<String>, state: LazyListState, modifier: Modifier) {
|
||||
val c = Ios.colors
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val letters = remember { ('A'..'Z').toList() + '#' }
|
||||
val firstIndex = remember(titles) {
|
||||
val m = HashMap<Char, Int>()
|
||||
titles.forEachIndexed { i, t -> m.putIfAbsent(letterOf(t), i) }
|
||||
m
|
||||
}
|
||||
var last by remember { mutableStateOf<Char?>(null) }
|
||||
fun jump(fraction: Float) {
|
||||
val l = letters[(fraction * letters.size).toInt().coerceIn(0, letters.lastIndex)]
|
||||
if (l == last) return
|
||||
last = l
|
||||
val target = letters.drop(letters.indexOf(l)).firstNotNullOfOrNull { firstIndex[it] } ?: return
|
||||
haptic.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
scope.launch { state.scrollToItem(target + 1) }
|
||||
}
|
||||
Column(
|
||||
modifier.fillMaxHeight(0.62f).width(22.dp).padding(end = 2.dp)
|
||||
.pointerInput(titles) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
jump(down.position.y / size.height)
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val p = ev.changes.firstOrNull() ?: break
|
||||
if (!p.pressed) break
|
||||
p.consume()
|
||||
jump(p.position.y / size.height)
|
||||
}
|
||||
last = null
|
||||
}
|
||||
},
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
letters.forEach { Txt(it.toString(), Type.caption2, color = c.accent, weight = FontWeight.SemiBold) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- albums ---------------------------------------------------------------------------------------
|
||||
|
||||
private val ALBUM_SORTS = listOf("Title" to "titleSort", "Artist" to "artist.titleSort,year", "Recently Added" to "addedAt:desc", "Year" to "year:desc")
|
||||
|
||||
@Composable
|
||||
fun AlbumsScreen() {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val menu = rememberMediaMenu()
|
||||
val overlays = LocalOverlays.current
|
||||
var sort by rememberInEntry("sort") { mutableIntStateOf(0) }
|
||||
val albums = rememberLoader("albums:$sort") { g.plex.albums(ALBUM_SORTS[sort].second) }
|
||||
Page("Albums", trailing = {
|
||||
TextButton("Sort", {
|
||||
overlays.actions("Sort By", ALBUM_SORTS.mapIndexed { i, (label, _) -> SheetAction(if (i == sort) "✓ $label" else label) { sort = i } })
|
||||
})
|
||||
}) {
|
||||
when (val s = albums.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { albums.refresh(true) } }
|
||||
is Load.Ok -> {
|
||||
item("gap") { Spacer(Modifier.height(8.dp)) }
|
||||
albumGrid("albums", s.value, onClick = { nav.push(Route.AlbumPage(it)) }, onLongClick = { menu.album(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- songs (paged) --------------------------------------------------------------------------------
|
||||
|
||||
private class SongPager {
|
||||
val tracks = mutableStateListOf<Track>()
|
||||
var total by mutableIntStateOf(-1)
|
||||
var loading by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SongsScreen() {
|
||||
val g = App.graph
|
||||
val menu = rememberMediaMenu()
|
||||
val pager = rememberInEntry("pager") { SongPager() }
|
||||
val state = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
fun loadMore() {
|
||||
if (pager.loading || (pager.total >= 0 && pager.tracks.size >= pager.total)) return
|
||||
pager.loading = true
|
||||
scope.launch {
|
||||
runCatching { g.plex.tracksPage(pager.tracks.size, 200) }
|
||||
.onSuccess { (page, total) -> pager.tracks += page; pager.total = total; pager.error = null }
|
||||
.onFailure { pager.error = it.message }
|
||||
pager.loading = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (pager.tracks.isEmpty()) loadMore()
|
||||
snapshotFlow { state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
|
||||
.distinctUntilChanged().filter { it > pager.tracks.size - 40 }.collect { loadMore() }
|
||||
}
|
||||
val subtitleTotal by remember { derivedStateOf { if (pager.total > 0) songs(pager.total) else null } }
|
||||
Page("Songs", state = state) {
|
||||
if (pager.tracks.isNotEmpty()) {
|
||||
item("buttons") {
|
||||
Column {
|
||||
subtitleTotal?.let { Txt(it, Type.subhead, Modifier.padding(start = MARGIN.dp, bottom = 12.dp), color = Ios.colors.label2) }
|
||||
PlayShuffle(
|
||||
onPlay = { g.playback.playTracks(pager.tracks.take(1000), 0) },
|
||||
onShuffle = { g.playback.playTracks(pager.tracks.take(1000), shuffle = true) },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
items(pager.tracks.size, key = { pager.tracks[it].ratingKey }) { i ->
|
||||
val t = pager.tracks[i]
|
||||
TrackRow(t, onClick = {
|
||||
val from = (i - 200).coerceAtLeast(0)
|
||||
g.playback.playTracks(pager.tracks.subList(from, (i + 800).coerceAtMost(pager.tracks.size)).toList(), i - from)
|
||||
}, onMore = { menu.show(t) })
|
||||
}
|
||||
if (pager.loading) item("more") { LoadingRow() }
|
||||
pager.error?.let { e -> item("err") { ErrorRow(e) { loadMore() } } }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- album & artist pages -------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun AlbumScreen(album: Album) {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val c = Ios.colors
|
||||
val menu = rememberMediaMenu()
|
||||
val overlays = LocalOverlays.current
|
||||
val tracks = rememberLoader("tracks") { g.plex.albumTracks(album.ratingKey) }
|
||||
val detail = rememberLoader("detail") { g.plex.album(album.ratingKey) ?: album }
|
||||
val a = detail.value ?: album
|
||||
val records by g.downloads.records.collectAsState()
|
||||
val list = tracks.value.orEmpty()
|
||||
val allDownloaded = list.isNotEmpty() && list.all { records[it.ratingKey]?.done == true }
|
||||
|
||||
Page(a.title, largeTitle = false, trailing = {
|
||||
if (list.isNotEmpty()) {
|
||||
IconButton(if (allDownloaded) Sym.check else Sym.download, {
|
||||
if (allDownloaded) overlays.confirm("Remove Download?", "“${a.title}” will no longer be available offline.", "Remove") { g.downloads.remove(list) }
|
||||
else { g.downloads.download(list); overlays.hud("Downloading", Sym.download) }
|
||||
})
|
||||
IconButton(Sym.ellipsis, { menu.album(a) })
|
||||
}
|
||||
}) {
|
||||
item("header") {
|
||||
Column(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Artwork(a.thumb, 264.dp, corner = 10.dp, placeholder = Sym.album)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Txt(a.title, Type.title3, Modifier.padding(horizontal = 32.dp), align = TextAlign.Center, maxLines = 2)
|
||||
Txt(a.artist, Type.title3, Modifier.padding(horizontal = 32.dp).tappable {
|
||||
a.artistKey?.let { nav.push(Route.ArtistPage(Artist(it, a.artist, a.artist))) }
|
||||
}, color = c.accent, weight = FontWeight.Normal, align = TextAlign.Center)
|
||||
val meta = listOfNotNull(a.genre?.uppercase(), a.year?.toString()).joinToString(" · ")
|
||||
if (meta.isNotEmpty()) Txt(meta, Type.caption1, Modifier.padding(top = 4.dp), color = c.label2, weight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
PlayShuffle(onPlay = { g.playback.playTracks(list, 0) }, onShuffle = { g.playback.playTracks(list, shuffle = true) })
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Hairline(Modifier.padding(start = MARGIN.dp))
|
||||
}
|
||||
}
|
||||
when (val s = tracks.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { tracks.refresh(true) } }
|
||||
is Load.Ok -> {
|
||||
val discs = s.value.mapNotNull { it.disc }.toSet().size > 1
|
||||
s.value.forEachIndexed { i, t ->
|
||||
if (discs && (i == 0 || s.value[i - 1].disc != t.disc)) {
|
||||
item("disc${t.disc}") { Txt("Disc ${t.disc}", Type.headline, Modifier.padding(start = MARGIN.dp, top = 18.dp, bottom = 6.dp), color = c.label2) }
|
||||
}
|
||||
item(t.ratingKey) {
|
||||
TrackRow(t, onClick = { g.playback.playTracks(s.value, i) }, number = t.index ?: (i + 1), showArt = false,
|
||||
subtitle = t.artist.takeIf { it != a.artist }, onMore = { menu.show(t, allowGoTo = false) })
|
||||
}
|
||||
}
|
||||
item("footer") {
|
||||
Txt("${songs(s.value.size)}, ${formatMinutes(s.value.sumOf { it.durationMs })}", Type.subhead,
|
||||
Modifier.padding(start = MARGIN.dp, top = 14.dp), color = c.label2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistScreen(artist: Artist) {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val menu = rememberMediaMenu()
|
||||
val scope = rememberCoroutineScope()
|
||||
val overlays = LocalOverlays.current
|
||||
val albums = rememberLoader("albums") { g.plex.artistAlbums(artist.ratingKey).sortedByDescending { it.year ?: 0 } }
|
||||
fun playAll(shuffle: Boolean) = scope.launch {
|
||||
runCatching { g.plex.artistTracks(artist.ratingKey) }
|
||||
.onSuccess { g.playback.playTracks(it, 0, shuffle) }
|
||||
.onFailure { overlays.hud("Couldn't load songs", Sym.warning) }
|
||||
}
|
||||
Page(artist.title, trailing = { IconButton(Sym.ellipsis, { menu.artist(artist) }) }) {
|
||||
item("buttons") {
|
||||
Column(Modifier.padding(top = 6.dp, bottom = 18.dp)) { PlayShuffle(onPlay = { playAll(false) }, onShuffle = { playAll(true) }) }
|
||||
}
|
||||
when (val s = albums.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { albums.refresh(true) } }
|
||||
is Load.Ok -> albumGrid("albums", s.value, onClick = { nav.push(Route.AlbumPage(it)) }, onLongClick = { menu.album(it, allowGoTo = false) }, showArtist = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- downloads ------------------------------------------------------------------------------------
|
||||
|
||||
private val DOWNLOAD_SORTS = listOf("Download Order", "Artist", "Release Date")
|
||||
|
||||
/** Downloaded songs gathered back into their albums. */
|
||||
private class DownloadedAlbum(val album: Album, val records: List<DownloadRecord>, val released: String?) {
|
||||
val added = records.maxOf { it.added }
|
||||
val done = records.count { it.done }
|
||||
val failed = records.count { it.error != null }
|
||||
val complete get() = done == records.size
|
||||
val tracks get() = records.map { it.track }.sortedWith(compareBy({ it.disc ?: 0 }, { it.index ?: 0 }, { it.title.lowercase() }))
|
||||
}
|
||||
|
||||
private fun artistSortKey(s: String) = s.trim().lowercase().removePrefix("the ")
|
||||
|
||||
@Composable
|
||||
fun DownloadsScreen() {
|
||||
val g = App.graph
|
||||
val c = Ios.colors
|
||||
val nav = LocalNav.current
|
||||
val menu = rememberMediaMenu()
|
||||
val overlays = LocalOverlays.current
|
||||
val section by g.settings.musicSection.flow.collectAsState()
|
||||
val records by g.downloads.records.collectAsState()
|
||||
val progress by g.downloads.progress.collectAsState()
|
||||
val usage by g.cache.usage.collectAsState()
|
||||
val sort by g.settings.downloadSort.flow.collectAsState()
|
||||
// Release days come from the album list (served from the HTTP cache when the server's away).
|
||||
val albumInfo = rememberLoader("albums:$section") { g.plex.albums().associateBy { it.ratingKey } }
|
||||
val info = albumInfo.value.orEmpty()
|
||||
|
||||
val albums = remember(records, info, sort) {
|
||||
val grouped = records.values.groupBy { r -> r.track.albumKey ?: "name:${r.track.artist}|${r.track.album}" }.map { (key, rs) ->
|
||||
val t = rs.first().track
|
||||
val known = info[key]
|
||||
val artists = rs.map { it.track.artist }.distinct()
|
||||
DownloadedAlbum(
|
||||
known ?: Album(key, t.album.ifEmpty { "Unknown Album" }, if (artists.size > 1) "Various Artists" else t.artist, t.artistKey, t.year, t.thumb),
|
||||
rs, known?.released ?: (known?.year ?: t.year)?.toString(),
|
||||
)
|
||||
}
|
||||
when (sort) {
|
||||
1 -> grouped.sortedWith(compareBy({ artistSortKey(it.album.artist) }, { it.released ?: "" }, { it.album.title.lowercase() }))
|
||||
2 -> grouped.sortedWith(compareByDescending<DownloadedAlbum> { it.released ?: "" }.thenBy { it.album.title.lowercase() })
|
||||
else -> grouped.sortedByDescending { it.added }
|
||||
}
|
||||
}
|
||||
val byKey = albums.associateBy { it.album.ratingKey }
|
||||
val allDone = albums.flatMap { a -> a.tracks.filter { records[it.ratingKey]?.done == true } }
|
||||
|
||||
Page("Downloads", trailing = {
|
||||
if (albums.isNotEmpty()) {
|
||||
TextButton("Sort", {
|
||||
overlays.actions("Sort By", DOWNLOAD_SORTS.mapIndexed { i, label -> SheetAction(if (i == sort) "✓ $label" else label) { g.settings.downloadSort.value = i } })
|
||||
})
|
||||
IconButton(Sym.ellipsis, {
|
||||
overlays.actions("Downloads", buildList {
|
||||
if (albums.any { it.failed > 0 }) add(SheetAction("Retry Failed") { g.downloads.retryFailed() })
|
||||
add(SheetAction("Remove All Downloads", destructive = true) {
|
||||
overlays.confirm("Remove All Downloads?", "Frees ${"%.1f".format(usage.pinnedBytes / 1e9)} GB. Songs stay in your library.", "Remove All") {
|
||||
g.downloads.remove(records.values.map { it.track })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}) {
|
||||
if (albums.isEmpty()) {
|
||||
item("empty") { EmptyState(Sym.download, "Nothing Downloaded", "Hold any album, artist, playlist or song and choose Download to play it, stems and all, without your server.") }
|
||||
return@Page
|
||||
}
|
||||
item("summary") {
|
||||
Column(Modifier.padding(bottom = 14.dp)) {
|
||||
Txt("${albums.size} album${if (albums.size == 1) "" else "s"} · ${songs(records.size)} · ${"%.1f".format(usage.pinnedBytes / 1e9)} GB · by ${DOWNLOAD_SORTS[sort].lowercase()}",
|
||||
Type.subhead, Modifier.padding(start = MARGIN.dp, bottom = 12.dp), color = c.label2)
|
||||
PlayShuffle(onPlay = { g.playback.playTracks(allDone, 0) }, onShuffle = { g.playback.playTracks(allDone, shuffle = true) })
|
||||
}
|
||||
}
|
||||
albumGrid(
|
||||
"downloads", albums.map { it.album },
|
||||
onClick = { a ->
|
||||
val d = byKey.getValue(a.ratingKey)
|
||||
if (a.ratingKey.startsWith("name:")) g.playback.playTracks(d.tracks, 0) else nav.push(Route.AlbumPage(a))
|
||||
},
|
||||
onLongClick = { a ->
|
||||
val d = byKey.getValue(a.ratingKey)
|
||||
val extra = buildList {
|
||||
if (d.failed > 0) add(SheetAction("Retry Failed Songs") { g.downloads.retryFailed() })
|
||||
if (!d.complete) add(SheetAction("Cancel Download", destructive = true) { g.downloads.remove(d.records.filter { !it.done }.map { it.track }) })
|
||||
}
|
||||
if (a.ratingKey.startsWith("name:")) menu.tracks("${a.title} — ${a.artist}", d.tracks, extra) else menu.album(a, extra)
|
||||
},
|
||||
subtitle = { a ->
|
||||
val d = byKey.getValue(a.ratingKey)
|
||||
when {
|
||||
d.failed > 0 -> "${a.artist} · ${d.failed} failed"
|
||||
!d.complete -> "${a.artist} · ${d.done} of ${d.records.size}"
|
||||
sort == 2 && d.released != null -> "${a.artist} · ${d.released.take(4)}"
|
||||
else -> a.artist
|
||||
}
|
||||
},
|
||||
badge = { a ->
|
||||
val d = byKey.getValue(a.ratingKey)
|
||||
if (!d.complete) {
|
||||
val inFlight = d.records.sumOf { (progress[it.track.ratingKey] ?: 0f).toDouble() }
|
||||
Box(Modifier.size(30.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center) {
|
||||
if (d.failed > 0) Icon(Sym.warning, Color.White, size = 18.dp)
|
||||
else ProgressRing(((d.done + inFlight) / d.records.size).toFloat(), size = 20.dp, color = Color.White)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.palette.graphics.Palette
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import coil3.toBitmap
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.engine.MeterHistory
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.core.StemQuality
|
||||
import com.stemroller.mobile.playback.MixPresets
|
||||
import com.stemroller.mobile.playback.QueueItem
|
||||
import com.stemroller.mobile.playback.RepeatMode
|
||||
import com.stemroller.mobile.playback.SourceKind
|
||||
import com.stemroller.mobile.playback.StemSetting
|
||||
import com.stemroller.mobile.playback.Stems
|
||||
import com.stemroller.mobile.ui.components.Artwork
|
||||
import com.stemroller.mobile.ui.components.Chip
|
||||
import com.stemroller.mobile.ui.components.Hairline
|
||||
import com.stemroller.mobile.ui.components.IconButton
|
||||
import com.stemroller.mobile.ui.components.IosSlider
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.Spinner
|
||||
import com.stemroller.mobile.ui.components.TextButton
|
||||
import com.stemroller.mobile.ui.components.pressFade
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.LocalPalette
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
enum class NpPanel { Art, Mixer, Queue }
|
||||
|
||||
/**
|
||||
* A solid colour from the album art, pushed dark (or light for the White theme) so text
|
||||
* stays readable. One flat colour: no gradients.
|
||||
*/
|
||||
@Composable
|
||||
private fun albumColor(thumb: String?, enabled: Boolean, dark: Boolean): Color? {
|
||||
val ctx = LocalContext.current
|
||||
val c by produceState<Color?>(null, thumb, enabled, dark) {
|
||||
value = null
|
||||
if (!enabled) return@produceState
|
||||
val url = App.graph.plex.artUrl(thumb, 120) ?: return@produceState
|
||||
val res = SingletonImageLoader.get(ctx).execute(ImageRequest.Builder(ctx).data(url).allowHardware(false).build())
|
||||
val bmp: Bitmap = (res as? SuccessResult)?.image?.toBitmap() ?: return@produceState
|
||||
val rgb = withContext(Dispatchers.Default) {
|
||||
val p = Palette.from(bmp).generate()
|
||||
(p.darkVibrantSwatch ?: p.vibrantSwatch ?: p.dominantSwatch)?.rgb
|
||||
} ?: return@produceState
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rgb, hsv)
|
||||
if (dark) {
|
||||
hsv[1] = min(hsv[1], 0.65f)
|
||||
hsv[2] = hsv[2].coerceIn(0.18f, 0.30f)
|
||||
} else {
|
||||
hsv[1] = min(hsv[1], 0.22f)
|
||||
hsv[2] = 0.96f
|
||||
}
|
||||
value = Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NowPlayingScreen(onClose: () -> Unit, dragModifier: Modifier) {
|
||||
val g = App.graph
|
||||
val pm = g.playback
|
||||
val base = Ios.colors
|
||||
val overlays = LocalOverlays.current
|
||||
val app = LocalApp.current
|
||||
val menu = rememberMediaMenu()
|
||||
val item by pm.current.collectAsState()
|
||||
val playing by pm.playWhenReady.collectAsState()
|
||||
val buffering by pm.buffering.collectAsState()
|
||||
val duration by pm.durationMs.collectAsState()
|
||||
val waveform by pm.waveform.collectAsState()
|
||||
val names by pm.stemNames.collectAsState()
|
||||
val mix by pm.mix.collectAsState()
|
||||
val master by pm.master.collectAsState()
|
||||
val source by pm.source.collectAsState()
|
||||
val device by g.eq.device.collectAsState()
|
||||
val profile by g.eq.active.collectAsState()
|
||||
val eqState by g.eq.state.collectAsState()
|
||||
val useAlbumColor by g.settings.albumColorNowPlaying.flow.collectAsState()
|
||||
val quality by g.settings.stemQuality.flow.collectAsState()
|
||||
var panel by remember { mutableStateOf(NpPanel.Art) }
|
||||
val position = rememberPlaybackPosition()
|
||||
val track = item?.track
|
||||
|
||||
val tint = albumColor(track?.thumb, useAlbumColor, base.dark)
|
||||
val dark = tint?.let { it.luminance() < 0.5f } ?: base.dark
|
||||
val c = if (tint == null) base else base.copy(
|
||||
bg = tint, fill = if (dark) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.06f),
|
||||
label = if (dark) Color.White else Color.Black,
|
||||
label2 = if (dark) Color.White.copy(alpha = 0.62f) else Color.Black.copy(alpha = 0.55f),
|
||||
label3 = if (dark) Color.White.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.2f),
|
||||
fillStrong = if (dark) Color.White.copy(alpha = 0.22f) else Color.Black.copy(alpha = 0.12f),
|
||||
separator = if (dark) Color.White.copy(alpha = 0.14f) else Color.Black.copy(alpha = 0.1f),
|
||||
)
|
||||
val env = remember(waveform, names, mix) { envelope(waveform, names, mix) }
|
||||
val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalPalette provides c) {
|
||||
Column(Modifier.fillMaxSize().background(c.bg).padding(bottom = bottom + 8.dp)) {
|
||||
// Grabber; dragging here (or on the art) pulls the sheet down.
|
||||
Box(dragModifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.size(38.dp, 5.dp).clip(CircleShape).background(c.label3).tappable(onClick = onClose))
|
||||
}
|
||||
if (track == null) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Txt("Nothing Playing", Type.title3, color = c.label2) }
|
||||
return@Column
|
||||
}
|
||||
AnimatedContent(panel, Modifier.weight(1f), transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) }, label = "panel") { p ->
|
||||
when (p) {
|
||||
NpPanel.Art -> ArtPanel(track.thumb, playing, dragModifier)
|
||||
NpPanel.Mixer -> Column {
|
||||
MiniHeader(track.thumb, track.title, track.artist) { menu.show(track) }
|
||||
MixerPanel(names, mix, source, master)
|
||||
}
|
||||
NpPanel.Queue -> Column {
|
||||
MiniHeader(track.thumb, track.title, track.artist) { menu.show(track) }
|
||||
QueuePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 28.dp)) {
|
||||
if (panel == NpPanel.Art) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt(track.title, Type.title3)
|
||||
Txt(track.artist, Type.title3, color = c.label2, weight = FontWeight.Normal, modifier = Modifier.tappable {
|
||||
track.artistKey?.let { k -> app.showInLibrary(com.stemroller.mobile.ui.nav.Route.ArtistPage(com.stemroller.mobile.plex.Artist(k, track.artist, track.artist))) }
|
||||
})
|
||||
}
|
||||
Box(Modifier.size(32.dp).clip(CircleShape).background(c.fill).pressFade { menu.show(track) }, contentAlignment = Alignment.Center) {
|
||||
Icon(Sym.ellipsis, c.label, size = 18.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
val badge = when (source) {
|
||||
SourceKind.Stems -> "${names.size} STEMS · ${if (g.stems.effectiveQuality(quality) == StemQuality.Lossless) "FLAC" else "OPUS ${quality.kbps}"}"
|
||||
SourceKind.Original -> "ORIGINAL · ${track.codec?.uppercase() ?: ""}".trimEnd(' ', '·')
|
||||
SourceKind.None -> null
|
||||
}
|
||||
ProgressVolumeBar(
|
||||
position, duration, env, master, onSeek = pm::seekTo, onVolume = pm::setMaster,
|
||||
played = c.label, unplayed = c.label3, centerLabel = badge,
|
||||
)
|
||||
// Transport.
|
||||
Row(Modifier.fillMaxWidth().height(84.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(Sym.backward, pm::previous, tint = c.label, size = 38.dp, box = 72.dp)
|
||||
Box(Modifier.size(80.dp).pressFade(onClick = pm::togglePlay), contentAlignment = Alignment.Center) {
|
||||
if (buffering && playing) Spinner(size = 34.dp, color = c.label)
|
||||
else Icon(if (playing) Sym.pause else Sym.play, c.label, size = 52.dp)
|
||||
}
|
||||
IconButton(Sym.forward, pm::next, tint = c.label, size = 38.dp, box = 72.dp)
|
||||
}
|
||||
// Bottom row: stems, output route, queue.
|
||||
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
PanelToggle(Sym.sliders, panel == NpPanel.Mixer) { panel = if (panel == NpPanel.Mixer) NpPanel.Art else NpPanel.Mixer }
|
||||
Row(
|
||||
Modifier.weight(1f).pressFade { onClose(); app.showInSettings(Route.Eq) },
|
||||
horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(deviceIcon(device).first, c.label2, size = 16.dp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
val p = profile
|
||||
val eqLabel = when {
|
||||
!eqState.enabled || p == null -> ""
|
||||
p.name.equals(device.label, ignoreCase = true) -> " · EQ"
|
||||
else -> " · ${p.name}"
|
||||
}
|
||||
Txt(device.label + eqLabel, Type.footnote, color = c.label2, weight = FontWeight.Medium)
|
||||
}
|
||||
PanelToggle(Sym.queue, panel == NpPanel.Queue) { panel = if (panel == NpPanel.Queue) NpPanel.Art else NpPanel.Queue }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelToggle(icon: ImageVector, on: Boolean, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(if (on) c.label else Color.Transparent).pressFade(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, if (on) c.bg else c.label2, size = 22.dp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Big art that eases back when paused, like Apple Music. */
|
||||
@Composable
|
||||
private fun ArtPanel(thumb: String?, playing: Boolean, dragModifier: Modifier) {
|
||||
val scale by animateFloatAsState(if (playing) 1f else 0.82f, spring(dampingRatio = 0.62f, stiffness = 260f), label = "art")
|
||||
BoxWithConstraints(dragModifier.fillMaxSize().padding(horizontal = 28.dp, vertical = 8.dp), contentAlignment = Alignment.Center) {
|
||||
val side: Dp = min(maxWidth.value, maxHeight.value).dp
|
||||
Box(Modifier.graphicsLayer { scaleX = scale; scaleY = scale }) {
|
||||
Artwork(thumb, side, corner = 12.dp, placeholder = Sym.note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniHeader(thumb: String?, title: String, artist: String, onMore: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Artwork(thumb, 64.dp, corner = 8.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt(title, Type.headline)
|
||||
Txt(artist, Type.subhead, color = c.label2)
|
||||
}
|
||||
Box(Modifier.size(32.dp).clip(CircleShape).background(c.fill).pressFade(onClick = onMore), contentAlignment = Alignment.Center) {
|
||||
Icon(Sym.ellipsis, c.label, size = 18.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stem mixer ----------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun MixerPanel(names: List<String>, mix: Map<String, StemSetting>, source: SourceKind, master: Float) {
|
||||
val pm = App.graph.playback
|
||||
val c = Ios.colors
|
||||
// Live meters, every display frame, showing the block that's audible now: quick attack,
|
||||
// ~130 ms fall, timed in seconds so it looks the same at 60 and 165 Hz.
|
||||
val levels = remember { mutableStateListOf<Float>() }
|
||||
LaunchedEffect(names) {
|
||||
val raw = FloatArray(MeterHistory.MAX_STEMS)
|
||||
val shown = FloatArray(names.size)
|
||||
var last = 0L
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
val fall = if (last == 0L) 0f else exp(-(now - last) / 1e9f / 0.13f)
|
||||
last = now
|
||||
val live = pm.engine.levelsNow(raw)
|
||||
for (i in shown.indices) shown[i] = max(if (live) raw.getOrElse(i) { 0f } else 0f, shown[i] * fall)
|
||||
if (levels.size != shown.size) { levels.clear(); levels.addAll(shown.toList()) }
|
||||
else for (i in shown.indices) if (levels[i] != shown[i]) levels[i] = shown[i]
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp)) {
|
||||
if (source == SourceKind.Original) {
|
||||
Box(Modifier.fillMaxWidth().padding(vertical = 8.dp).clip(RoundedCornerShape(12.dp)).background(c.fill).padding(14.dp)) {
|
||||
Txt("No stems for this song yet, so you're hearing the original file. stemd on your server separates new music on its own; once this one's done, the app picks it up within a minute and plays it with stems next time.",
|
||||
Type.footnote, color = c.label2, maxLines = 5)
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.horizontalScroll(rememberScrollState()).padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val current = MixPresets.normalized(mix)
|
||||
MixPresets.ALL.forEach { p ->
|
||||
val target = p.build(names) ?: return@forEach
|
||||
Chip(p.label, MixPresets.normalized(target) == current, onClick = { pm.applyMix(target) })
|
||||
}
|
||||
}
|
||||
}
|
||||
names.forEachIndexed { i, name ->
|
||||
val s = mix[name] ?: StemSetting()
|
||||
StemFader(name, s, levels.getOrElse(i) { 0f }, enabled = source == SourceKind.Stems,
|
||||
onLevel = { pm.setStemLevel(name, it) }, onMute = { pm.toggleMute(name) }, onSolo = { pm.toggleSolo(name) })
|
||||
}
|
||||
Hairline(Modifier.padding(vertical = 10.dp), c.separator)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Txt("Master", Type.headline, Modifier.weight(1f))
|
||||
Txt("${(master * 100).toInt()}%", Type.footnote.copy(fontFeatureSettings = "tnum"), color = c.label2)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Sym.speaker, c.label2, size = 16.dp)
|
||||
IosSlider(master, pm::setMaster, Modifier.weight(1f).padding(horizontal = 8.dp), fill = c.label)
|
||||
Icon(Sym.speakerWave, c.label2, size = 18.dp)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StemFader(name: String, s: StemSetting, level: Float, enabled: Boolean, onLevel: (Float) -> Unit, onMute: () -> Unit, onSolo: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val color = Color(Stems.color(name))
|
||||
Column(Modifier.fillMaxWidth().padding(top = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(10.dp).clip(CircleShape).background(if (s.muted) c.label3 else color))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Txt(Stems.label(name), Type.headline, Modifier.weight(1f), color = if (s.muted) c.label2 else c.label)
|
||||
val pct = (com.stemroller.mobile.engine.stemTaper(s.level) * 100).toInt()
|
||||
Txt("$pct%", Type.footnote.copy(fontFeatureSettings = "tnum"), color = c.label2, modifier = Modifier.padding(end = 10.dp))
|
||||
if (enabled) {
|
||||
ToggleBox("M", s.muted, c.red, onMute)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
ToggleBox("S", s.solo, Color(0xFFFFCC00), onSolo)
|
||||
}
|
||||
}
|
||||
IosSlider(s.level, onLevel, fill = if (s.muted) c.label3 else color, detent = StemSetting.UNITY, enabled = enabled, thumb = 24.dp)
|
||||
// Live level under the fader.
|
||||
Canvas(Modifier.fillMaxWidth().height(3.dp).padding(horizontal = 12.dp)) {
|
||||
val w = size.width * min(1f, max(0f, (20f * kotlin.math.log10(level + 1e-5f) + 48f) / 48f))
|
||||
drawRoundRect(c.fill, Offset.Zero, size, CornerRadius(size.height / 2))
|
||||
drawRoundRect(color, Offset.Zero, Size(w, size.height), CornerRadius(size.height / 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleBox(text: String, on: Boolean, onColor: Color, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Box(
|
||||
Modifier.size(30.dp, 26.dp).clip(RoundedCornerShape(7.dp)).background(if (on) onColor else c.fill).pressFade(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Txt(text, Type.footnote, color = if (on) Color.Black else c.label2, weight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- queue ---------------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun QueuePanel() {
|
||||
val pm = App.graph.playback
|
||||
val c = Ios.colors
|
||||
val overlays = LocalOverlays.current
|
||||
val queue by pm.queue.collectAsState()
|
||||
val index by pm.index.collectAsState()
|
||||
val shuffle by pm.shuffle.collectAsState()
|
||||
val repeat by pm.repeat.collectAsState()
|
||||
val upcoming = remember(queue, index) { queue.drop(index + 1) }
|
||||
var dragUid by remember { mutableStateOf<Long?>(null) }
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
val rowPx = with(LocalDensity.current) { 60.dp.toPx() }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt("Playing Next", Type.headline)
|
||||
Txt(songs(upcoming.size), Type.footnote, color = c.label2)
|
||||
}
|
||||
QueueToggle(Sym.shuffle, shuffle, pm::toggleShuffle)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
QueueToggle(if (repeat == RepeatMode.One) Sym.repeatOne else Sym.repeat, repeat != RepeatMode.Off, pm::cycleRepeat)
|
||||
if (upcoming.isNotEmpty()) TextButton("Clear", { pm.clearUpNext() }, Modifier.padding(start = 6.dp), color = c.label2)
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(upcoming.size, key = { upcoming[it].uid }) { i ->
|
||||
val q: QueueItem = upcoming[i]
|
||||
val dragging = dragUid == q.uid
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(60.dp).zIndex(if (dragging) 1f else 0f)
|
||||
.graphicsLayer { translationY = if (dragging) dragOffset else 0f }
|
||||
.background(if (dragging) c.fill else Color.Transparent)
|
||||
.tappable(onLongClick = {
|
||||
overlays.actions(q.track.title, listOf(
|
||||
SheetAction("Play Next") { pm.move(index + 1 + i, index + 1) },
|
||||
SheetAction("Remove from Queue", destructive = true) { pm.removeAt(index + 1 + i) },
|
||||
))
|
||||
}) { pm.skipTo(index + 1 + i) }
|
||||
.padding(start = 24.dp, end = 8.dp)
|
||||
.animateItem(placementSpec = if (dragging) null else spring(stiffness = 900f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Artwork(q.track.thumb, 44.dp, corner = 5.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt(q.track.title, Type.body)
|
||||
Txt(q.track.artist, Type.subhead, color = c.label2)
|
||||
}
|
||||
Box(Modifier.size(44.dp).pointerInput(q.uid) {
|
||||
detectDragGestures(
|
||||
onDragStart = { dragUid = q.uid; dragOffset = 0f },
|
||||
onDragEnd = { dragUid = null; dragOffset = 0f },
|
||||
onDragCancel = { dragUid = null; dragOffset = 0f },
|
||||
) { ch, d ->
|
||||
ch.consume()
|
||||
dragOffset += d.y
|
||||
val qi = pm.queue.value.indexOfFirst { it.uid == q.uid }
|
||||
val first = pm.index.value + 1
|
||||
if (dragOffset > rowPx / 2 && qi < pm.queue.value.lastIndex) {
|
||||
pm.move(qi, qi + 1); dragOffset -= rowPx
|
||||
} else if (dragOffset < -rowPx / 2 && qi > first) {
|
||||
pm.move(qi, qi - 1); dragOffset += rowPx
|
||||
}
|
||||
}
|
||||
}, contentAlignment = Alignment.Center) { Icon(Sym.grip, c.label3, size = 22.dp) }
|
||||
}
|
||||
}
|
||||
if (upcoming.isEmpty()) item("empty") {
|
||||
Txt("Nothing queued. Use Play Next or Add to Queue on any song.", Type.subhead, Modifier.padding(24.dp), color = c.label2, maxLines = 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueToggle(icon: ImageVector, on: Boolean, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
Box(
|
||||
Modifier.size(36.dp, 30.dp).clip(RoundedCornerShape(8.dp)).background(if (on) c.label else c.fill).pressFade(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, if (on) c.bg else c.label2, size = 18.dp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mini player ----------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun MiniPlayer(onOpen: () -> Unit, dragModifier: Modifier) {
|
||||
val pm = App.graph.playback
|
||||
val c = Ios.colors
|
||||
val item by pm.current.collectAsState()
|
||||
val playing by pm.playWhenReady.collectAsState()
|
||||
val buffering by pm.buffering.collectAsState()
|
||||
val duration by pm.durationMs.collectAsState()
|
||||
val position = rememberPlaybackPosition()
|
||||
val t = item?.track ?: return
|
||||
Box(dragModifier.fillMaxWidth().height(64.dp).background(c.bar).tappable(onClick = onOpen)) {
|
||||
Hairline(Modifier.align(Alignment.TopStart), c.barBorder)
|
||||
Row(Modifier.fillMaxSize().padding(start = 16.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Artwork(t.thumb, 46.dp, corner = 6.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt(t.title, Type.subhead, weight = FontWeight.Medium)
|
||||
Txt(t.artist, Type.footnote, color = c.label2)
|
||||
}
|
||||
Box(Modifier.size(48.dp).pressFade(onClick = pm::togglePlay), contentAlignment = Alignment.Center) {
|
||||
if (buffering && playing) Spinner(size = 22.dp, color = c.label) else Icon(if (playing) Sym.pause else Sym.play, c.label, size = 28.dp)
|
||||
}
|
||||
IconButton(Sym.forward, pm::next, tint = c.label, size = 28.dp, box = 48.dp)
|
||||
}
|
||||
// Hairline progress along the bottom edge.
|
||||
Canvas(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(2.dp)) {
|
||||
val f = if (duration > 0) (position.value.toFloat() / duration).coerceIn(0f, 1f) else 0f
|
||||
drawRect(c.accent, Offset.Zero, Size(size.width * f, size.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.plex.Playlist
|
||||
import com.stemroller.mobile.plex.SearchResults
|
||||
import com.stemroller.mobile.plex.Track
|
||||
import com.stemroller.mobile.ui.components.Artwork
|
||||
import com.stemroller.mobile.ui.components.Chevron
|
||||
import com.stemroller.mobile.ui.components.EmptyState
|
||||
import com.stemroller.mobile.ui.components.ErrorRow
|
||||
import com.stemroller.mobile.ui.components.Hairline
|
||||
import com.stemroller.mobile.ui.components.IconButton
|
||||
import com.stemroller.mobile.ui.components.LoadingRow
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.MARGIN
|
||||
import com.stemroller.mobile.ui.components.Page
|
||||
import com.stemroller.mobile.ui.components.SearchField
|
||||
import com.stemroller.mobile.ui.components.SectionTitle
|
||||
import com.stemroller.mobile.ui.components.SheetAction
|
||||
import com.stemroller.mobile.ui.components.Spinner
|
||||
import com.stemroller.mobile.ui.components.Tag
|
||||
import com.stemroller.mobile.ui.components.TextButton
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.nav.Load
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.nav.rememberInEntry
|
||||
import com.stemroller.mobile.ui.nav.rememberLoader
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun PlaylistsScreen() {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val c = Ios.colors
|
||||
val menu = rememberMediaMenu()
|
||||
val overlays = LocalOverlays.current
|
||||
val lists = rememberLoader("playlists") { g.plex.playlists() }
|
||||
val queue by g.playback.queue.collectAsState()
|
||||
LaunchedEffect(Unit) { lists.refresh() }
|
||||
Page("Playlists", trailing = {
|
||||
IconButton(Sym.plus, {
|
||||
// Plex can't hold an empty playlist, so new ones start from songs you have.
|
||||
val now = g.playback.current.value?.track
|
||||
val acts = buildList {
|
||||
if (queue.isNotEmpty()) add(SheetAction("New Playlist from Queue (${queue.size})") { menu.newPlaylist(queue.map { it.track }) { lists.refresh() } })
|
||||
if (now != null) add(SheetAction("New Playlist with “${now.title}”") { menu.newPlaylist(listOf(now)) { lists.refresh() } })
|
||||
}
|
||||
if (acts.isEmpty()) overlays.hud("Play something first, or use “Add to Playlist” on any song.", Sym.info)
|
||||
else overlays.actions("New Playlist", acts)
|
||||
})
|
||||
}) {
|
||||
when (val s = lists.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { lists.refresh(true) } }
|
||||
is Load.Ok -> {
|
||||
if (s.value.isEmpty()) item("empty") {
|
||||
EmptyState(Sym.noteList, "No Playlists", "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.")
|
||||
}
|
||||
items(s.value.size, key = { s.value[it].ratingKey }) { i ->
|
||||
PlaylistRow(s.value[i], onLongClick = { menu.playlist(s.value[i]) }) { nav.push(Route.PlaylistPage(s.value[i])) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaylistRow(p: Playlist, onLongClick: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val c = Ios.colors
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
Box(Modifier.fillMaxWidth().background(if (pressed) c.cellPressed else Color.Transparent)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(76.dp).tappable(onLongClick = onLongClick, interaction = src, onClick = onClick).padding(start = MARGIN.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Artwork(p.thumb, 60.dp, corner = 6.dp, placeholder = Sym.noteList)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Txt(p.title, Type.body)
|
||||
Txt(songs(p.trackCount), Type.subhead, color = c.label2)
|
||||
}
|
||||
if (p.smart) {
|
||||
Tag("SMART")
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Chevron()
|
||||
}
|
||||
Hairline(Modifier.align(Alignment.BottomStart).padding(start = 94.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private class EditState {
|
||||
var editing by mutableStateOf(false)
|
||||
val items = mutableStateListOf<Track>()
|
||||
val removed = mutableStateListOf<Track>()
|
||||
var dragKey by mutableStateOf<Long?>(null)
|
||||
var dragOffset by mutableFloatStateOf(0f)
|
||||
var saving by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PlaylistScreen(playlist: Playlist) {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val c = Ios.colors
|
||||
val menu = rememberMediaMenu()
|
||||
val overlays = LocalOverlays.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val tracks = rememberLoader("tracks") { g.plex.playlistTracks(playlist.ratingKey) }
|
||||
var title by rememberInEntry("title") { mutableStateOf(playlist.title) }
|
||||
val edit = rememberInEntry("edit") { EditState() }
|
||||
val list = tracks.value.orEmpty()
|
||||
val rowPx = with(LocalDensity.current) { 64.dp.toPx() }
|
||||
|
||||
fun commit() {
|
||||
val original = list
|
||||
val target = edit.items.toList()
|
||||
val removed = edit.removed.toList()
|
||||
edit.saving = true
|
||||
g.scope.launch {
|
||||
runCatching {
|
||||
removed.forEach { t -> t.playlistItemId?.let { g.plex.removeFromPlaylist(playlist.ratingKey, it) } }
|
||||
// Replay the new order as "move after" calls against a simulated server list.
|
||||
val sim = original.filter { o -> removed.none { it.playlistItemId == o.playlistItemId } }.toMutableList()
|
||||
target.forEachIndexed { i, t ->
|
||||
if (sim.getOrNull(i)?.playlistItemId != t.playlistItemId) {
|
||||
val id = t.playlistItemId ?: return@forEachIndexed
|
||||
g.plex.movePlaylistItem(playlist.ratingKey, id, if (i == 0) null else target[i - 1].playlistItemId)
|
||||
sim.removeAll { it.playlistItemId == id }
|
||||
sim.add(i, t)
|
||||
}
|
||||
}
|
||||
}.onFailure { overlays.hud("Couldn't save: ${it.message}", com.stemroller.mobile.ui.theme.Sym.warning) }
|
||||
tracks.refresh()
|
||||
edit.saving = false
|
||||
edit.editing = false
|
||||
}
|
||||
}
|
||||
|
||||
Page(title, largeTitle = false, trailing = {
|
||||
if (!playlist.smart && list.isNotEmpty()) {
|
||||
if (edit.saving) Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Spinner() }
|
||||
else if (edit.editing) TextButton("Done", { commit() }, bold = true)
|
||||
else TextButton("Edit", {
|
||||
edit.items.clear(); edit.items.addAll(list); edit.removed.clear(); edit.editing = true
|
||||
})
|
||||
}
|
||||
if (!edit.editing) IconButton(Sym.ellipsis, {
|
||||
menu.playlist(playlist.copy(title = title, trackCount = list.size), extra = buildList {
|
||||
if (!playlist.smart) add(SheetAction("Rename…") {
|
||||
overlays.prompt("Rename Playlist", null, title, "Save") { name ->
|
||||
g.scope.launch { runCatching { g.plex.renamePlaylist(playlist.ratingKey, name) }.onSuccess { title = name } }
|
||||
}
|
||||
})
|
||||
add(SheetAction("Delete Playlist", destructive = true) {
|
||||
overlays.confirm("Delete “$title”?", "This removes it from Plex for every app.", "Delete") {
|
||||
g.scope.launch { runCatching { g.plex.deletePlaylist(playlist.ratingKey) }.onSuccess { nav.pop() } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}) {
|
||||
item("header") {
|
||||
Column(Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Artwork(playlist.thumb, 230.dp, corner = 10.dp, placeholder = Sym.noteList)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Txt(title, Type.title2, Modifier.padding(horizontal = 32.dp), align = TextAlign.Center, maxLines = 2)
|
||||
if (list.isNotEmpty()) Txt("${songs(list.size)} · ${formatMinutes(list.sumOf { it.durationMs })}", Type.subhead, color = c.label2)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
if (!edit.editing) PlayShuffle(onPlay = { g.playback.playTracks(list, 0) }, onShuffle = { g.playback.playTracks(list, shuffle = true) })
|
||||
}
|
||||
}
|
||||
when (val s = tracks.state.value) {
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(s.message) { tracks.refresh(true) } }
|
||||
is Load.Ok -> if (edit.editing) {
|
||||
items(edit.items.size, key = { edit.items[it].playlistItemId ?: it.toLong() }) { i ->
|
||||
val t = edit.items[i]
|
||||
val id = t.playlistItemId
|
||||
val dragging = edit.dragKey == id
|
||||
TrackRow(
|
||||
t, onClick = {},
|
||||
modifier = Modifier.zIndex(if (dragging) 1f else 0f)
|
||||
.graphicsLayer { translationY = if (dragging) edit.dragOffset else 0f }
|
||||
.background(if (dragging) c.cellPressed else Color.Transparent)
|
||||
.animateItem(placementSpec = if (dragging) null else androidx.compose.animation.core.spring(stiffness = 900f)),
|
||||
leading = {
|
||||
Box(Modifier.size(24.dp).clip(CircleShape).background(c.red).tappable {
|
||||
edit.removed += t
|
||||
edit.items.remove(t)
|
||||
}, contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.size(11.dp, 2.dp).background(Color.White))
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
Box(Modifier.size(44.dp).pointerInput(id) {
|
||||
detectDragGestures(
|
||||
onDragStart = { edit.dragKey = id; edit.dragOffset = 0f },
|
||||
onDragEnd = { edit.dragKey = null; edit.dragOffset = 0f },
|
||||
onDragCancel = { edit.dragKey = null; edit.dragOffset = 0f },
|
||||
) { ch, d ->
|
||||
ch.consume()
|
||||
edit.dragOffset += d.y
|
||||
val from = edit.items.indexOfFirst { it.playlistItemId == id }
|
||||
if (edit.dragOffset > rowPx / 2 && from < edit.items.lastIndex) {
|
||||
edit.items.add(from + 1, edit.items.removeAt(from))
|
||||
edit.dragOffset -= rowPx
|
||||
} else if (edit.dragOffset < -rowPx / 2 && from > 0) {
|
||||
edit.items.add(from - 1, edit.items.removeAt(from))
|
||||
edit.dragOffset += rowPx
|
||||
}
|
||||
}
|
||||
}, contentAlignment = Alignment.Center) { Icon(Sym.grip, c.label3, size = 22.dp) }
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(s.value.size, key = { s.value[it].playlistItemId ?: it.toLong() }) { i ->
|
||||
val t = s.value[i]
|
||||
TrackRow(t, onClick = { g.playback.playTracks(s.value, i) }, onMore = {
|
||||
menu.show(t, extra = if (playlist.smart) emptyList() else listOf(SheetAction("Remove from Playlist", destructive = true) {
|
||||
g.scope.launch {
|
||||
runCatching { t.playlistItemId?.let { g.plex.removeFromPlaylist(playlist.ratingKey, it) } }
|
||||
tracks.refresh()
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- search -------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
val g = App.graph
|
||||
val nav = LocalNav.current
|
||||
val c = Ios.colors
|
||||
val menu = rememberMediaMenu()
|
||||
var query by rememberInEntry("q") { mutableStateOf("") }
|
||||
var results by rememberInEntry("r") { mutableStateOf<Load<SearchResults>?>(null) }
|
||||
LaunchedEffect(query) {
|
||||
val q = query.trim()
|
||||
if (q.length < 2) {
|
||||
results = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(280)
|
||||
results = Load.Loading
|
||||
results = try {
|
||||
Load.Ok(g.plex.search(q))
|
||||
} catch (e: Exception) {
|
||||
Load.Err(e.message ?: "Search failed")
|
||||
}
|
||||
}
|
||||
Page("Search") {
|
||||
item("field") { SearchField(query, { query = it }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp), "Artists, Albums, Songs") }
|
||||
when (val r = results) {
|
||||
null -> item("hint") { EmptyState(Sym.search, "Search Your Library", "Find artists, albums, songs and playlists on your Plex server.") }
|
||||
Load.Loading -> item("loading") { LoadingRow() }
|
||||
is Load.Err -> item("err") { ErrorRow(r.message) { query = query.trim() + " " } }
|
||||
is Load.Ok -> {
|
||||
val s = r.value
|
||||
if (s.isEmpty) item("none") { EmptyState(Sym.search, "No Results", "Nothing matches “${query.trim()}”.") }
|
||||
if (s.artists.isNotEmpty()) {
|
||||
item("ah") { SectionTitle("Artists") }
|
||||
items(s.artists.size, key = { "a" + s.artists[it].ratingKey }) {
|
||||
ArtistRow(s.artists[it], onLongClick = { menu.artist(s.artists[it]) }) { nav.push(Route.ArtistPage(s.artists[it])) }
|
||||
}
|
||||
}
|
||||
if (s.albums.isNotEmpty()) {
|
||||
item("alh") { SectionTitle("Albums") }
|
||||
albumGrid("al", s.albums, onClick = { nav.push(Route.AlbumPage(it)) }, onLongClick = { menu.album(it) })
|
||||
}
|
||||
if (s.tracks.isNotEmpty()) {
|
||||
item("th") { SectionTitle("Songs") }
|
||||
items(s.tracks.size, key = { "t" + s.tracks[it].ratingKey }) { i ->
|
||||
val t = s.tracks[i]
|
||||
TrackRow(t, onClick = { g.playback.playTracks(s.tracks, i) }, subtitle = "${t.artist} — ${t.album}", onMore = { menu.show(t) })
|
||||
}
|
||||
}
|
||||
if (s.playlists.isNotEmpty()) {
|
||||
item("ph") { SectionTitle("Playlists") }
|
||||
items(s.playlists.size, key = { "p" + s.playlists[it].ratingKey }) {
|
||||
PlaylistRow(s.playlists[it], onLongClick = { menu.playlist(s.playlists[it]) }) { nav.push(Route.PlaylistPage(s.playlists[it])) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.engine.masterTaper
|
||||
import com.stemroller.mobile.engine.stemTaper
|
||||
import com.stemroller.mobile.playback.StemSetting
|
||||
import com.stemroller.mobile.playback.Waveform
|
||||
import com.stemroller.mobile.ui.theme.Icon
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Playback position, refreshed every display frame while playing (165 Hz on the OnePlus 15).
|
||||
* Read it inside draw lambdas so only drawing, not composition, follows it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPlaybackPosition(): State<Long> {
|
||||
val pm = App.graph.playback
|
||||
val pos = remember { mutableLongStateOf(pm.positionMs()) }
|
||||
val playing by pm.playWhenReady.collectAsState()
|
||||
val current by pm.current.collectAsState()
|
||||
LaunchedEffect(playing, current) {
|
||||
while (true) {
|
||||
if (playing) withFrameNanos { } else delay(200)
|
||||
pos.longValue = pm.positionMs()
|
||||
}
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
/** 0..1 loudness per waveform bucket for the current mix: stems weighted by their faders. */
|
||||
fun envelope(w: Waveform?, names: List<String>, mix: Map<String, StemSetting>): FloatArray? {
|
||||
if (w == null) return null
|
||||
w.stems?.let { stems ->
|
||||
val anySolo = names.any { mix[it]?.solo == true }
|
||||
val gains = stems.keys.associateWith { n ->
|
||||
val s = mix[n] ?: StemSetting()
|
||||
if (anySolo && !s.solo || s.muted) 0f else stemTaper(s.level)
|
||||
}
|
||||
// Scaled to the full mix's range, so muting a stem still shows as quieter bars.
|
||||
return shape(mixDb(stems, gains), mixDb(stems, null))
|
||||
}
|
||||
w.loudnessDb?.let { db -> return shape(db, db) }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun mixDb(stems: Map<String, FloatArray>, gains: Map<String, Float>?): FloatArray {
|
||||
val n = stems.values.maxOf { it.size }
|
||||
return FloatArray(n) { i ->
|
||||
var e = 0f
|
||||
for ((name, arr) in stems) {
|
||||
val v = arr.getOrElse(i) { 0f } * (gains?.get(name) ?: 1f)
|
||||
e += v * v
|
||||
}
|
||||
20f * log10(sqrt(e) + 1e-6f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loudness to bar height, relative to the song itself: its loudest moments (99th percentile)
|
||||
* fill the bar and its quiet tenth sits near the bottom, over 6 to 24 dB, with a curve that
|
||||
* pulls the middle down. Modern masters barely move in absolute terms (a few dB from verse to
|
||||
* chorus), so an absolute scale draws nearly every song as a solid block.
|
||||
*/
|
||||
private fun shape(db: FloatArray, ref: FloatArray): FloatArray {
|
||||
val audible = ref.filter { it > -60f }.ifEmpty { ref.toList() }.sorted()
|
||||
if (audible.isEmpty()) return FloatArray(db.size) { 0.28f }
|
||||
fun pct(p: Float) = audible[(p * audible.size).toInt().coerceAtMost(audible.lastIndex)]
|
||||
val hi = pct(0.99f)
|
||||
val span = (hi - pct(0.10f)).coerceIn(6f, 24f)
|
||||
val lo = hi - span
|
||||
return FloatArray(db.size) { 0.05f + 0.95f * ((db[it] - lo) / span).coerceIn(0f, 1f).pow(1.8f) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plexamp's seek bar: the track's loudness as bars, played part in accent. The bars'
|
||||
* height follows the master volume, so the volume is drawn over the progress.
|
||||
* Drag sideways to seek, up/down to change volume, tap to jump.
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressVolumeBar(
|
||||
position: State<Long>,
|
||||
durationMs: Long,
|
||||
envelope: FloatArray?,
|
||||
master: Float,
|
||||
onSeek: (Long) -> Unit,
|
||||
onVolume: (Float) -> Unit,
|
||||
played: Color,
|
||||
unplayed: Color,
|
||||
centerLabel: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = Ios.colors
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val slop = LocalViewConfiguration.current.touchSlop
|
||||
var scrubFraction by remember { mutableFloatStateOf(-1f) }
|
||||
var adjusting by remember { mutableStateOf(false) }
|
||||
val vol by rememberUpdatedState(master)
|
||||
val dur by rememberUpdatedState(durationMs)
|
||||
val seek by rememberUpdatedState(onSeek)
|
||||
val volume by rememberUpdatedState(onVolume)
|
||||
val volScale by animateFloatAsState(0.14f + 0.86f * masterTaper(master).let { sqrt(sqrt(it)) }, tween(90), label = "vol")
|
||||
val guides by animateFloatAsState(if (adjusting) 1f else 0f, tween(150), label = "guides")
|
||||
val shown by remember { derivedStateOf { if (scrubFraction >= 0f) (scrubFraction * dur).toLong() else position.value } }
|
||||
val shownSecond by remember { derivedStateOf { shown / 1000 } }
|
||||
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
Canvas(
|
||||
Modifier.fillMaxWidth().height(56.dp).pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
var axis = 0 // 1 = seek, 2 = volume
|
||||
var total = Offset.Zero
|
||||
val startVol = vol
|
||||
var lastStep = (startVol * 20).toInt()
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val ch = ev.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!ch.pressed) {
|
||||
when (axis) {
|
||||
0 -> seek((ch.position.x / size.width).coerceIn(0f, 1f).times(dur).toLong())
|
||||
1 -> seek((scrubFraction * dur).toLong())
|
||||
}
|
||||
break
|
||||
}
|
||||
total += ch.positionChange()
|
||||
if (axis == 0 && (abs(total.x) > slop || abs(total.y) > slop)) {
|
||||
axis = if (abs(total.x) >= abs(total.y)) 1 else 2
|
||||
if (axis == 2) adjusting = true
|
||||
haptic.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
|
||||
}
|
||||
when (axis) {
|
||||
1 -> scrubFraction = (ch.position.x / size.width).coerceIn(0f, 1f)
|
||||
2 -> {
|
||||
val v = (startVol - total.y / (size.height * 4.5f)).coerceIn(0f, 1f)
|
||||
val step = (v * 20).toInt()
|
||||
if (step != lastStep) {
|
||||
lastStep = step
|
||||
if (v == 0f || v == 1f) haptic.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
}
|
||||
volume(v)
|
||||
}
|
||||
}
|
||||
if (axis != 0) ch.consume()
|
||||
}
|
||||
scrubFraction = -1f
|
||||
adjusting = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
val env = envelope
|
||||
val barW = 2.7.dp.toPx()
|
||||
val pitch = 4.6.dp.toPx()
|
||||
val count = max(8, (size.width / pitch).toInt())
|
||||
val gap = (size.width - count * barW) / (count - 1)
|
||||
val maxH = size.height
|
||||
val minH = barW
|
||||
val frac = if (dur > 0) (if (scrubFraction >= 0f) scrubFraction else position.value.toFloat() / dur).coerceIn(0f, 1f) else 0f
|
||||
val cy = size.height / 2
|
||||
if (guides > 0f) {
|
||||
val g = (maxH * volScale) / 2
|
||||
val col = c.label2.copy(alpha = 0.5f * guides)
|
||||
drawLine(col, Offset(0f, cy - g), Offset(size.width, cy - g), 1.dp.toPx())
|
||||
drawLine(col, Offset(0f, cy + g), Offset(size.width, cy + g), 1.dp.toPx())
|
||||
}
|
||||
for (i in 0 until count) {
|
||||
val a = if (env == null || env.isEmpty()) 0.28f else {
|
||||
val from = i * env.size / count
|
||||
val to = max(from + 1, (i + 1) * env.size / count)
|
||||
var m = 0f
|
||||
for (k in from until to.coerceAtMost(env.size)) m = max(m, env[k])
|
||||
m
|
||||
}
|
||||
val h = minH + (maxH - minH) * a * volScale
|
||||
val x = i * (barW + gap)
|
||||
val isPlayed = (x + barW / 2) / size.width <= frac
|
||||
drawRoundRect(if (isPlayed) played else unplayed, Offset(x, cy - h / 2), Size(barW, h), CornerRadius(barW / 2))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Box(Modifier.fillMaxWidth().height(18.dp)) {
|
||||
val tnum = Type.caption1.copy(fontFeatureSettings = "tnum")
|
||||
Txt(formatDuration(shownSecond * 1000), tnum, Modifier.align(Alignment.CenterStart), color = c.label2, weight = FontWeight.Medium)
|
||||
Txt("-" + formatDuration(max(0, dur - shownSecond * 1000)), tnum, Modifier.align(Alignment.CenterEnd), color = c.label2, weight = FontWeight.Medium)
|
||||
Row(Modifier.align(Alignment.Center).graphicsLayer { alpha = if (adjusting) 1f else 0.95f }, verticalAlignment = Alignment.CenterVertically) {
|
||||
if (adjusting) {
|
||||
Icon(if (master <= 0.001f) Sym.speakerSlash else Sym.speakerWave, c.label2, size = 14.dp)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Txt("${(master * 100).toInt()}%", tnum, color = c.label2, weight = FontWeight.SemiBold)
|
||||
} else if (centerLabel != null) {
|
||||
Txt(centerLabel, Type.caption2, color = c.label2, weight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.stemroller.mobile.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.stemroller.mobile.App
|
||||
import com.stemroller.mobile.core.Accent
|
||||
import com.stemroller.mobile.core.StemQuality
|
||||
import com.stemroller.mobile.core.ThemeMode
|
||||
import com.stemroller.mobile.plex.MusicSection
|
||||
import com.stemroller.mobile.stems.StemServerStatus
|
||||
import com.stemroller.mobile.ui.components.Cell
|
||||
import com.stemroller.mobile.ui.components.IosSwitch
|
||||
import com.stemroller.mobile.ui.components.LocalOverlays
|
||||
import com.stemroller.mobile.ui.components.Page
|
||||
import com.stemroller.mobile.ui.components.SegmentedControl
|
||||
import com.stemroller.mobile.ui.components.SettingsIcon
|
||||
import com.stemroller.mobile.ui.components.Spinner
|
||||
import com.stemroller.mobile.ui.components.section
|
||||
import com.stemroller.mobile.ui.components.tappable
|
||||
import com.stemroller.mobile.ui.nav.LocalNav
|
||||
import com.stemroller.mobile.ui.nav.Route
|
||||
import com.stemroller.mobile.ui.nav.rememberInEntry
|
||||
import com.stemroller.mobile.ui.theme.Ios
|
||||
import com.stemroller.mobile.ui.theme.Sym
|
||||
import com.stemroller.mobile.ui.theme.Txt
|
||||
import com.stemroller.mobile.ui.theme.Type
|
||||
import com.stemroller.mobile.ui.theme.palette
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private fun gb(bytes: Long) = if (bytes < 1e9) "%.0f MB".format(bytes / 1e6) else "%.1f GB".format(bytes / 1e9)
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
val g = App.graph
|
||||
val s = g.settings
|
||||
val nav = LocalNav.current
|
||||
val user by s.plexUser.flow.collectAsState()
|
||||
val token by s.plexToken.flow.collectAsState()
|
||||
val sectionTitle by s.musicSectionTitle.flow.collectAsState()
|
||||
val status by g.stems.status.collectAsState()
|
||||
val quality by s.stemQuality.flow.collectAsState()
|
||||
val keepMix by s.keepStemMix.flow.collectAsState()
|
||||
val wifi by s.downloadsWifiOnly.flow.collectAsState()
|
||||
val theme by s.theme.flow.collectAsState()
|
||||
val hz by s.highRefreshRate.flow.collectAsState()
|
||||
val usage by g.cache.usage.collectAsState()
|
||||
val eqState by g.eq.state.collectAsState()
|
||||
val eqProfile by g.eq.active.collectAsState()
|
||||
val view = LocalView.current
|
||||
val mode = view.display?.mode
|
||||
|
||||
// Everything on one screen: no footers but the last, one line each.
|
||||
Page("Settings", grouped = true) {
|
||||
section("servers", header = "Servers", separatorInset = 60.dp, rows = listOf(
|
||||
{ Cell("Plex", icon = { SettingsIcon(Sym.server, Color(0xFFE5A00D)) }, value = if (token.isEmpty()) "Not Signed In" else sectionTitle.ifEmpty { user }, chevron = true) { nav.push(Route.PlexAccount) } },
|
||||
{ Cell("Stem Server", icon = { SettingsIcon(Sym.waveform, Color(0xFFAF52DE)) }, chevron = true, value = when (val st = status) {
|
||||
is StemServerStatus.Online -> "${st.tracks} songs"
|
||||
is StemServerStatus.Offline -> "Offline"
|
||||
StemServerStatus.Unknown -> "…"
|
||||
}) { nav.push(Route.StemServer) } },
|
||||
))
|
||||
section("sound", header = "Sound", separatorInset = 60.dp, rows = listOf(
|
||||
{ Cell("Equalizer", icon = { SettingsIcon(Sym.sliders, Color(0xFF34C759)) }, value = if (!eqState.enabled) "Off" else eqProfile?.name ?: "On", chevron = true) { nav.push(Route.Eq) } },
|
||||
{ Cell("Stem Quality", icon = { SettingsIcon(Sym.gauge, Color(0xFFFF2D55)) }, value = quality.label, chevron = true) { nav.push(Route.Quality) } },
|
||||
{ Cell("Keep Stem Mix Between Songs", icon = { SettingsIcon(Sym.speakerWave, Color(0xFF5856D6)) }, trailing = { IosSwitch(keepMix, { s.keepStemMix.value = it }) }) },
|
||||
))
|
||||
section("storage", header = "Storage", separatorInset = 60.dp, rows = listOf(
|
||||
{ Cell("Cache & Downloads", icon = { SettingsIcon(Sym.externalDrive, Color(0xFF8E8E93)) }, value = gb(usage.cachedBytes), chevron = true) { nav.push(Route.Storage) } },
|
||||
{ Cell("Download on Wi-Fi Only", icon = { SettingsIcon(Sym.download, Color(0xFF30B0C7)) }, trailing = { IosSwitch(wifi, { s.downloadsWifiOnly.value = it }) }) },
|
||||
))
|
||||
section("display", header = "Display", separatorInset = 60.dp,
|
||||
footer = "StemRoller 1.1 · stems by stemd on your server" + (mode?.let { " · ${"%.0f".format(it.refreshRate)} Hz" } ?: ""),
|
||||
rows = listOf(
|
||||
{ Cell("Appearance", icon = { SettingsIcon(Sym.paintbrush, Color(0xFF007AFF)) }, value = theme.label, chevron = true) { nav.push(Route.Appearance) } },
|
||||
{ Cell("High Refresh Rate", icon = { SettingsIcon(Sym.phone, Color(0xFFFF9500)) }, trailing = { IosSwitch(hz, { s.highRefreshRate.value = it }) }) },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** A Settings-style row with an inline text field on the right. */
|
||||
@Composable
|
||||
private fun FieldCell(label: String, value: String, onChange: (String) -> Unit, placeholder: String, secret: Boolean = false, keyboard: KeyboardType = KeyboardType.Uri) {
|
||||
val c = Ios.colors
|
||||
Row(Modifier.fillMaxWidth().height(46.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Txt(label, Type.body, Modifier.width(96.dp))
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
if (value.isEmpty()) Txt(placeholder, Type.body, color = c.label3, align = TextAlign.End)
|
||||
BasicTextField(
|
||||
value, onChange, singleLine = true,
|
||||
textStyle = Type.body.copy(color = c.label2, textAlign = TextAlign.End),
|
||||
cursorBrush = SolidColor(c.accent),
|
||||
visualTransformation = if (secret) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboard, autoCorrectEnabled = false),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PinState {
|
||||
var code by mutableStateOf<String?>(null)
|
||||
var id by mutableStateOf(0L)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlexAccountScreen() {
|
||||
val g = App.graph
|
||||
val s = g.settings
|
||||
val c = Ios.colors
|
||||
val ctx = LocalContext.current
|
||||
val overlays = LocalOverlays.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val token by s.plexToken.flow.collectAsState()
|
||||
val user by s.plexUser.flow.collectAsState()
|
||||
val section by s.musicSection.flow.collectAsState()
|
||||
var url by rememberInEntry("url") { mutableStateOf(s.plexUrl.value) }
|
||||
var manual by rememberInEntry("manual") { mutableStateOf("") }
|
||||
val pin = rememberInEntry("pin") { PinState() }
|
||||
var sections by rememberInEntry("sections") { mutableStateOf<List<MusicSection>?>(null) }
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun finish(newToken: String) {
|
||||
s.plexUrl.value = url.trim().trimEnd('/')
|
||||
s.plexToken.value = newToken
|
||||
s.plexUser.value = runCatching { g.plex.user(newToken).let { it.title ?: it.username ?: "Plex user" } }.getOrDefault("Plex user")
|
||||
s.plexMachineId.value = runCatching { g.plex.machineId() }.getOrDefault("")
|
||||
sections = runCatching { g.plex.musicSections() }.getOrNull()
|
||||
sections?.singleOrNull()?.let { s.musicSection.value = it.key; s.musicSectionTitle.value = it.title }
|
||||
g.stems.refresh()
|
||||
}
|
||||
|
||||
LaunchedEffect(token) {
|
||||
if (token.isNotEmpty() && sections == null) sections = runCatching { g.plex.musicSections() }.getOrNull()
|
||||
}
|
||||
// Poll plex.tv while a PIN is showing.
|
||||
LaunchedEffect(pin.id) {
|
||||
if (pin.id == 0L) return@LaunchedEffect
|
||||
repeat(300) {
|
||||
delay(2000)
|
||||
val p = runCatching { g.plex.checkPin(pin.id) }.getOrNull()
|
||||
val t = p?.authToken
|
||||
if (!t.isNullOrEmpty()) {
|
||||
pin.code = null
|
||||
pin.id = 0
|
||||
finish(t)
|
||||
overlays.hud("Signed In")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
pin.code = null
|
||||
pin.id = 0
|
||||
pin.error = "The code expired. Try again."
|
||||
}
|
||||
|
||||
Page("Plex", grouped = true) {
|
||||
section("server", header = "Server", footer = "Your server's LAN address (port 32400).", rows = listOf(
|
||||
{ FieldCell("Address", url, { url = it }, "http://192.168.86.2:32400") },
|
||||
{
|
||||
Cell(if (testing) "Testing…" else "Test Connection", titleColor = c.accent) {
|
||||
testing = true
|
||||
scope.launch {
|
||||
val ok = runCatching { g.plex.probe(url, token) }.getOrDefault(false)
|
||||
testing = false
|
||||
if (ok) {
|
||||
s.plexUrl.value = url.trim().trimEnd('/')
|
||||
overlays.hud("Connected")
|
||||
} else overlays.hud(if (token.isEmpty()) "Sign in first" else "Couldn't reach Plex at that address", Sym.warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
if (token.isEmpty()) {
|
||||
section("signin", header = "Sign In", footer = pin.error ?: if (pin.code == null) "Enter the code at plex.tv/link while signed in to Plex." else null, rows = listOfNotNull(
|
||||
{
|
||||
Cell(if (pin.code == null) "Sign In with plex.tv" else "Get a New Code", titleColor = c.accent) {
|
||||
pin.error = null
|
||||
scope.launch {
|
||||
runCatching { g.plex.createPin() }
|
||||
.onSuccess { pin.code = it.code; pin.id = it.id }
|
||||
.onFailure { pin.error = "Couldn't reach plex.tv: ${it.message}" }
|
||||
}
|
||||
}
|
||||
},
|
||||
if (pin.code != null) ({
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Txt(pin.code!!, Type.largeTitle.copy(fontSize = 44.sp, letterSpacing = 8.sp), weight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spinner(size = 16.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Txt("Waiting for plex.tv/link…", Type.footnote, color = c.label2)
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(Modifier.clip(CircleShape).background(c.accent).tappable {
|
||||
ctx.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://plex.tv/link")))
|
||||
}.padding(horizontal = 18.dp, vertical = 9.dp)) { Txt("Open plex.tv/link", Type.headline, color = Color.White) }
|
||||
}
|
||||
}) else null,
|
||||
))
|
||||
section("token", header = "Or Paste an X-Plex-Token", rows = listOf(
|
||||
{ FieldCell("Token", manual, { manual = it }, "Required", secret = true, keyboard = KeyboardType.Password) },
|
||||
{
|
||||
Cell("Use Token", titleColor = if (manual.isBlank()) c.label3 else c.accent) {
|
||||
if (manual.isNotBlank()) scope.launch {
|
||||
if (g.plex.probe(url, manual.trim())) finish(manual.trim()) else overlays.hud("That token didn't work on this server", Sym.warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
} else {
|
||||
section("account", header = "Account", rows = listOf { Cell(user.ifEmpty { "Signed In" }, icon = { SettingsIcon(Sym.person, Color(0xFFE5A00D)) }) })
|
||||
val list = sections
|
||||
section("library", header = "Music Library", rows = when {
|
||||
list == null -> listOf { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { Spinner() } }
|
||||
list.isEmpty() -> listOf { Cell("No music libraries found") }
|
||||
else -> list.map { sec ->
|
||||
{ Cell(sec.title, subtitle = sec.locations.joinToString(), checked = sec.key == section) {
|
||||
s.musicSection.value = sec.key
|
||||
s.musicSectionTitle.value = sec.title
|
||||
} }
|
||||
}
|
||||
})
|
||||
section("signout", rows = listOf {
|
||||
Cell("Sign Out", titleColor = c.red, centered = true) {
|
||||
overlays.confirm("Sign Out of Plex?", "Downloads stay on this phone.", "Sign Out") {
|
||||
s.plexToken.value = ""
|
||||
s.plexUser.value = ""
|
||||
s.musicSection.value = ""
|
||||
s.musicSectionTitle.value = ""
|
||||
sections = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StemServerScreen() {
|
||||
val g = App.graph
|
||||
val s = g.settings
|
||||
val c = Ios.colors
|
||||
val overlays = LocalOverlays.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val status by g.stems.status.collectAsState()
|
||||
var url by rememberInEntry("url") { mutableStateOf(s.stemUrl.value) }
|
||||
var token by rememberInEntry("token") { mutableStateOf(s.stemToken.value) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Page("Stem Server", grouped = true) {
|
||||
section("addr", header = "stemserve", footer = "stemserve shares the Stems folder that stemd fills (port 8765). The app checks it for new stems every minute.", rows = listOf(
|
||||
{ FieldCell("Address", url, { url = it }, "http://192.168.86.2:8765") },
|
||||
{ FieldCell("Token", token, { token = it }, "Optional", secret = true, keyboard = KeyboardType.Password) },
|
||||
{
|
||||
Cell(if (busy) "Connecting…" else "Save & Connect", titleColor = c.accent) {
|
||||
s.stemUrl.value = url.trim().trimEnd('/')
|
||||
s.stemToken.value = token.trim()
|
||||
busy = true
|
||||
scope.launch {
|
||||
g.stems.refresh(force = true)
|
||||
busy = false
|
||||
overlays.hud(if (g.stems.status.value is StemServerStatus.Online) "Connected" else "Couldn't connect", if (g.stems.status.value is StemServerStatus.Online) Sym.check else Sym.warning)
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
section("status", header = "Status", rows = listOf<@Composable () -> Unit>(
|
||||
{ Cell("Connection", value = when (val st = status) {
|
||||
is StemServerStatus.Online -> "Online"
|
||||
is StemServerStatus.Offline -> "Offline"
|
||||
StemServerStatus.Unknown -> "Checking"
|
||||
}) },
|
||||
{ Cell("Songs with Stems", value = when (val st = status) {
|
||||
is StemServerStatus.Online -> "${st.tracks}"
|
||||
is StemServerStatus.Offline -> "${st.cachedTracks} (cached)"
|
||||
StemServerStatus.Unknown -> "—"
|
||||
}) },
|
||||
{ Cell("Opus Transcoding", value = (status as? StemServerStatus.Online)?.let { if (it.transcode) "Available" else "No ffmpeg" } ?: "—") },
|
||||
) + listOfNotNull((status as? StemServerStatus.Offline)?.let { st -> { Cell("Error", subtitle = st.reason) } }))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QualityScreen() {
|
||||
val s = App.graph.settings
|
||||
val q by s.stemQuality.flow.collectAsState()
|
||||
Page("Stem Quality", grouped = true) {
|
||||
section("q", footer = "For streaming and new downloads. Opus is made once on the server and kept there; 256 kbps is about an eighth of the FLAC size.", rows =
|
||||
StemQuality.entries.map { e -> { Cell(e.label, subtitle = e.detail, checked = q == e) { s.stemQuality.value = e } } })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StorageScreen() {
|
||||
val g = App.graph
|
||||
val app = LocalApp.current
|
||||
val s = g.settings
|
||||
val c = Ios.colors
|
||||
val nav = LocalNav.current
|
||||
val overlays = LocalOverlays.current
|
||||
val usage by g.cache.usage.collectAsState()
|
||||
val limit by s.cacheLimitGb.flow.collectAsState()
|
||||
Page("Storage", grouped = true) {
|
||||
section("usage", header = "On This Phone", rows = listOf(
|
||||
{ Cell("Downloads", value = gb(usage.pinnedBytes), chevron = true) { app.selectTab(Tab.Downloads) } },
|
||||
{ Cell("Streaming Cache", value = gb(usage.cachedBytes - usage.pinnedBytes)) },
|
||||
))
|
||||
val limits = listOf(2, 5, 10, 20, 50, 100)
|
||||
section("limit", header = "Streaming Cache Limit", footer = "Streamed songs stay for replay and offline use, oldest first out. Downloads don't count.", rows = listOf {
|
||||
Box(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
SegmentedControl(limits.map { "$it GB" }, limits.indexOf(limit).coerceAtLeast(0), { s.cacheLimitGb.value = limits[it] })
|
||||
}
|
||||
})
|
||||
section("clear", rows = listOf {
|
||||
Cell("Clear Streaming Cache", titleColor = c.red, centered = true) {
|
||||
overlays.confirm("Clear Cache?", "Downloads are kept.", "Clear") { g.cache.clearUnpinned() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppearanceScreen() {
|
||||
val s = App.graph.settings
|
||||
val c = Ios.colors
|
||||
val theme by s.theme.flow.collectAsState()
|
||||
val accent by s.accent.flow.collectAsState()
|
||||
val albumColor by s.albumColorNowPlaying.flow.collectAsState()
|
||||
Page("Appearance", grouped = true) {
|
||||
section("theme", header = "Theme", footer = "Black is true black for OLED. No gradients anywhere, by design.", rows = listOf(
|
||||
{
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
ThemeMode.entries.forEach { m ->
|
||||
val p = palette(m, accent)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.tappable { s.theme.value = m }) {
|
||||
Box(
|
||||
Modifier.size(78.dp, 140.dp).clip(RoundedCornerShape(12.dp)).background(p.groupedBg)
|
||||
.border(if (m == theme) 3.dp else 1.dp, if (m == theme) c.accent else c.separator, RoundedCornerShape(12.dp)).padding(8.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(Modifier.size(40.dp, 6.dp).clip(CircleShape).background(p.label))
|
||||
repeat(3) { Box(Modifier.fillMaxWidth().height(18.dp).clip(RoundedCornerShape(4.dp)).background(p.cell)) }
|
||||
Box(Modifier.size(24.dp, 6.dp).clip(CircleShape).background(p.accent))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Txt(m.label, Type.subhead, weight = if (m == theme) FontWeight.SemiBold else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
section("accent", header = "Accent", rows = listOf {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
Accent.entries.forEach { a ->
|
||||
val col = Color(if (c.dark) a.dark else a.light)
|
||||
Box(
|
||||
Modifier.size(32.dp).clip(CircleShape).background(col).tappable { s.accent.value = a }
|
||||
.border(if (a == accent) 3.dp else 0.dp, if (a == accent) c.label else Color.Transparent, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
section("np", header = "Now Playing", footer = "Paints the Now Playing screen in one solid colour taken from the album art.", rows = listOf {
|
||||
Cell("Album Colour Background", trailing = { IosSwitch(albumColor, { s.albumColorNowPlaying.value = it }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.stemroller.mobile.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* SF Symbols-flavoured glyphs on a 24-unit grid. Each layer is a path that's either
|
||||
* filled (stroke = 0) or stroked with round caps, like SF's regular weight.
|
||||
*/
|
||||
object Sym {
|
||||
private class Layer(val d: String, val stroke: Float = 0f)
|
||||
|
||||
private fun icon(name: String, vararg layers: Layer): ImageVector {
|
||||
val b = ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f)
|
||||
for (l in layers) {
|
||||
val nodes = PathParser().parsePathString(l.d).toNodes()
|
||||
if (l.stroke > 0f) {
|
||||
b.addPath(nodes, fill = null, stroke = SolidColor(Color.Black), strokeLineWidth = l.stroke,
|
||||
strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round)
|
||||
} else {
|
||||
b.addPath(nodes, fill = SolidColor(Color.Black))
|
||||
}
|
||||
}
|
||||
return b.build()
|
||||
}
|
||||
|
||||
private fun f(d: String) = Layer(d)
|
||||
private fun s(d: String, w: Float = 1.9f) = Layer(d, w)
|
||||
private fun circle(cx: Float, cy: Float, r: Float) = "M${cx - r} ${cy}a$r $r 0 1 0 ${2 * r} 0a$r $r 0 1 0 ${-2 * r} 0z"
|
||||
|
||||
val play by lazy { icon("play", f("M7.5 4.9v14.2c0 .82.9 1.32 1.6.88l11-7.1a1.05 1.05 0 0 0 0-1.76l-11-7.1c-.7-.44-1.6.06-1.6.88z")) }
|
||||
val pause by lazy {
|
||||
icon("pause", f("M6.2 4.5h3.1c.45 0 .8.35.8.8v13.4c0 .45-.35.8-.8.8H6.2a.8.8 0 0 1-.8-.8V5.3c0-.45.35-.8.8-.8zm8.5 0h3.1c.45 0 .8.35.8.8v13.4c0 .45-.35.8-.8.8h-3.1a.8.8 0 0 1-.8-.8V5.3c0-.45.35-.8.8-.8z"))
|
||||
}
|
||||
val forward by lazy {
|
||||
icon("forward", f("M1.6 6.8v10.4c0 .8.88 1.28 1.55.84l7.95-5.2a1 1 0 0 0 0-1.68L3.15 5.96c-.67-.44-1.55.04-1.55.84zm11 0v10.4c0 .8.88 1.28 1.55.84l7.95-5.2a1 1 0 0 0 0-1.68l-7.95-5.2c-.67-.44-1.55.04-1.55.84z"))
|
||||
}
|
||||
val backward by lazy {
|
||||
icon("backward", f("M22.4 6.8v10.4c0 .8-.88 1.28-1.55.84l-7.95-5.2a1 1 0 0 1 0-1.68l7.95-5.2c.67-.44 1.55.04 1.55.84zm-11 0v10.4c0 .8-.88 1.28-1.55.84l-7.95-5.2a1 1 0 0 1 0-1.68l7.95-5.2c.67-.44 1.55.04 1.55.84z"))
|
||||
}
|
||||
val chevronRight by lazy { icon("chevron.right", s("M9 4.5l7.5 7.5L9 19.5", 2.4f)) }
|
||||
val chevronLeft by lazy { icon("chevron.left", s("M15.5 4l-8 8 8 8", 2.6f)) }
|
||||
val search by lazy { icon("magnifyingglass", s("${circle(10.5f, 10.5f, 6.75f)}M15.6 15.6l5 5", 2.2f)) }
|
||||
val note by lazy { icon("music.note", s("${circle(6.5f, 17.5f, 3f)}M9.5 17.5V4.2l9 -1.7v12.6${circle(15.5f, 14.8f, 3f)}M9.5 8.2l9-1.7", 1.9f)) }
|
||||
val noteList by lazy {
|
||||
icon("music.note.list", s("M3 6h11M3 11h11M3 16h6", 2f), s("M18.5 17.2V5l3-.8", 1.9f), f(circle(16.4f, 17.6f, 2.4f)))
|
||||
}
|
||||
val library by lazy {
|
||||
icon("square.stack", s("M6.5 3.8h11M4.5 6.8h15", 1.8f),
|
||||
f("M4.2 9.6h15.6c.94 0 1.7.76 1.7 1.7v7.5c0 .94-.76 1.7-1.7 1.7H4.2c-.94 0-1.7-.76-1.7-1.7v-7.5c0-.94.76-1.7 1.7-1.7z"))
|
||||
}
|
||||
val sliders by lazy {
|
||||
icon("slider.vertical.3", s("M6 3.5v17M12 3.5v17M18 3.5v17", 1.8f),
|
||||
f(circle(6f, 14.5f, 2.6f)), f(circle(12f, 8f, 2.6f)), f(circle(18f, 16f, 2.6f)))
|
||||
}
|
||||
val gear by lazy {
|
||||
icon("gearshape", f("M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"))
|
||||
}
|
||||
val ellipsis by lazy { icon("ellipsis", f(circle(5f, 12f, 1.9f)), f(circle(12f, 12f, 1.9f)), f(circle(19f, 12f, 1.9f))) }
|
||||
val plus by lazy { icon("plus", s("M12 5v14M5 12h14", 2.2f)) }
|
||||
val check by lazy { icon("checkmark", s("M5 12.5l4.5 4.5L19 7.5", 2.4f)) }
|
||||
val xmark by lazy { icon("xmark", s("M6.5 6.5l11 11M17.5 6.5l-11 11", 2.2f)) }
|
||||
val shuffle by lazy {
|
||||
icon("shuffle", s("M3 7h3.2c2.4 0 3.8 1.3 5.3 5s2.9 5 5.3 5H20.5M3 17h3.2c1.2 0 2.1-.35 2.9-1.1M14.3 8.1c.8-.75 1.7-1.1 2.9-1.1H20.5M18 4.5L20.5 7 18 9.5M18 14.5l2.5 2.5-2.5 2.5", 1.9f))
|
||||
}
|
||||
val repeat by lazy {
|
||||
icon("repeat", s("M4 11.5V9.5A3.5 3.5 0 0 1 7.5 6H20M17.2 3.2L20 6l-2.8 2.8M20 12.5v2A3.5 3.5 0 0 1 16.5 18H4M6.8 20.8L4 18l2.8-2.8", 1.9f))
|
||||
}
|
||||
val repeatOne by lazy {
|
||||
icon("repeat.1", s("M4 11.5V9.5A3.5 3.5 0 0 1 7.5 6H20M17.2 3.2L20 6l-2.8 2.8M20 12.5v2A3.5 3.5 0 0 1 16.5 18H4M6.8 20.8L4 18l2.8-2.8", 1.9f),
|
||||
s("M11 10.6l1.4-.9v5", 1.7f))
|
||||
}
|
||||
val speaker by lazy { icon("speaker.fill", f("M3.2 9.2h3.1L11 5.3c.4-.33 1-.05 1 .47v12.46c0 .52-.6.8-1 .47l-4.7-3.9H3.2a.7.7 0 0 1-.7-.7V9.9c0-.39.31-.7.7-.7z")) }
|
||||
val speakerWave by lazy {
|
||||
icon("speaker.wave.3.fill", f("M2.2 9.2h3.1L10 5.3c.4-.33 1-.05 1 .47v12.46c0 .52-.6.8-1 .47l-4.7-3.9H2.2a.7.7 0 0 1-.7-.7V9.9c0-.39.31-.7.7-.7z"),
|
||||
s("M14 9.3a3.8 3.8 0 0 1 0 5.4M16.6 6.8a7.4 7.4 0 0 1 0 10.4M19.2 4.3a11 11 0 0 1 0 15.4", 1.7f))
|
||||
}
|
||||
val speakerSlash by lazy {
|
||||
icon("speaker.slash.fill", f("M2.2 9.2h3.1L10 5.3c.4-.33 1-.05 1 .47v12.46c0 .52-.6.8-1 .47l-4.7-3.9H2.2a.7.7 0 0 1-.7-.7V9.9c0-.39.31-.7.7-.7z"),
|
||||
s("M15 9.5l5 5M20 9.5l-5 5", 1.9f))
|
||||
}
|
||||
val download by lazy { icon("arrow.down.circle", s("${circle(12f, 12f, 9.25f)}M12 7.5v8.5M8.5 12.6L12 16l3.5-3.4", 1.8f)) }
|
||||
val queue by lazy {
|
||||
icon("list.bullet", s("M8.5 6h12M8.5 12h12M8.5 18h12", 1.9f), f(circle(4f, 6f, 1.5f)), f(circle(4f, 12f, 1.5f)), f(circle(4f, 18f, 1.5f)))
|
||||
}
|
||||
val person by lazy { icon("person", s("${circle(12f, 7.8f, 4f)}M4.5 20.5c0-3.7 3.4-6.2 7.5-6.2s7.5 2.5 7.5 6.2", 1.9f)) }
|
||||
val album by lazy {
|
||||
icon("square.stack", s("M6 3.8h12", 1.8f),
|
||||
f("M5 6.8h14c.94 0 1.7.76 1.7 1.7v10.8c0 .94-.76 1.7-1.7 1.7H5c-.94 0-1.7-.76-1.7-1.7V8.5c0-.94.76-1.7 1.7-1.7z"))
|
||||
}
|
||||
val waveform by lazy { icon("waveform", s("M3 10.5v3M6.6 7v10M10.2 4.5v15M13.8 8.5v7M17.4 6v12M21 10v4", 2f)) }
|
||||
val headphones by lazy {
|
||||
icon("headphones", s("M4 15.5v-3.2a8 8 0 0 1 16 0v3.2", 1.9f),
|
||||
f("M3.5 14.2h2.6c.66 0 1.2.54 1.2 1.2v4.2c0 .66-.54 1.2-1.2 1.2H4.9c-.77 0-1.4-.63-1.4-1.4v-5.2zM20.5 14.2h-2.6c-.66 0-1.2.54-1.2 1.2v4.2c0 .66.54 1.2 1.2 1.2h1.2c.77 0 1.4-.63 1.4-1.4v-5.2z"))
|
||||
}
|
||||
val phone by lazy { icon("iphone", s("M8.2 2.8h7.6c1.1 0 2 .9 2 2v14.4c0 1.1-.9 2-2 2H8.2c-1.1 0-2-.9-2-2V4.8c0-1.1.9-2 2-2zM10.6 5.6h2.8", 1.8f)) }
|
||||
val usb by lazy { icon("cable.connector", s("M8.5 3.5h7v6.5h-7zM12 10v10.5M10.5 6h.01M13.5 6h.01", 1.8f)) }
|
||||
val server by lazy {
|
||||
icon("server.rack", s("M4.5 4.5h15c.55 0 1 .45 1 1v3.5c0 .55-.45 1-1 1h-15a1 1 0 0 1-1-1V5.5c0-.55.45-1 1-1zM4.5 14h15c.55 0 1 .45 1 1v3.5c0 .55-.45 1-1 1h-15a1 1 0 0 1-1-1V15c0-.55.45-1 1-1z", 1.8f),
|
||||
f(circle(7f, 7.25f, 1.1f)), f(circle(7f, 16.75f, 1.1f)))
|
||||
}
|
||||
val refresh by lazy { icon("arrow.clockwise", s("M19.5 12.5A7.5 7.5 0 1 1 17 6.8M19.5 3.5v4.4h-4.4", 2f)) }
|
||||
val grip by lazy { icon("line.3.horizontal", s("M4 7.5h16M4 12h16M4 16.5h16", 1.8f)) }
|
||||
val info by lazy { icon("info.circle", s("${circle(12f, 12f, 9.25f)}M12 11v6M12 7.6v.01", 2f)) }
|
||||
val warning by lazy { icon("exclamationmark.triangle", s("M10.3 4.2L2.8 17.6c-.75 1.33.21 2.9 1.7 2.9h15c1.49 0 2.45-1.57 1.7-2.9L13.7 4.2a1.95 1.95 0 0 0-3.4 0zM12 9.5v4.5M12 17.2v.01", 1.8f)) }
|
||||
val paintbrush by lazy { icon("paintbrush", s("M14.5 4.5l5 5-7.8 7.8-5-5zM6.7 12.3c-2.3.3-3.2 2.5-3.2 4.2 0 1.3-.5 2.4-1 3 3.7.6 7.4-.3 7.7-3.7", 1.8f)) }
|
||||
val gauge by lazy { icon("gauge", s("M4.2 17.5a9 9 0 1 1 15.6 0M12 13l4-5", 1.9f), f(circle(12f, 13.4f, 1.7f))) }
|
||||
val externalDrive by lazy { icon("internaldrive", s("M3.5 13l2.4-7.2c.27-.8 1-1.3 1.85-1.3h8.5c.84 0 1.58.5 1.85 1.3L20.5 13v4.5c0 1.1-.9 2-2 2h-13c-1.1 0-2-.9-2-2zM3.5 13h17M16.5 16.3h.01", 1.8f)) }
|
||||
val airplay by lazy {
|
||||
icon("airplayaudio", s("M6.3 17.4a8 8 0 1 1 11.4 0M8.9 14.9a4.4 4.4 0 1 1 6.2 0", 1.8f), f("M12 14.2l4.5 6.3h-9z"))
|
||||
}
|
||||
val playNext by lazy { icon("text.insert", s("M4 6.5h9M4 11.5h9M4 16.5h6", 1.9f), f("M15.5 13.6v6.8c0 .5.56.8.98.52l5.1-3.4a.62.62 0 0 0 0-1.04l-5.1-3.4c-.42-.28-.98.02-.98.52z")) }
|
||||
val addQueue by lazy { icon("text.append", s("M4 6.5h16M4 11.5h16M4 16.5h9M17.5 14v6M14.5 17h6", 1.9f)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Icon(vector: ImageVector, tint: Color, modifier: Modifier = Modifier, size: Dp = 24.dp) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(vector),
|
||||
contentDescription = null,
|
||||
modifier = modifier.size(size),
|
||||
colorFilter = ColorFilter.tint(tint),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.stemroller.mobile.ui.theme
|
||||
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.stemroller.mobile.R
|
||||
import com.stemroller.mobile.core.Accent
|
||||
import com.stemroller.mobile.core.ThemeMode
|
||||
import kotlin.math.exp
|
||||
|
||||
/** iOS semantic colours, flattened to solid values for each scheme. */
|
||||
@Immutable
|
||||
data class Palette(
|
||||
val dark: Boolean,
|
||||
val bg: Color,
|
||||
val groupedBg: Color,
|
||||
val cell: Color,
|
||||
val cellPressed: Color,
|
||||
val elevated: Color,
|
||||
val fill: Color,
|
||||
val fillStrong: Color,
|
||||
val label: Color,
|
||||
val label2: Color,
|
||||
val label3: Color,
|
||||
val separator: Color,
|
||||
val bar: Color,
|
||||
val barBorder: Color,
|
||||
val accent: Color,
|
||||
val red: Color,
|
||||
val green: Color,
|
||||
val switchOff: Color,
|
||||
val scrim: Color,
|
||||
val placeholder: Color,
|
||||
val thumbBorder: Color,
|
||||
val tabInactive: Color,
|
||||
)
|
||||
|
||||
fun palette(mode: ThemeMode, accent: Accent): Palette = when (mode) {
|
||||
ThemeMode.Black -> Palette(
|
||||
dark = true, bg = Color(0xFF000000), groupedBg = Color(0xFF000000), cell = Color(0xFF1C1C1E),
|
||||
cellPressed = Color(0xFF2C2C2E), elevated = Color(0xFF1C1C1E), fill = Color(0xFF1C1C1E),
|
||||
fillStrong = Color(0xFF3A3A3C), label = Color.White, label2 = Color(0xFF8D8D93), label3 = Color(0xFF48484A),
|
||||
separator = Color(0xFF2C2C2E), bar = Color(0xFF000000), barBorder = Color(0xFF262628),
|
||||
accent = Color(accent.dark), red = Color(0xFFFF453A), green = Color(0xFF30D158), switchOff = Color(0xFF39393D),
|
||||
scrim = Color(0x99000000), placeholder = Color(0xFF1C1C1E), thumbBorder = Color(0x00000000), tabInactive = Color(0xFF757575),
|
||||
)
|
||||
ThemeMode.Dark -> Palette(
|
||||
dark = true, bg = Color(0xFF1C1C1E), groupedBg = Color(0xFF1C1C1E), cell = Color(0xFF2C2C2E),
|
||||
cellPressed = Color(0xFF3A3A3C), elevated = Color(0xFF2C2C2E), fill = Color(0xFF2C2C2E),
|
||||
fillStrong = Color(0xFF48484A), label = Color.White, label2 = Color(0xFF98989D), label3 = Color(0xFF5A5A5E),
|
||||
separator = Color(0xFF3D3D40), bar = Color(0xFF1C1C1E), barBorder = Color(0xFF38383A),
|
||||
accent = Color(accent.dark), red = Color(0xFFFF453A), green = Color(0xFF30D158), switchOff = Color(0xFF48484A),
|
||||
scrim = Color(0x99000000), placeholder = Color(0xFF3A3A3C), thumbBorder = Color(0x00000000), tabInactive = Color(0xFF808084),
|
||||
)
|
||||
ThemeMode.White -> Palette(
|
||||
dark = false, bg = Color(0xFFFFFFFF), groupedBg = Color(0xFFF2F2F7), cell = Color(0xFFFFFFFF),
|
||||
cellPressed = Color(0xFFD1D1D6), elevated = Color(0xFFFFFFFF), fill = Color(0xFFEFEFF0),
|
||||
fillStrong = Color(0xFFE3E3E8), label = Color.Black, label2 = Color(0xFF8A8A8E), label3 = Color(0xFFC4C4C7),
|
||||
separator = Color(0xFFD8D8DC), bar = Color(0xFFF9F9F9), barBorder = Color(0xFFD1D1D6),
|
||||
accent = Color(accent.light), red = Color(0xFFFF3B30), green = Color(0xFF34C759), switchOff = Color(0xFFE9E9EA),
|
||||
scrim = Color(0x55000000), placeholder = Color(0xFFE5E5EA), thumbBorder = Color(0xFFD1D1D6), tabInactive = Color(0xFF999999),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
private fun inter(opsz: Float) = FontFamily(
|
||||
listOf(400, 500, 600, 700, 800).map { w ->
|
||||
Font(
|
||||
R.font.inter,
|
||||
weight = FontWeight(w),
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(w), FontVariation.Setting("opsz", opsz)),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/** Inter stands in for SF Pro: the text cut below 20 sp, the display cut above. */
|
||||
val InterText = inter(14f)
|
||||
val InterDisplay = inter(32f)
|
||||
|
||||
/** Inter's recommended tracking for a size, like SF's size-specific tracking. */
|
||||
private fun tracking(size: Float): TextUnit = (size * (-0.0223f + 0.185f * exp(-0.1745f * size))).sp
|
||||
|
||||
private fun style(size: Float, line: Float, weight: Int) = TextStyle(
|
||||
fontFamily = if (size >= 20f) InterDisplay else InterText,
|
||||
fontSize = size.sp,
|
||||
lineHeight = line.sp,
|
||||
fontWeight = FontWeight(weight),
|
||||
letterSpacing = tracking(size),
|
||||
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
|
||||
)
|
||||
|
||||
/** The iOS text styles, in points-as-sp (a OnePlus 15 is about as wide as an iPhone Pro Max). */
|
||||
object Type {
|
||||
val largeTitle = style(34f, 41f, 700)
|
||||
val title2 = style(22f, 28f, 700)
|
||||
val title3 = style(20f, 25f, 600)
|
||||
val headline = style(17f, 22f, 600)
|
||||
val body = style(17f, 22f, 400)
|
||||
val subhead = style(15f, 20f, 400)
|
||||
val footnote = style(13f, 18f, 400)
|
||||
val caption1 = style(12f, 16f, 400)
|
||||
val caption2 = style(11f, 13f, 400)
|
||||
}
|
||||
|
||||
val LocalPalette = staticCompositionLocalOf { palette(ThemeMode.Black, Accent.Music) }
|
||||
|
||||
object Ios {
|
||||
val colors: Palette @Composable get() = LocalPalette.current
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IosTheme(mode: ThemeMode, accent: Accent, content: @Composable () -> Unit) {
|
||||
val p = remember(mode, accent) { palette(mode, accent) }
|
||||
CompositionLocalProvider(LocalPalette provides p, content = content)
|
||||
}
|
||||
|
||||
/** BasicText with iOS defaults: label colour, single line, tail ellipsis. */
|
||||
@Composable
|
||||
fun Txt(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Ios.colors.label,
|
||||
maxLines: Int = 1,
|
||||
align: TextAlign? = null,
|
||||
weight: FontWeight? = null,
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
style = style.copy(color = color, textAlign = align ?: TextAlign.Unspecified, fontWeight = weight ?: style.fontWeight),
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M3,17v2h6v-2L3,17zM3,5v2h10L13,5L3,5zM13,21v-2h8v-2h-8v-2h-2v6h2zM7,9v2L3,11v2h4v2h2L9,9L7,9zM21,13v-2L11,11v2h10zM15,9h2L17,7h4L17,5L17,3h-2v6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M19,11h-1.7c0,0.74 -0.16,1.43 -0.43,2.05l1.23,1.23c0.56,-0.98 0.9,-2.09 0.9,-3.28zM14.98,11.17c0,-0.06 0.02,-0.11 0.02,-0.17L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v0.18l5.98,5.99zM4.27,3L3,4.27l6.01,6.01L9,11c0,1.66 1.33,3 2.99,3 0.22,0 0.44,-0.03 0.65,-0.08l1.66,1.66c-0.71,0.33 -1.5,0.52 -2.31,0.52 -2.76,0 -5.3,-2.1 -5.3,-5.1L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c0.91,-0.13 1.77,-0.45 2.54,-0.9L19.73,21 21,19.73 4.27,3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#000000" />
|
||||
</shape>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FF7AB6" android:pathData="M33,46a3,3 0,0 1,6 0v16a3,3 0,0 1,-6 0z" />
|
||||
<path android:fillColor="#FFB547" android:pathData="M41,36a3,3 0,0 1,6 0v36a3,3 0,0 1,-6 0z" />
|
||||
<path android:fillColor="#7C8CFF" android:pathData="M49,42a3,3 0,0 1,6 0v24a3,3 0,0 1,-6 0z" />
|
||||
<path android:fillColor="#FF8A5C" android:pathData="M57,32a3,3 0,0 1,6 0v44a3,3 0,0 1,-6 0z" />
|
||||
<path android:fillColor="#B48CFF" android:pathData="M65,40a3,3 0,0 1,6 0v28a3,3 0,0 1,-6 0z" />
|
||||
<path android:fillColor="#4CD07D" android:pathData="M73,48a3,3 0,0 1,6 0v12a3,3 0,0 1,-6 0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M33,46a3,3 0,0 1,6 0v16a3,3 0,0 1,-6 0zM41,36a3,3 0,0 1,6 0v36a3,3 0,0 1,-6 0zM49,42a3,3 0,0 1,6 0v24a3,3 0,0 1,-6 0zM57,32a3,3 0,0 1,6 0v44a3,3 0,0 1,-6 0zM65,40a3,3 0,0 1,6 0v28a3,3 0,0 1,-6 0zM73,48a3,3 0,0 1,6 0v12a3,3 0,0 1,-6 0z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M3,10a1,1 0,0 1,2 0v4a1,1 0,0 1,-2 0zM7,7a1,1 0,0 1,2 0v10a1,1 0,0 1,-2 0zM11,9a1,1 0,0 1,2 0v6a1,1 0,0 1,-2 0zM15,5a1,1 0,0 1,2 0v14a1,1 0,0 1,-2 0zM19,9a1,1 0,0 1,2 0v6a1,1 0,0 1,-2 0z" />
|
||||
</vector>
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Referenced by name from android.resource:// URIs in the Android Auto tree, which the shrinker can't see. -->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/ic_car_*" />
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">StemRoller</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StemRoller" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowSplashScreenBackground">@android:color/black</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<!-- Android Auto takes its media accents (progress bar, focus highlights) from these. -->
|
||||
<item name="android:colorPrimary">#FF1C1C1E</item>
|
||||
<item name="android:colorPrimaryDark">#FF000000</item>
|
||||
<item name="android:colorAccent">#FFFC3C44</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Plex and stemserve live on the LAN over plain HTTP. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.stemroller.mobile
|
||||
|
||||
import com.stemroller.mobile.auto.CarIds
|
||||
import com.stemroller.mobile.auto.CarTree
|
||||
import com.stemroller.mobile.playback.MixPresets
|
||||
import com.stemroller.mobile.playback.StemSetting
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CarTreeTest {
|
||||
@Test
|
||||
fun picksRoundTrip() {
|
||||
assertEquals(CarIds.Pick.Song("album:123", "456"), CarIds.parsePick(CarIds.play("album:123", "456")))
|
||||
assertEquals(CarIds.Pick.Shuffle("playlist:9"), CarIds.parsePick(CarIds.shuffle("playlist:9")))
|
||||
// Search queries can contain the separator; the container is last so they survive.
|
||||
val q = CarIds.search("AC|DC: Back in Black")
|
||||
assertEquals(CarIds.Pick.Song(q, "77"), CarIds.parsePick(CarIds.play(q, "77")))
|
||||
assertNull(CarIds.parsePick("album:123"))
|
||||
assertNull(CarIds.parsePick("play|456"))
|
||||
assertNull(CarIds.parsePick("shuffle|"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitsContainers() {
|
||||
assertEquals("album" to "123", CarIds.split("album:123"))
|
||||
assertEquals("search" to "a:b", CarIds.split("search:a:b"))
|
||||
assertNull(CarIds.split("root"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lettersFoldAccentsAndNonLetters() {
|
||||
assertEquals("E", CarTree.letterOf("Éric Serra"))
|
||||
assertEquals("#", CarTree.letterOf("2Pac"))
|
||||
assertEquals("#", CarTree.letterOf("!!!"))
|
||||
assertEquals("B", CarTree.letterOf(" black sabbath"))
|
||||
assertEquals("#", CarTree.letterOf(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bucketsPutNumbersFirst() {
|
||||
val b = CarTree.buckets(listOf("Zappa", "ABBA", "2Pac", "AC/DC", "Björk")) { it }
|
||||
assertEquals(listOf("#", "A", "B", "Z"), b.keys.toList())
|
||||
assertEquals(listOf("ABBA", "AC/DC"), b["A"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMatchingIgnoresCaseAccentsPunctuationAndThe() {
|
||||
assertTrue(CarTree.matches("Paranoid", "paranoid"))
|
||||
assertTrue(CarTree.matches("The Beatles", "beatles"))
|
||||
assertTrue(CarTree.matches("Motörhead", "motorhead"))
|
||||
assertTrue(CarTree.matches("AC/DC", "acdc"))
|
||||
assertFalse(CarTree.matches("Paranoid", "para"))
|
||||
assertFalse(CarTree.matches("Paranoid", ""))
|
||||
}
|
||||
|
||||
private val six = listOf("vocals", "drums", "bass", "guitar", "piano", "other")
|
||||
|
||||
@Test
|
||||
fun mixCycleSkipsPresetsTheTrackCantDo() {
|
||||
val labels = generateSequence(emptyMap<String, StemSetting>()) { m -> MixPresets.next(six, m).build(six) }
|
||||
.drop(1).take(MixPresets.ALL.size).map { MixPresets.current(six, it)!!.label }.toList()
|
||||
assertEquals(listOf("Karaoke", "A Cappella", "No Drums", "Rhythm", "No Guitar", "No Bass", "Full Mix"), labels)
|
||||
|
||||
val two = listOf("vocals", "instrumental")
|
||||
assertEquals("Karaoke", MixPresets.next(two, emptyMap()).label)
|
||||
assertEquals("A Cappella", MixPresets.next(two, MixPresets.KARAOKE.build(two)!!).label)
|
||||
assertEquals("Full Mix", MixPresets.next(two, MixPresets.ALL[2].build(two)!!).label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun customMixIsRecognisedAndCyclesToFull() {
|
||||
val custom = mapOf("drums" to StemSetting(level = 0.5f))
|
||||
assertNull(MixPresets.current(six, custom))
|
||||
assertEquals("Full Mix", MixPresets.next(six, custom).label)
|
||||
// Faders left at their defaults don't make a mix custom.
|
||||
assertEquals("Full Mix", MixPresets.current(six, mapOf("bass" to StemSetting()))!!.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vocalsSilence() {
|
||||
assertFalse(MixPresets.vocalsSilent(six, emptyMap()))
|
||||
assertTrue(MixPresets.vocalsSilent(six, MixPresets.KARAOKE.build(six)!!))
|
||||
assertFalse(MixPresets.vocalsSilent(six, MixPresets.ALL[2].build(six)!!)) // A Cappella
|
||||
assertTrue(MixPresets.vocalsSilent(six, MixPresets.ALL[4].build(six)!!)) // Rhythm solos drums and bass
|
||||
assertFalse(MixPresets.vocalsSilent(listOf("drums"), emptyMap()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.stemroller.mobile
|
||||
|
||||
import com.stemroller.mobile.engine.EQ_BANDS
|
||||
import com.stemroller.mobile.engine.EqChannel
|
||||
import com.stemroller.mobile.engine.EqFilter
|
||||
import com.stemroller.mobile.engine.FilterType
|
||||
import com.stemroller.mobile.eq.ApoConfig
|
||||
import com.stemroller.mobile.eq.EqProfile
|
||||
import com.stemroller.mobile.engine.EqMath
|
||||
import com.stemroller.mobile.engine.EqParams
|
||||
import com.stemroller.mobile.engine.Equalizer
|
||||
import com.stemroller.mobile.engine.FloatRing
|
||||
import com.stemroller.mobile.engine.Limiter
|
||||
import com.stemroller.mobile.engine.stemTaper
|
||||
import com.stemroller.mobile.engine.stemUntaper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
|
||||
class DspTest {
|
||||
private fun gains(vararg pairs: Pair<Int, Float>) = EqFilter.graphic(List(EQ_BANDS.size) { i -> pairs.firstOrNull { it.first == i }?.second ?: 0f })
|
||||
|
||||
@Test fun flatOrCutsNeedNoPreamp() {
|
||||
assertEquals(0f, EqMath.autoPreampDb(gains()), 1e-4f)
|
||||
assertEquals(0f, EqMath.autoPreampDb(gains(3 to -6f, 7 to -12f)), 1e-4f)
|
||||
}
|
||||
|
||||
@Test fun singleBoostIsCancelledExactly() {
|
||||
assertEquals(-6f, EqMath.autoPreampDb(gains(5 to 6f)), 0.1f)
|
||||
assertEquals(-12f, EqMath.autoPreampDb(gains(9 to 12f)), 0.15f)
|
||||
}
|
||||
|
||||
@Test fun overlappingBoostsAddUp() {
|
||||
val p = EqMath.autoPreampDb(gains(1 to 6f, 2 to 6f))
|
||||
assertTrue("got $p", p < -6.5f)
|
||||
}
|
||||
|
||||
@Test fun manualPreampCountsTowardsAntiClip() {
|
||||
assertEquals(0f, EqMath.autoPreampDb(gains(5 to 6f), manualDb = -6f), 0.1f)
|
||||
assertEquals(-3f, EqMath.autoPreampDb(gains(5 to 6f), manualDb = -3f), 0.1f)
|
||||
}
|
||||
|
||||
/** A full-scale sine at the curve's peak must not clip once the auto preamp is applied. */
|
||||
@Test fun preampPreventsClippingAtThePeak() {
|
||||
val g = gains(0 to 9f, 1 to 9f, 2 to 6f, 8 to 4f)
|
||||
val pre = EqMath.autoPreampDb(g)
|
||||
val fs = 48000
|
||||
val freqs = EqMath.logFreqs(256)
|
||||
val resp = EqMath.response(g, freqs)
|
||||
val peakF = freqs[resp.indices.maxBy { resp[it] }]
|
||||
val eq = Equalizer().apply { configure(EqParams(g, pre), fs) }
|
||||
val n = fs * 2
|
||||
val buf = FloatArray(n * 2) { i -> sin(2 * PI * peakF * (i / 2) / fs).toFloat() }
|
||||
eq.process(buf, n)
|
||||
val settled = buf.copyOfRange(n, n * 2).maxOf { abs(it) }
|
||||
assertTrue("peak $settled at ${peakF.toInt()} Hz", settled <= 1.01f)
|
||||
assertTrue("too quiet: $settled", settled > 0.95f)
|
||||
}
|
||||
|
||||
private fun at(f: EqFilter, hz: Double) = EqMath.response(listOf(f), doubleArrayOf(hz))[0]
|
||||
|
||||
@Test fun shelvesReachTheirGain() {
|
||||
val ls = EqFilter("a", FilterType.LowShelf, 100f, 6f, 0.707f)
|
||||
assertEquals(6.0, at(ls, 20.0), 0.3)
|
||||
assertEquals(3.0, at(ls, 100.0), 0.3)
|
||||
assertEquals(0.0, at(ls, 5000.0), 0.1)
|
||||
val hs = EqFilter("b", FilterType.HighShelf, 5000f, -4f, 0.707f)
|
||||
assertEquals(-4.0, at(hs, 19000.0), 0.4)
|
||||
assertEquals(0.0, at(hs, 100.0), 0.1)
|
||||
}
|
||||
|
||||
@Test fun passSlopesFallAtTheRightRate() {
|
||||
for (slope in listOf(6, 12, 24, 36, 48)) {
|
||||
val lp = EqFilter("lp", FilterType.LowPass, 1000f, slope = slope, q = 0.707f)
|
||||
// Two octaves above the corner, a Butterworth is down ~slope*2 dB, and -3 dB at the corner.
|
||||
val drop = at(lp, 4000.0) - at(lp, 8000.0)
|
||||
assertEquals("slope $slope", slope.toDouble(), drop, slope * 0.12 + 0.6)
|
||||
assertEquals("corner $slope", -3.0, at(lp, 1000.0), 0.3)
|
||||
val hp = EqFilter("hp", FilterType.HighPass, 1000f, slope = slope, q = 0.707f)
|
||||
assertEquals("hp slope $slope", slope.toDouble(), at(hp, 250.0) - at(hp, 125.0), slope * 0.12 + 0.6)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun notchAndAllPass() {
|
||||
assertTrue(at(EqFilter("n", FilterType.Notch, 60f, q = 4f), 60.0) < -40)
|
||||
assertEquals(0.0, at(EqFilter("n", FilterType.Notch, 60f, q = 4f), 1000.0), 0.1)
|
||||
for (hz in listOf(50.0, 1000.0, 15000.0)) assertEquals(0.0, at(EqFilter("ap", FilterType.AllPass, 1000f), hz), 1e-6)
|
||||
assertEquals(0.0, at(EqFilter("bp", FilterType.BandPass, 1000f, q = 0.707f), 1000.0), 0.05)
|
||||
}
|
||||
|
||||
@Test fun channelFiltersOnlyTouchTheirSide() {
|
||||
val f = EqFilter("l", FilterType.Peak, 1000f, 6f, 1.41f, channel = EqChannel.Left)
|
||||
val eq = Equalizer().apply { configure(EqParams(listOf(f), 0f), 48000) }
|
||||
val n = 48000
|
||||
val buf = FloatArray(n * 2) { i -> (0.25 * sin(2 * PI * 1000 * (i / 2) / 48000.0)).toFloat() }
|
||||
eq.process(buf, n)
|
||||
val l = (n / 2 until n).maxOf { abs(buf[it * 2]) }
|
||||
val r = (n / 2 until n).maxOf { abs(buf[it * 2 + 1]) }
|
||||
assertEquals(0.5, l.toDouble(), 0.02)
|
||||
assertEquals(0.25, r.toDouble(), 0.001)
|
||||
}
|
||||
|
||||
@Test fun parsesAutoEqConfig() {
|
||||
val text = """
|
||||
Preamp: -6.4 dB
|
||||
Filter 1: ON LSC Fc 105 Hz Gain 6.0 dB Q 0.70
|
||||
Filter 2: ON PK Fc 2300 Hz Gain -3.1 dB Q 1.41
|
||||
Filter 3: OFF HSC Fc 10000 Hz Gain 2 dB Q 0.7
|
||||
Channel: R
|
||||
Filter 4: ON PK Fc 500 Hz Gain 1 dB BW Oct 1
|
||||
GraphicEQ: 20 -1; 30 -2
|
||||
""".trimIndent()
|
||||
val r = ApoConfig.parse(text)!!
|
||||
assertEquals(-6.4f, r.preamp!!, 1e-4f)
|
||||
assertEquals(4, r.filters.size)
|
||||
assertEquals(1, r.skipped)
|
||||
assertEquals(FilterType.LowShelf, r.filters[0].type)
|
||||
assertEquals(105f, r.filters[0].freq, 1e-4f)
|
||||
assertEquals(-3.1f, r.filters[1].gain, 1e-4f)
|
||||
assertEquals(false, r.filters[2].on)
|
||||
assertEquals(EqChannel.Right, r.filters[3].channel)
|
||||
assertEquals(1.414f, r.filters[3].q, 0.01f)
|
||||
assertEquals(null, ApoConfig.parse("hello"))
|
||||
}
|
||||
|
||||
@Test fun apoExportRoundTrips() {
|
||||
val p = EqProfile("x", "Test", List(EQ_BANDS.size) { if (it == 2) 3f else 0f }, preamp = -2f, filters = listOf(
|
||||
EqFilter("a", FilterType.HighShelf, 8000f, -2.5f, 0.71f, channel = EqChannel.Left),
|
||||
EqFilter("b", FilterType.HighPass, 30f, slope = 24),
|
||||
))
|
||||
val back = ApoConfig.parse(ApoConfig.format(p))!!
|
||||
assertEquals(-2f, back.preamp!!, 1e-4f)
|
||||
val freqs = EqMath.logFreqs(64)
|
||||
val want = EqMath.response(p.chain, freqs, true)
|
||||
val got = EqMath.response(back.filters, freqs, true)
|
||||
for (i in freqs.indices) assertEquals(want[i], got[i], 0.05)
|
||||
}
|
||||
|
||||
/** eq.json written before parametric filters existed must still load, or profiles are lost. */
|
||||
@Test fun oldEqSettingsStillLoad() {
|
||||
val old = """{"enabled":true,"automatic":false,"manualId":"abc","profiles":[{"id":"abc","name":"XM5",
|
||||
"gains":[1,2,3,0,0,0,0,0,-1,-2],"devices":[{"type":8,"name":"WH-1000XM5"}]}]}"""
|
||||
val s = com.stemroller.mobile.core.AppJson.decodeFromString(com.stemroller.mobile.eq.EqState.serializer(), old)
|
||||
val p = s.profiles.single()
|
||||
assertEquals("XM5", p.name)
|
||||
assertEquals(0, p.filters.size)
|
||||
assertEquals(0f, p.preamp)
|
||||
assertEquals(5, p.chain.size)
|
||||
}
|
||||
|
||||
@Test fun limiterHoldsTheCeiling() {
|
||||
val lim = Limiter().apply { setSampleRate(48000) }
|
||||
val n = 48000
|
||||
val loud = FloatArray(n * 2) { i -> (2.5 * sin(2 * PI * 220 * (i / 2) / 48000.0)).toFloat() }
|
||||
lim.process(loud, n)
|
||||
assertTrue(loud.maxOf { abs(it) } <= 0.9851f)
|
||||
val lim2 = Limiter().apply { setSampleRate(48000) }
|
||||
val quiet = FloatArray(n * 2) { i -> (0.5 * sin(2 * PI * 220 * (i / 2) / 48000.0)).toFloat() }
|
||||
val copy = quiet.copyOf()
|
||||
lim2.process(quiet, n)
|
||||
assertTrue(quiet.indices.all { abs(quiet[it] - copy[it]) < 1e-6f })
|
||||
}
|
||||
|
||||
@Test fun stemTaperHasUnityAtTheDetent() {
|
||||
assertEquals(1f, stemTaper(0.8f), 1e-6f)
|
||||
assertEquals(0f, stemTaper(0f), 1e-6f)
|
||||
assertEquals(0.8f, stemUntaper(1f), 1e-6f)
|
||||
}
|
||||
|
||||
@Test fun ringWrapsAround() {
|
||||
val ring = FloatRing(100)
|
||||
val src = FloatArray(140) { it.toFloat() }
|
||||
val dst = FloatArray(200)
|
||||
var next = 0f
|
||||
repeat(20) {
|
||||
assertTrue(ring.write(src, 0, 140) { false })
|
||||
val got = ring.read(dst, 70)
|
||||
assertEquals(70, got)
|
||||
for (i in 0 until got * 2) {
|
||||
assertEquals(next, dst[i])
|
||||
next = (next + 1) % 140
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.stemroller.mobile
|
||||
|
||||
import com.stemroller.mobile.cache.MediaCache
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.nio.file.Files
|
||||
import java.util.Random
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Streams a real stem from a locally running stemserve (server/stemserve.py) through the
|
||||
* block cache and checks every byte against the file on the share.
|
||||
*/
|
||||
class MediaCacheTest {
|
||||
private val stems = File("/var/mnt/unraid/Music/Stems")
|
||||
private lateinit var dir: File
|
||||
private lateinit var src: File
|
||||
private lateinit var url: String
|
||||
private val http = OkHttpClient.Builder().readTimeout(60, TimeUnit.SECONDS).build()
|
||||
|
||||
@Before fun setUp() {
|
||||
val up = runCatching { URI("http://localhost:8765/api/info").toURL().readText() }.isSuccess
|
||||
assumeTrue("stemserve not running on :8765", up && stems.isDirectory)
|
||||
src = stems.walkTopDown().filter { it.name.endsWith(" - vocals.flac") }.minBy { it.length() }
|
||||
val rel = src.relativeTo(stems).invariantSeparatorsPath.split('/')
|
||||
.joinToString("/") { URLEncoder.encode(it, "UTF-8").replace("+", "%20") }
|
||||
url = "http://localhost:8765/file/$rel"
|
||||
dir = Files.createTempDirectory("mediacache").toFile()
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
if (::dir.isInitialized) dir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun expected(pos: Long, len: Int): ByteArray = RandomAccessFile(src, "r").use { f ->
|
||||
f.seek(pos)
|
||||
ByteArray(len).also { f.readFully(it) }
|
||||
}
|
||||
|
||||
@Test fun randomReadsMatchTheFile() {
|
||||
val cache = MediaCache(dir, http) { Long.MAX_VALUE }
|
||||
val res = cache.acquire("test|vocals") { url }
|
||||
assertEquals(src.length(), res.size())
|
||||
val rnd = Random(42)
|
||||
val pool = Executors.newFixedThreadPool(4)
|
||||
val jobs = (0 until 64).map {
|
||||
val len = 1 + rnd.nextInt(300_000)
|
||||
val pos = (rnd.nextDouble() * (src.length() - len)).toLong()
|
||||
pool.submit<Unit> {
|
||||
val buf = ByteArray(len)
|
||||
var got = 0
|
||||
while (got < len) {
|
||||
val n = res.readAt(pos + got, buf, got, len - got)
|
||||
check(n > 0)
|
||||
got += n
|
||||
}
|
||||
assertArrayEquals("at $pos+$len", expected(pos, len), buf)
|
||||
}
|
||||
}
|
||||
jobs.forEach { it.get(60, TimeUnit.SECONDS) }
|
||||
pool.shutdown()
|
||||
// Past EOF
|
||||
assertEquals(-1, res.readAt(src.length(), ByteArray(10), 0, 10))
|
||||
cache.release(res)
|
||||
}
|
||||
|
||||
@Test fun downloadPersistsAndSurvivesReopen() {
|
||||
val cache = MediaCache(dir, http) { Long.MAX_VALUE }
|
||||
val res = cache.acquire("dl|vocals") { url }
|
||||
res.pinned = true
|
||||
res.downloadAll({ _, _ -> }, { false })
|
||||
assertTrue(res.isComplete)
|
||||
cache.release(res)
|
||||
assertTrue(cache.isComplete("dl|vocals"))
|
||||
|
||||
// A fresh cache over the same directory serves it without any network.
|
||||
val offline = MediaCache(dir, http) { Long.MAX_VALUE }
|
||||
val again = offline.acquire("dl|vocals") { "http://127.0.0.1:9/unreachable" }
|
||||
val buf = ByteArray(4096)
|
||||
val tail = src.length() - 4096
|
||||
assertEquals(4096, again.readAt(tail, buf, 0, 4096))
|
||||
assertArrayEquals(expected(tail, 4096), buf)
|
||||
offline.release(again)
|
||||
}
|
||||
|
||||
@Test fun evictionSparesPinnedAndInUse() {
|
||||
val cache = MediaCache(dir, http) { 1L }
|
||||
val pinned = cache.acquire("pinned") { url }
|
||||
pinned.pinned = true
|
||||
pinned.downloadAll({ _, _ -> }, { false })
|
||||
cache.release(pinned)
|
||||
val loose = cache.acquire("loose") { url }
|
||||
loose.downloadAll({ _, _ -> }, { false })
|
||||
cache.release(loose)
|
||||
// Trimming runs on the cache's own thread; give it a moment.
|
||||
val deadline = System.currentTimeMillis() + 5000
|
||||
while (cache.isComplete("loose") && System.currentTimeMillis() < deadline) Thread.sleep(50)
|
||||
assertFalse(cache.isComplete("loose"))
|
||||
assertTrue(cache.isComplete("pinned"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.stemroller.mobile
|
||||
|
||||
import com.stemroller.mobile.core.AppJson
|
||||
import com.stemroller.mobile.stems.StemEntry
|
||||
import com.stemroller.mobile.stems.StemFile
|
||||
import com.stemroller.mobile.stems.StemIndex
|
||||
import com.stemroller.mobile.stems.StemIndexFile
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
class StemIndexTest {
|
||||
private fun entry(source: String, seconds: Double = 200.0) =
|
||||
StemEntry(key = source.substringBeforeLast('.'), source = source, duration = seconds, stems = listOf(StemFile("vocals", "v.flac", 1)))
|
||||
|
||||
private val entries = listOf(
|
||||
entry("Artist/Album/01 Song.flac"),
|
||||
entry("Intro.flac", 60.0),
|
||||
entry("Other/Album2/Intro.flac", 90.0),
|
||||
entry("Beyoncé/Lemonade/Hold Up.flac"), // decomposed é, as some filesystems store it
|
||||
)
|
||||
|
||||
@Test fun matchesThroughUnknownContainerRoot() {
|
||||
val idx = StemIndex(entries)
|
||||
val hit = idx.find("/music/Actual music/Flacs/Artist/Album/01 Song.flac", 200_000)
|
||||
assertEquals("Artist/Album/01 Song", hit?.key)
|
||||
assertEquals(setOf("/music/actual music/flacs/"), idx.roots)
|
||||
}
|
||||
|
||||
@Test fun rootLevelFileNeedsALearnedRoot() {
|
||||
val idx = StemIndex(entries)
|
||||
assertNull(idx.find("/music/Flacs/Intro.flac", 60_000))
|
||||
idx.find("/music/Flacs/Artist/Album/01 Song.flac", 200_000)
|
||||
assertEquals("Intro", idx.find("/music/Flacs/Intro.flac", 60_000)?.key)
|
||||
}
|
||||
|
||||
@Test fun sameNameInAnotherFolderDoesNotCollide() {
|
||||
val idx = StemIndex(entries, listOf("/music/flacs/"))
|
||||
assertEquals("Other/Album2/Intro", idx.find("/music/Flacs/Other/Album2/Intro.flac", 90_000)?.key)
|
||||
assertNull(idx.find("/music/Flacs/Elsewhere/Intro.flac", 90_000))
|
||||
}
|
||||
|
||||
@Test fun otherFormatOfSameTrackMatches() {
|
||||
val idx = StemIndex(entries)
|
||||
assertEquals("Artist/Album/01 Song", idx.find("/data/Artist/Album/01 Song.mp3", 200_500)?.key)
|
||||
}
|
||||
|
||||
@Test fun durationMismatchRejectsAGuess() {
|
||||
val idx = StemIndex(entries)
|
||||
assertNull(idx.find("/data/Artist/Album/01 Song.flac", 120_000))
|
||||
}
|
||||
|
||||
@Test fun unicodeNormalisationAndCase() {
|
||||
val idx = StemIndex(entries)
|
||||
assertEquals("Beyoncé/Lemonade/Hold Up", idx.find("/MUSIC/Beyoncé/Lemonade/hold up.flac", 200_000)?.key)
|
||||
}
|
||||
|
||||
/** Every entry the real stemserve reports must map back from a Plex-style path. */
|
||||
@Test fun realIndexRoundTrips() {
|
||||
val body = runCatching { URI("http://localhost:8765/api/index").toURL().readText() }.getOrNull()
|
||||
assumeTrue("stemserve not running locally", body != null)
|
||||
val index = AppJson.decodeFromString(StemIndexFile.serializer(), body!!)
|
||||
val idx = StemIndex(index.tracks)
|
||||
val deep = index.tracks.filter { it.source.count { c -> c == '/' } >= 1 }
|
||||
for (e in deep + index.tracks) {
|
||||
val ms = ((e.duration ?: 0.0) * 1000).toLong()
|
||||
assertEquals(e.source, e.key, idx.find("/data/music/Actual music/Flacs/${e.source}", ms)?.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,42 @@
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
kotlin = "2.2.21"
|
||||
composeBom = "2025.10.01"
|
||||
activity = "1.11.0"
|
||||
coreKtx = "1.17.0"
|
||||
lifecycle = "2.9.4"
|
||||
media3 = "1.8.0"
|
||||
okhttp = "4.12.0"
|
||||
coil = "3.3.0"
|
||||
serialization = "1.9.0"
|
||||
coroutines = "1.10.2"
|
||||
palette = "1.0.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" }
|
||||
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,23 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StemRoller"
|
||||
include(":app")
|
||||
@@ -0,0 +1,47 @@
|
||||
[project]
|
||||
name = "stemroller"
|
||||
version = "0.1.0"
|
||||
description = "Drag-drop stem separation (Demucs + RoFormer) with a 240Hz Material x Aero UI"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
dependencies = [
|
||||
"PySide6>=6.10",
|
||||
"numpy>=2.0",
|
||||
"soundfile>=0.13",
|
||||
"sounddevice>=0.5",
|
||||
"torch==2.14.1",
|
||||
"triton-rocm",
|
||||
"torchvision",
|
||||
"demucs @ git+https://github.com/adefossez/demucs",
|
||||
"audio-separator[cpu]>=0.30",
|
||||
"audioread",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
stemroller = "stemroller.app:main"
|
||||
stemroller-worker-cli = "stemroller.worker.cli:main"
|
||||
stemroller-player = "stemroller.player.app:main"
|
||||
|
||||
[tool.uv]
|
||||
environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"]
|
||||
# torchaudio has no build matching torch 2.14; nothing here needs it.
|
||||
override-dependencies = ["torchaudio ; sys_platform == 'never'"]
|
||||
|
||||
[tool.uv.sources]
|
||||
torch = { index = "pytorch-rocm" }
|
||||
triton-rocm = { index = "pytorch-rocm" }
|
||||
torchvision = { index = "pytorch-rocm" }
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-rocm"
|
||||
url = "https://download.pytorch.org/whl/rocm7.2"
|
||||
explicit = true
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["stemroller"]
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Add StemRoller and StemRoller Player to the app grid (and "Open with" for audio files).
|
||||
# Re-run after moving the project; `--uninstall` removes all of it.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps"
|
||||
DESKTOP="$APPS/stemroller.desktop"
|
||||
ICON="$ICONS/stemroller.svg"
|
||||
PLAYER_DESKTOP="$APPS/stemroller-player.desktop"
|
||||
PLAYER_ICON="$ICONS/stemroller-player.svg"
|
||||
# stem-on / stem-off from older installs; library separation now runs on the server (server/stemd.py).
|
||||
OLD_BIN="$HOME/.local/bin"
|
||||
|
||||
if [[ "${1:-}" == "--uninstall" ]]; then
|
||||
rm -f "$DESKTOP" "$ICON" "$PLAYER_DESKTOP" "$PLAYER_ICON" "$OLD_BIN/stem-on" "$OLD_BIN/stem-off"
|
||||
echo "removed $DESKTOP, $PLAYER_DESKTOP and their icons"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for bin in stemroller stemroller-player; do
|
||||
if [[ ! -x "$ROOT/.venv/bin/$bin" ]]; then
|
||||
echo "missing $ROOT/.venv/bin/$bin; run 'uv sync' in $ROOT first" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$APPS" "$ICONS"
|
||||
install -m 644 "$ROOT/stemroller/assets/stemroller.svg" "$ICON"
|
||||
install -m 644 "$ROOT/stemroller/player/assets/stemroller-player.svg" "$PLAYER_ICON"
|
||||
|
||||
# StartupWMClass / file name match QGuiApplication.setDesktopFileName("stemroller"),
|
||||
# so Wayland groups the window under this entry and shows its icon.
|
||||
cat > "$DESKTOP" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=StemRoller
|
||||
GenericName=Stem Separator
|
||||
Comment=Split songs into vocals, drums, bass and more on the GPU
|
||||
Exec="$ROOT/.venv/bin/stemroller" %F
|
||||
Path=$ROOT
|
||||
Icon=stemroller
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Audio;
|
||||
MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/wav;audio/x-wav;audio/ogg;audio/opus;audio/mp4;audio/aac;audio/x-aiff;audio/x-m4a;
|
||||
Keywords=stems;demucs;roformer;vocals;karaoke;separate;
|
||||
StartupWMClass=stemroller
|
||||
StartupNotify=true
|
||||
EOF
|
||||
chmod 644 "$DESKTOP"
|
||||
|
||||
# StartupWMClass matches QGuiApplication.setDesktopFileName("stemroller-player"); the
|
||||
# MPRIS DesktopEntry points here too, so media controls show this icon.
|
||||
cat > "$PLAYER_DESKTOP" <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=StemRoller Player
|
||||
GenericName=Music Player
|
||||
Comment=Your Plex library and its stems, in a spreadsheet
|
||||
Exec="$ROOT/.venv/bin/stemroller-player"
|
||||
Path=$ROOT
|
||||
Icon=stemroller-player
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Audio;Player;
|
||||
Keywords=music;plex;stems;karaoke;player;excel;
|
||||
StartupWMClass=stemroller-player
|
||||
StartupNotify=true
|
||||
EOF
|
||||
chmod 644 "$PLAYER_DESKTOP"
|
||||
|
||||
command -v update-desktop-database >/dev/null && update-desktop-database -q "$APPS" || true
|
||||
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q -t "${ICONS%/scalable/apps}" || true
|
||||
command -v desktop-file-validate >/dev/null && desktop-file-validate "$DESKTOP" "$PLAYER_DESKTOP" || true
|
||||
rm -f "$OLD_BIN/stem-on" "$OLD_BIN/stem-off"
|
||||
|
||||
echo "installed $DESKTOP and $PLAYER_DESKTOP"
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-alpine
|
||||
RUN apk add --no-cache ffmpeg
|
||||
COPY stemserve.py /app/stemserve.py
|
||||
ENV STEMS_DIR=/stems CACHE_DIR=/cache PORT=8765 PYTHONUNBUFFERED=1
|
||||
EXPOSE 8765
|
||||
# Unraid shares belong to nobody:users.
|
||||
USER 99:100
|
||||
CMD ["python", "/app/stemserve.py"]
|
||||
@@ -0,0 +1,126 @@
|
||||
# stemserve
|
||||
|
||||
Serves the StemRoller stems folder to the Android app: an index of every separated
|
||||
song, the stem files with HTTP range support (so the phone can seek and stream), per-stem
|
||||
waveforms, and optional Opus transcodes so cached songs take ~1/8 the space.
|
||||
|
||||
One Python file, standard library only. `ffmpeg` is optional; without it you get FLAC
|
||||
only and no waveforms.
|
||||
|
||||
## Run it on unraid
|
||||
|
||||
The stems live at `/mnt/user/Music/Stems` on the server (`//192.168.86.2/Music/Stems`
|
||||
from the desktop). From an unraid terminal:
|
||||
|
||||
```sh
|
||||
mkdir -p /mnt/user/appdata/stemserve/cache /mnt/user/appdata/stemd/state/queue
|
||||
chown -R 99:100 /mnt/user/appdata/stemserve /mnt/user/appdata/stemd
|
||||
cp -r /path/to/stemRoller2/server /mnt/user/appdata/stemserve/src # or copy over SMB
|
||||
docker build -t stemserve /mnt/user/appdata/stemserve/src
|
||||
docker run -d --name stemserve --restart unless-stopped -p 8765:8765 \
|
||||
-v /mnt/user/Music/Stems:/stems:ro \
|
||||
-v /mnt/user/appdata/stemserve/cache:/cache \
|
||||
-v /mnt/user/appdata/stemd/state/queue:/queue \
|
||||
stemserve
|
||||
```
|
||||
|
||||
or `docker compose up -d --build` in this folder (Compose Manager plugin).
|
||||
|
||||
Check it from any machine on the LAN:
|
||||
|
||||
```sh
|
||||
curl http://192.168.86.2:8765/api/info
|
||||
```
|
||||
|
||||
## Settings (environment)
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|-------------|----------|------------------------------------------------------------|
|
||||
| `STEMS_DIR` | `/stems` | The StemRoller library output folder |
|
||||
| `CACHE_DIR` | `/cache` | Opus transcodes and waveforms (safe to delete) |
|
||||
| `PORT` | `8765` | Listen port |
|
||||
| `TOKEN` | empty | If set, requests need `X-Stem-Token` or `?token=` |
|
||||
| `INDEX_TTL` | `30` | Seconds between rescans of the stems folder |
|
||||
| `QUEUE_DIR` | `/queue` | stemd's Stem Now folder (`stemd/state/queue` on unraid) |
|
||||
|
||||
## API
|
||||
|
||||
| Request | Returns |
|
||||
|---------------------------------------------------|-------------------------------------------------|
|
||||
| `GET /api/info` | track count, whether ffmpeg is available |
|
||||
| `GET /api/index` | every finished song (ETag; 304 when unchanged) |
|
||||
| `GET /api/peaks?key=K&n=256` | RMS per stem in `n` buckets, 0..1000 |
|
||||
| `POST /api/prepare?key=K&fmt=opus&br=256` | starts the Opus transcodes in the background |
|
||||
| `POST /api/stem` `{"paths": [plex file paths]}` | Stem Now: stemd separates these next |
|
||||
| `GET /file/K/<file>.flac[?fmt=opus&br=256]` | the stem, with `Range` support |
|
||||
|
||||
`K` is the song's folder under the stems root, e.g. `1970 - Paranoid/1-02 - Paranoid`.
|
||||
The app matches Plex tracks to it by path: the stems folder mirrors the music folder.
|
||||
|
||||
## Run it anywhere else
|
||||
|
||||
```sh
|
||||
STEMS_DIR=/var/mnt/unraid/Music/Stems CACHE_DIR=~/.cache/stemserve python3 stemserve.py
|
||||
```
|
||||
|
||||
# stemd: separate new music on the server's GPU
|
||||
|
||||
`stemd.py` keeps the stems library up to date. It walks the music folder, separates every
|
||||
track whose stems folder has no `.stemroller.json` yet (BS-RoFormer SW 6-stem by default,
|
||||
like the desktop app), and writes them in the layout above, so stemserve and the phone pick
|
||||
them up within `INDEX_TTL`. Once it has caught up it checks for new music every 30 minutes.
|
||||
Nothing runs on the desktop.
|
||||
|
||||
It writes the same layout the desktop app's old library mode did (`stemroller/library_core.py`),
|
||||
so the ~300 tracks already done on the desktop are skipped, and a track interrupted
|
||||
mid-way (`docker stop`, a reboot) is redone from scratch next time.
|
||||
|
||||
## Run it on unraid (NVIDIA)
|
||||
|
||||
Needs the **Nvidia-Driver** plugin (Apps tab). The image builds from the repo root
|
||||
because it uses the `stemroller` package. Copy the repo, or at least `stemroller/`,
|
||||
`server/` and `.dockerignore`, to `/mnt/user/appdata/stemd/src`, then from an unraid terminal:
|
||||
|
||||
```sh
|
||||
mkdir -p /mnt/user/appdata/stemd/models /mnt/user/appdata/stemd/state
|
||||
chown -R 99:100 /mnt/user/appdata/stemd
|
||||
docker build -f /mnt/user/appdata/stemd/src/server/stemd.Dockerfile -t stemd /mnt/user/appdata/stemd/src
|
||||
docker run -d --name stemd --restart unless-stopped --stop-timeout 60 \
|
||||
--runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility \
|
||||
-v "/mnt/user/Music/Actual music/Flacs":/music:ro \
|
||||
-v /mnt/user/Music/Stems:/stems \
|
||||
-v /mnt/user/appdata/stemd/models:/models \
|
||||
-v /mnt/user/appdata/stemd/state:/state \
|
||||
stemd
|
||||
docker logs -f stemd
|
||||
```
|
||||
|
||||
The first track downloads the model (~700 MB) into `models/`. Each scan logs how many
|
||||
tracks are left, and each track logs its name, how long it took and how many times
|
||||
realtime that was.
|
||||
|
||||
Separation runs one track at a time at low CPU priority, and the model is unloaded between
|
||||
passes so the VRAM is free while idle. It can share the card with Plex transcoding; if
|
||||
VRAM runs out mid-track, that track is retried on the next pass rather than marked failed.
|
||||
Tracks that fail (corrupt files, over 40 minutes) are listed in `state/failed.json` and
|
||||
retried when the file changes; delete that file to retry them all.
|
||||
|
||||
**Stem Now** (hold a song, album, artist or playlist in the app) jumps the queue: stemserve
|
||||
writes the request into `state/queue/`, and stemd separates those tracks next, after the
|
||||
one it's on (or within 5 seconds if it's idle). Requests run oldest first, and survive a
|
||||
restart. stemd matches the Plex paths to files under `/music` by their artist/album/file
|
||||
tail, so the two containers don't need the same mount points. Tracks in `failed.json` are
|
||||
skipped here too.
|
||||
|
||||
To update after changing the code: copy the files over again, rebuild, then
|
||||
`docker rm -f stemd` and repeat the `docker run`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|--------------|------------------|------------------------------------------------------|
|
||||
| `MODEL` | `roformer_bs_sw` | any id from `stemroller/catalog.py` |
|
||||
| `DEVICE` | `cuda` | `cuda`, `cuda:N` or `cpu` |
|
||||
| `RESCAN_MIN` | `30` | minutes between scans once caught up |
|
||||
| `FP16` | `1` | half precision on the GPU (`0` for fp32) |
|
||||
| `OVERLAP`, `SEGMENT`, `SHIFTS` | `0.25`, `0`, `0` | as in the desktop app's settings |
|
||||
|
||||
`docker build --build-arg TORCH_INDEX=cpu ...` makes a small image without CUDA for testing.
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
stemserve:
|
||||
build: .
|
||||
image: stemserve:latest
|
||||
container_name: stemserve
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- /mnt/user/Music/Stems:/stems:ro
|
||||
- /mnt/user/appdata/stemserve/cache:/cache
|
||||
# stemd's Stem Now folder (create it owned by 99:100 first; see README).
|
||||
- /mnt/user/appdata/stemd/state/queue:/queue
|
||||
environment:
|
||||
# Optional shared secret; set the same token in the app (Settings > Stem Server).
|
||||
- TOKEN=
|
||||
@@ -0,0 +1,73 @@
|
||||
# stemd's Python deps, pinned to match uv.lock (torch and torchvision come from the CUDA index; see stemd.Dockerfile).
|
||||
# Regenerate: uv export --frozen --no-hashes --no-emit-project --no-header --no-annotate \
|
||||
# | grep -vE '^(torch|torchvision|triton-rocm|pyside6|pyside6-addons|pyside6-essentials|shiboken6|sounddevice)[=@ ]' | sed 's/ ;.*//' \
|
||||
# | sed 's#^demucs @ git+\(.*\)@\(.*\)#demucs @ \1/archive/\2.tar.gz#' (no git in the image)
|
||||
absl-py==2.5.0
|
||||
anyio==4.15.1
|
||||
audio-separator==0.47.0
|
||||
audioread==3.1.0
|
||||
beartype==0.18.5
|
||||
certifi==2026.7.22
|
||||
cffi==2.1.1
|
||||
charset-normalizer==3.5.2
|
||||
click==8.5.0
|
||||
cloudpickle==3.1.2
|
||||
cython==3.3.0
|
||||
decorator==5.3.1
|
||||
demucs @ https://github.com/adefossez/demucs/archive/2883f3db65617d6d178c6ed10d869dc14e44e59b.tar.gz
|
||||
diffq==0.2.4
|
||||
einops==0.8.2
|
||||
filelock==4.0.12
|
||||
flatbuffers==25.12.19
|
||||
fsspec==2026.9.0
|
||||
h11==0.16.0
|
||||
hf-xet==1.7.0
|
||||
httpcore2==2.13.1
|
||||
httpx2==2.13.1
|
||||
huggingface-hub==2.1.1
|
||||
idna==3.20
|
||||
jinja2==3.1.6
|
||||
joblib==1.6.0
|
||||
julius==0.2.8
|
||||
lameenc==1.8.4
|
||||
lazy-loader==0.6
|
||||
librosa==1.0.0
|
||||
llvmlite==0.50.0
|
||||
markupsafe==3.0.4
|
||||
ml-collections==1.1.0
|
||||
ml-dtypes==0.6.0
|
||||
mpmath==1.3.0
|
||||
msgpack==1.2.3
|
||||
narwhals==2.26.0
|
||||
networkx==3.7
|
||||
numba==0.68.0
|
||||
numpy==2.5.3
|
||||
onnx-weekly==1.24.0.dev20261005
|
||||
onnx2torch-py313==1.6.0
|
||||
onnxruntime==1.30.0
|
||||
packaging==26.3
|
||||
pillow==12.3.0
|
||||
platformdirs==4.12.4
|
||||
pooch==1.9.0
|
||||
protobuf==7.36.2
|
||||
pycparser==3.0
|
||||
pydub==0.25.1
|
||||
pyyaml==6.0.3
|
||||
requests==2.34.2
|
||||
resampy==0.4.3
|
||||
rotary-embedding-torch==0.6.5
|
||||
safetensors==0.8.0
|
||||
samplerate==0.1.0
|
||||
scikit-learn==1.9.1
|
||||
scipy==1.18.1
|
||||
setuptools==84.0.0
|
||||
six==1.17.0
|
||||
soundfile==0.14.0
|
||||
soxr==1.1.0
|
||||
sphn==0.2.1
|
||||
sympy==1.14.0
|
||||
threadpoolctl==3.7.0
|
||||
tqdm==4.70.1
|
||||
truststore==0.10.4
|
||||
typing-extensions==4.16.0
|
||||
urllib3==2.8.0
|
||||
@@ -0,0 +1,24 @@
|
||||
# stemd: separates new music into stems on the server's NVIDIA GPU (see stemd.py).
|
||||
# Build from the repo root, since it needs the stemroller package:
|
||||
# docker build -f server/stemd.Dockerfile -t stemd .
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
# cu126 runs on NVIDIA driver 525 and up. TORCH_INDEX=cpu builds a small GPU-less image for testing.
|
||||
ARG TORCH_INDEX=cu126
|
||||
RUN pip install --no-cache-dir torch==2.14.1 torchvision==0.29.1 --index-url https://download.pytorch.org/whl/${TORCH_INDEX}
|
||||
COPY server/stemd-requirements.txt /tmp/
|
||||
# Everything is pinned, so --no-deps keeps pip from swapping torch for a non-CUDA build.
|
||||
# gcc is only for diffq, which has no wheel.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
|
||||
&& pip install --no-cache-dir --no-deps -r /tmp/stemd-requirements.txt && pip check \
|
||||
&& apt-get purge -y gcc libc6-dev && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
|
||||
COPY stemroller/__init__.py stemroller/catalog.py stemroller/library_core.py /app/stemroller/
|
||||
COPY stemroller/worker/__init__.py stemroller/worker/__main__.py stemroller/worker/audio_io.py \
|
||||
stemroller/worker/backends.py /app/stemroller/worker/
|
||||
COPY server/stemd.py /app/server/
|
||||
ENV MUSIC_DIR=/music STEMS_DIR=/stems STATE_DIR=/state MODELS_DIR=/models \
|
||||
TORCH_HOME=/models/torch HF_HOME=/models/hf NUMBA_CACHE_DIR=/tmp/numba HOME=/tmp \
|
||||
PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=ignore::FutureWarning
|
||||
# Unraid shares belong to nobody:users.
|
||||
USER 99:100
|
||||
CMD ["python", "/app/server/stemd.py"]
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stemd: separates every track in the music library that doesn't have stems yet.
|
||||
|
||||
Runs on the server next to stemserve. Walks MUSIC_DIR, and for each track whose folder
|
||||
under STEMS_DIR has no marker it runs the separation on the GPU and writes the stems with
|
||||
the same layout and marker as the desktop app (stemroller/library_core.py), so stemserve
|
||||
and the phone pick them up as they land. Once caught up it looks again every RESCAN_MIN.
|
||||
|
||||
"Stem Now" in the app goes through stemserve, which drops a request (a JSON list of Plex
|
||||
file paths) into QUEUE_DIR. Those tracks are separated before the rest: between tracks
|
||||
during a pass, and within a few seconds while idle.
|
||||
|
||||
Settings (environment):
|
||||
|
||||
MUSIC_DIR /music the music library (read-only is fine)
|
||||
STEMS_DIR /stems where stems go
|
||||
STATE_DIR /state failed-track list
|
||||
QUEUE_DIR STATE_DIR/queue "Stem Now" requests from stemserve
|
||||
MODELS_DIR /models model weights, downloaded on first use
|
||||
MODEL roformer_bs_sw any id from stemroller/catalog.py
|
||||
DEVICE cuda cuda, cuda:N or cpu
|
||||
RESCAN_MIN 30 minutes between scans once caught up
|
||||
FP16 1 half precision on the GPU
|
||||
OVERLAP / SEGMENT / SHIFTS as in the desktop app's settings
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from stemroller.catalog import MODELS_BY_ID # noqa: E402
|
||||
from stemroller.library_core import (MAX_SECONDS, SOURCE_RANK, duration, is_done, is_share_error, # noqa: E402
|
||||
iter_tracks, signature, write_track)
|
||||
|
||||
MUSIC_DIR = os.environ.get("MUSIC_DIR", "/music")
|
||||
STEMS_DIR = os.environ.get("STEMS_DIR", "/stems")
|
||||
STATE_DIR = os.environ.get("STATE_DIR", "/state")
|
||||
MODELS_DIR = os.environ.get("MODELS_DIR", "/models")
|
||||
MODEL = os.environ.get("MODEL", "roformer_bs_sw")
|
||||
DEVICE = os.environ.get("DEVICE", "cuda")
|
||||
RESCAN_S = float(os.environ.get("RESCAN_MIN", "30")) * 60
|
||||
FP16 = os.environ.get("FP16", "1") not in ("0", "false", "no", "")
|
||||
OVERLAP = float(os.environ.get("OVERLAP", "0.25"))
|
||||
SEGMENT = float(os.environ.get("SEGMENT", "0"))
|
||||
SHIFTS = int(os.environ.get("SHIFTS", "0"))
|
||||
QUEUE_DIR = os.environ.get("QUEUE_DIR", os.path.join(STATE_DIR, "queue"))
|
||||
FAILED_PATH = os.path.join(STATE_DIR, "failed.json")
|
||||
QUEUE_POLL_S = 5 # how often an idle stemd looks for Stem Now requests
|
||||
SHARE_RETRY_S = 5 * 60 # stems folder unwritable: try again this soon rather than at RESCAN_S
|
||||
MAX_CRASHES = 2 # GPU crashes on one track before it's given up on
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
|
||||
def log(*args):
|
||||
print(time.strftime("%Y-%m-%d %H:%M:%S"), *args, flush=True)
|
||||
|
||||
|
||||
class Failed:
|
||||
"""Tracks that didn't separate, keyed by path, retried once the source file changes."""
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
with open(FAILED_PATH) as f:
|
||||
self.data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
self.data = {}
|
||||
|
||||
def skip(self, path, sig):
|
||||
e = self.data.get(path)
|
||||
return bool(e) and e["sig"] == sig and e.get("tries", MAX_CRASHES) >= MAX_CRASHES
|
||||
|
||||
def add(self, path, sig, msg, crash=False):
|
||||
tries = MAX_CRASHES
|
||||
if crash:
|
||||
e = self.data.get(path)
|
||||
tries = (e.get("tries", 0) if e and e["sig"] == sig else 0) + 1
|
||||
self.data[path] = {"sig": sig, "msg": msg, "tries": tries, "at": time.time()}
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
tmp = FAILED_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(self.data, f, indent=1)
|
||||
os.replace(tmp, FAILED_PATH)
|
||||
|
||||
|
||||
class _NoProgress:
|
||||
"""Stands in for the worker's socket; per-track progress isn't worth a log line."""
|
||||
|
||||
def send(self, *_, **__):
|
||||
pass
|
||||
|
||||
|
||||
class Separator:
|
||||
def __init__(self):
|
||||
import torch
|
||||
from stemroller.worker.backends import DemucsBackend, RoformerBackend
|
||||
|
||||
self.torch = torch
|
||||
self.model = MODELS_BY_ID[MODEL]
|
||||
self.device = torch.device(DEVICE if DEVICE == "cpu" or torch.cuda.is_available() else "cpu")
|
||||
if DEVICE != "cpu" and self.device.type == "cpu":
|
||||
log("WARNING: no CUDA device visible; separating on the CPU (slow). Is the container "
|
||||
"running with the nvidia runtime?")
|
||||
self.backend = (DemucsBackend() if self.model["family"] == "demucs" else RoformerBackend(MODELS_DIR))
|
||||
name = torch.cuda.get_device_name(self.device) if self.device.type == "cuda" else "CPU"
|
||||
log(f"model {self.model['label']} on {name}, torch {torch.__version__}")
|
||||
|
||||
def run(self, path, tmp):
|
||||
# Imported late: worker/__main__ sets ROCm env defaults, harmless here, and owns Job's
|
||||
# parameter rules (overlap, fp16, ...), which must match the desktop's.
|
||||
from stemroller.worker.__main__ import Job
|
||||
job = Job({"id": "stemd", "file": path, "model": MODEL, "out_dir": tmp, "segment": SEGMENT,
|
||||
"shifts": SHIFTS, "overlap": OVERLAP, "fp16": FP16}, _NoProgress())
|
||||
if "file" in self.model and not os.path.exists(os.path.join(MODELS_DIR, self.model["file"])):
|
||||
log(" downloading the model (first run only)")
|
||||
job.cancel_event = stop
|
||||
stems, sr = self.backend.run(job, self.device)
|
||||
# Backends return (channels, samples); the writer wants (samples, channels).
|
||||
return {name: audio.T for name, audio in stems.items()}, sr
|
||||
|
||||
def release(self):
|
||||
self.backend.release()
|
||||
|
||||
def is_fatal(self, e):
|
||||
"""CUDA errors other than OOM poison the context; only a fresh process recovers."""
|
||||
if isinstance(e, self.torch.OutOfMemoryError):
|
||||
return False
|
||||
return isinstance(e, getattr(self.torch, "AcceleratorError", ())) or "CUDA error" in str(e)
|
||||
|
||||
|
||||
def separate(sep, failed, path, dest, sig, label):
|
||||
"""Separate one track into dest. Returns True if it was written.
|
||||
|
||||
Raises on a stop request, an unwritable stems share, or a GPU error that needs a restart.
|
||||
"""
|
||||
rel = os.path.relpath(path, MUSIC_DIR)
|
||||
dur = duration(path)
|
||||
if dur is not None and dur > MAX_SECONDS:
|
||||
log(f"{label} skip {rel}: {dur / 60:.0f} min long")
|
||||
failed.add(path, sig, f"longer than {MAX_SECONDS // 60} minutes")
|
||||
return False
|
||||
log(f"{label} {rel}" + (f" ({int(dur) // 60}:{int(dur) % 60:02d})" if dur else ""))
|
||||
t1 = time.monotonic()
|
||||
tmp = tempfile.mkdtemp(prefix="stemd-")
|
||||
try:
|
||||
stems, sr = sep.run(path, tmp)
|
||||
meta = {"source": path, "size": sig[0], "mtime_ns": sig[1], "model": MODEL, "samplerate": sr,
|
||||
"duration": len(next(iter(stems.values()))) / sr}
|
||||
write_track(dest, stems, meta)
|
||||
except BaseException as e:
|
||||
if stop.is_set():
|
||||
raise # Cancelled; the track is redone next start
|
||||
if is_share_error(e):
|
||||
log(f" can't write to {STEMS_DIR}: {e}; pausing")
|
||||
raise
|
||||
if not isinstance(e, Exception):
|
||||
raise
|
||||
msg = str(e).strip().splitlines()[0] if str(e).strip() else e.__class__.__name__
|
||||
if isinstance(e, sep.torch.OutOfMemoryError):
|
||||
# Something else (Plex, a game server) may be holding VRAM; try again next pass.
|
||||
log(" out of GPU memory; will retry next pass")
|
||||
sep.release()
|
||||
return False
|
||||
if sep.is_fatal(e):
|
||||
failed.add(path, sig, msg, crash=True)
|
||||
log(f" GPU error: {msg}; restarting")
|
||||
sys.exit(75)
|
||||
failed.add(path, sig, msg)
|
||||
log(f" failed: {msg}")
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
took = time.monotonic() - t1
|
||||
log(f" done in {took:.0f}s" + (f" ({meta['duration'] / took:.1f}x realtime)" if took else ""))
|
||||
return True
|
||||
|
||||
|
||||
def resolve(plex_path):
|
||||
"""The music file a Plex path names, or None. Plex sees the library under its own mount
|
||||
point, so this takes the longest tail of the path (folder and file at least) that exists
|
||||
under MUSIC_DIR, and prefers the best format when a folder holds the song in several."""
|
||||
parts = [p for p in plex_path.replace("\\", "/").split("/") if p not in ("", ".", "..")]
|
||||
root = os.path.realpath(MUSIC_DIR)
|
||||
for i in range(len(parts) - 1):
|
||||
path = os.path.join(root, *parts[i:])
|
||||
if os.path.isfile(path) and os.path.realpath(path).startswith(root + os.sep):
|
||||
break
|
||||
else:
|
||||
return None
|
||||
folder, name = os.path.split(path)
|
||||
base = os.path.splitext(name)[0].casefold()
|
||||
try:
|
||||
same = [f for f in os.listdir(folder) if os.path.splitext(f)[0].casefold() == base
|
||||
and os.path.splitext(f)[1].lower() in SOURCE_RANK]
|
||||
except OSError:
|
||||
same = []
|
||||
if same:
|
||||
path = os.path.join(folder, min(same, key=lambda f: (SOURCE_RANK[os.path.splitext(f)[1].lower()], f != name)))
|
||||
return path if os.path.splitext(path)[1].lower() in SOURCE_RANK else None
|
||||
|
||||
|
||||
def serve_queue(sep, failed):
|
||||
"""Separate every Stem Now request, oldest first. Returns whether any track was attempted.
|
||||
|
||||
A request file is deleted once all its tracks are done, so one cut short by a restart
|
||||
picks up where it left off (finished tracks are skipped)."""
|
||||
used = False
|
||||
while not stop.is_set():
|
||||
reqs = sorted(glob.glob(os.path.join(QUEUE_DIR, "*.json")))
|
||||
if not reqs:
|
||||
break
|
||||
req = reqs[0]
|
||||
try:
|
||||
with open(req) as f:
|
||||
paths = json.load(f).get("paths", [])
|
||||
except (OSError, ValueError, AttributeError) as e:
|
||||
log(f"stem now: unreadable request {os.path.basename(req)}: {e}")
|
||||
paths = []
|
||||
todo = []
|
||||
for p in paths if isinstance(paths, list) else []:
|
||||
src = resolve(p) if isinstance(p, str) else None
|
||||
if src is None:
|
||||
log(f"stem now: not in {MUSIC_DIR}: {p}")
|
||||
continue
|
||||
dest = os.path.join(STEMS_DIR, os.path.splitext(os.path.relpath(src, MUSIC_DIR))[0])
|
||||
if is_done(dest):
|
||||
continue
|
||||
try:
|
||||
sig = signature(src)
|
||||
except OSError:
|
||||
continue
|
||||
if failed.skip(src, sig):
|
||||
log(f"stem now: skip {os.path.relpath(src, MUSIC_DIR)}: failed before ({failed.data[src]['msg']})")
|
||||
continue
|
||||
todo.append((src, dest, sig))
|
||||
if todo:
|
||||
log(f"stem now: {len(todo)} track{'' if len(todo) == 1 else 's'} ahead of the rest")
|
||||
for i, (src, dest, sig) in enumerate(todo, 1):
|
||||
if stop.is_set():
|
||||
return used
|
||||
if is_done(dest):
|
||||
continue
|
||||
used = True
|
||||
separate(sep, failed, src, dest, sig, f"[now {i}/{len(todo)}]")
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
os.remove(req)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return used
|
||||
|
||||
|
||||
def run_pass(sep, failed):
|
||||
"""Separate everything that needs it. Returns (tracks done, whether the GPU was used)."""
|
||||
t0 = time.monotonic()
|
||||
todo, total = [], 0
|
||||
for path, dest in iter_tracks(MUSIC_DIR, STEMS_DIR):
|
||||
if stop.is_set():
|
||||
return 0, False
|
||||
total += 1
|
||||
if is_done(dest):
|
||||
continue
|
||||
try:
|
||||
sig = signature(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not failed.skip(path, sig):
|
||||
todo.append((path, dest, sig))
|
||||
log(f"scan: {total:,} tracks, {total - len(todo):,} done or skipped, {len(todo):,} to do "
|
||||
f"({time.monotonic() - t0:.0f}s)")
|
||||
if not total:
|
||||
log(f"WARNING: no music found in {MUSIC_DIR}; is the share mounted and readable?")
|
||||
|
||||
done, used = 0, False
|
||||
for i, (path, dest, sig) in enumerate(todo, 1):
|
||||
used = serve_queue(sep, failed) or used
|
||||
if stop.is_set():
|
||||
break
|
||||
if is_done(dest): # a full pass takes days; the desktop or a Stem Now may have got there first
|
||||
continue
|
||||
used = True
|
||||
if separate(sep, failed, path, dest, sig, f"[{i}/{len(todo)}]"):
|
||||
done += 1
|
||||
return done, used
|
||||
|
||||
|
||||
def idle(sep, failed, seconds):
|
||||
"""Wait for the next scan, separating Stem Now requests as they arrive."""
|
||||
end = time.monotonic() + seconds
|
||||
while not stop.is_set() and time.monotonic() < end:
|
||||
if serve_queue(sep, failed):
|
||||
sep.release()
|
||||
stop.wait(min(QUEUE_POLL_S, max(0.0, end - time.monotonic())))
|
||||
|
||||
|
||||
def main():
|
||||
for name, path in (("MUSIC_DIR", MUSIC_DIR), ("STEMS_DIR", STEMS_DIR)):
|
||||
if not os.path.isdir(path):
|
||||
sys.exit(f"{name} not found: {path}")
|
||||
if MODEL not in MODELS_BY_ID:
|
||||
sys.exit(f"unknown MODEL {MODEL!r}; pick one of: {', '.join(MODELS_BY_ID)}")
|
||||
# docker stop sends SIGTERM; stop between (or inside) tracks instead of dying mid-write.
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||
try:
|
||||
os.nice(10)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.makedirs(QUEUE_DIR, exist_ok=True)
|
||||
except OSError as e:
|
||||
log(f"WARNING: can't create {QUEUE_DIR} ({e}); Stem Now won't work")
|
||||
log(f"stemd: {MUSIC_DIR} -> {STEMS_DIR}")
|
||||
sep = Separator()
|
||||
failed = Failed()
|
||||
while not stop.is_set():
|
||||
try:
|
||||
done, used = run_pass(sep, failed)
|
||||
if done:
|
||||
log(f"pass finished: {done:,} tracks separated")
|
||||
if used:
|
||||
sep.release() # hand the VRAM back while idle
|
||||
idle(sep, failed, RESCAN_S)
|
||||
except OSError as e:
|
||||
if stop.is_set():
|
||||
break
|
||||
if not is_share_error(e):
|
||||
raise
|
||||
sep.release()
|
||||
stop.wait(SHARE_RETRY_S)
|
||||
except BaseException:
|
||||
if stop.is_set():
|
||||
break
|
||||
raise
|
||||
log("stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stemserve: serves StemRoller library stems to the Android app over HTTP.
|
||||
|
||||
Stdlib only; ffmpeg is optional and enables Opus transcodes and waveforms.
|
||||
|
||||
The stems folder is what stemroller.library writes:
|
||||
|
||||
<STEMS_DIR>/Artist/Album/01 Song/.stemroller.json
|
||||
<STEMS_DIR>/Artist/Album/01 Song/01 Song - vocals.flac, ...
|
||||
|
||||
Endpoints (all GET unless noted; add ?token=... or X-Stem-Token when TOKEN is set):
|
||||
|
||||
/api/info server facts
|
||||
/api/index every finished track (ETag / If-None-Match)
|
||||
/api/peaks?key=<key>&n=<buckets> per-stem RMS envelopes, 0..1000
|
||||
POST /api/prepare?key=<key>&fmt=opus&br=160 start transcodes in the background
|
||||
POST /api/stem {"paths": [...]} "Stem Now": stemd separates these Plex files next
|
||||
/file/<key>/<file>[?fmt=opus&br=160] the stem itself, with Range support
|
||||
|
||||
<key> is the track's folder relative to STEMS_DIR, e.g. "Artist/Album/01 Song".
|
||||
"""
|
||||
|
||||
import email.utils
|
||||
import hashlib
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from array import array
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
STEMS_DIR = os.path.realpath(os.environ.get("STEMS_DIR", "/stems"))
|
||||
CACHE_DIR = os.environ.get("CACHE_DIR", "/cache")
|
||||
PORT = int(os.environ.get("PORT", "8765"))
|
||||
TOKEN = os.environ.get("TOKEN", "")
|
||||
INDEX_TTL = float(os.environ.get("INDEX_TTL", "30"))
|
||||
QUEUE_DIR = os.environ.get("QUEUE_DIR", "/queue") # stemd's request folder, shared with it
|
||||
MAX_BODY = 8 << 20
|
||||
MARKER = ".stemroller.json"
|
||||
VERSION = 1
|
||||
FFMPEG = shutil.which("ffmpeg")
|
||||
OPUS_RATES = (64, 96, 128, 160, 192, 256)
|
||||
PEAK_RATE = 2000 # Hz the envelope is computed at; plenty for a few hundred bars
|
||||
CHUNK = 1 << 20
|
||||
|
||||
_pool = ThreadPoolExecutor(max_workers=max(2, (os.cpu_count() or 2) // 2))
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def log(*args):
|
||||
print(time.strftime("%H:%M:%S"), *args, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _lock_for(path):
|
||||
with _locks_guard:
|
||||
return _locks.setdefault(path, threading.Lock())
|
||||
|
||||
|
||||
# ---- index ------------------------------------------------------------------
|
||||
|
||||
class Index:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._scanning = threading.Lock()
|
||||
self._at = 0.0
|
||||
self._body = b'{"tracks":[]}'
|
||||
self._etag = '"empty"'
|
||||
self._count = 0
|
||||
self._by_key: dict[str, dict] = {}
|
||||
|
||||
def get(self):
|
||||
if time.monotonic() - self._at > INDEX_TTL:
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
return self._body, self._etag
|
||||
|
||||
def entry(self, key):
|
||||
if time.monotonic() - self._at > INDEX_TTL:
|
||||
self.refresh()
|
||||
with self._lock:
|
||||
return self._by_key.get(key)
|
||||
|
||||
@property
|
||||
def count(self):
|
||||
return self._count
|
||||
|
||||
def refresh(self):
|
||||
# One scan at a time; callers that lose the race use the previous result.
|
||||
if not self._scanning.acquire(blocking=self._at == 0):
|
||||
return
|
||||
try:
|
||||
tracks = []
|
||||
for root, dirs, files in os.walk(STEMS_DIR):
|
||||
dirs[:] = sorted(d for d in dirs if not d.startswith("."))
|
||||
if MARKER not in files:
|
||||
continue
|
||||
entry = _read_marker(root, set(files))
|
||||
if entry:
|
||||
tracks.append(entry)
|
||||
tracks.sort(key=lambda t: t["key"].casefold())
|
||||
body = json.dumps({"version": VERSION, "tracks": tracks}, separators=(",", ":")).encode()
|
||||
with self._lock:
|
||||
self._body = body
|
||||
self._etag = '"%s"' % hashlib.sha1(body).hexdigest()[:20]
|
||||
self._count = len(tracks)
|
||||
self._by_key = {t["key"]: t for t in tracks}
|
||||
self._at = time.monotonic()
|
||||
finally:
|
||||
self._scanning.release()
|
||||
|
||||
|
||||
def _read_marker(folder, files):
|
||||
try:
|
||||
with open(os.path.join(folder, MARKER), encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
key = os.path.relpath(folder, STEMS_DIR).replace(os.sep, "/")
|
||||
stems = []
|
||||
for s in meta.get("stems", []):
|
||||
name, fname = s.get("name"), s.get("file")
|
||||
if not name or not fname or fname not in files:
|
||||
continue
|
||||
try:
|
||||
st = os.stat(os.path.join(folder, fname))
|
||||
except OSError:
|
||||
continue
|
||||
stems.append({"name": name, "file": fname, "size": st.st_size, "mtime": int(st.st_mtime)})
|
||||
if not stems:
|
||||
return None
|
||||
# The stems folder mirrors the music folder, so the source's path relative to the
|
||||
# music root is this folder's parent plus the source's file name.
|
||||
src_name = os.path.basename(meta.get("source") or "") or os.path.basename(folder)
|
||||
parent = os.path.dirname(key)
|
||||
return {
|
||||
"key": key,
|
||||
"source": f"{parent}/{src_name}" if parent else src_name,
|
||||
"duration": meta.get("duration"),
|
||||
"samplerate": meta.get("samplerate"),
|
||||
"model": meta.get("model"),
|
||||
"finished": meta.get("finished"),
|
||||
"stems": stems,
|
||||
}
|
||||
|
||||
|
||||
INDEX = Index()
|
||||
|
||||
|
||||
# ---- transcodes and peaks ---------------------------------------------------
|
||||
|
||||
def _resolve(key, fname):
|
||||
"""Absolute path of a stem file, or None if it escapes STEMS_DIR or is missing."""
|
||||
path = os.path.realpath(os.path.join(STEMS_DIR, key, fname))
|
||||
if not path.startswith(STEMS_DIR + os.sep) or not path.lower().endswith(".flac"):
|
||||
return None
|
||||
return path if os.path.isfile(path) else None
|
||||
|
||||
|
||||
def _cache_name(src, tag, ext):
|
||||
st = os.stat(src)
|
||||
h = hashlib.sha1(f"{src}|{st.st_size}|{st.st_mtime_ns}|{tag}".encode()).hexdigest()
|
||||
return os.path.join(CACHE_DIR, tag.split(":")[0], h[:2], h + ext)
|
||||
|
||||
|
||||
def transcode(src, br):
|
||||
"""Path of an Ogg Opus copy of src at br kbps, transcoding it first if needed."""
|
||||
out = _cache_name(src, f"opus:{br}", ".opus")
|
||||
if os.path.exists(out):
|
||||
os.utime(out) # for LRU-ish cleanup by mtime
|
||||
return out
|
||||
with _lock_for(out):
|
||||
if os.path.exists(out):
|
||||
return out
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
tmp = out + ".part"
|
||||
t0 = time.monotonic()
|
||||
subprocess.run([FFMPEG, "-nostdin", "-v", "error", "-y", "-i", src, "-map", "0:a:0",
|
||||
"-c:a", "libopus", "-b:a", f"{br}k", "-vbr", "on", "-application", "audio",
|
||||
"-map_metadata", "-1", "-f", "ogg", tmp], check=True, timeout=600)
|
||||
os.replace(tmp, out)
|
||||
log(f"opus {br}k {os.path.basename(src)} in {time.monotonic() - t0:.1f}s")
|
||||
return out
|
||||
|
||||
|
||||
def _envelope(src, n, duration):
|
||||
"""RMS of src in n equal buckets across `duration` seconds, as 0..1 of full scale."""
|
||||
raw = subprocess.run([FFMPEG, "-nostdin", "-v", "error", "-i", src, "-map", "0:a:0", "-ac", "1",
|
||||
"-ar", str(PEAK_RATE), "-f", "s16le", "-"],
|
||||
check=True, capture_output=True, timeout=300).stdout
|
||||
pcm = array("h")
|
||||
pcm.frombytes(raw[:len(raw) // 2 * 2])
|
||||
if sys.byteorder == "big":
|
||||
pcm.byteswap()
|
||||
total = max(1, int(round((duration or len(pcm) / PEAK_RATE) * PEAK_RATE)))
|
||||
out = []
|
||||
for i in range(n):
|
||||
a, b = i * total // n, (i + 1) * total // n
|
||||
chunk = pcm[a:max(b, a + 1)]
|
||||
if not chunk:
|
||||
out.append(0)
|
||||
continue
|
||||
ms = sum(map(operator.mul, chunk, chunk)) / len(chunk)
|
||||
out.append(min(1000, int(round((ms ** 0.5) / 32768 * 1000))))
|
||||
return out
|
||||
|
||||
|
||||
def peaks(entry, n):
|
||||
folder = os.path.join(STEMS_DIR, entry["key"])
|
||||
first = _resolve(entry["key"], entry["stems"][0]["file"])
|
||||
sig = "|".join(f"{s['file']}:{s['size']}:{s['mtime']}" for s in entry["stems"])
|
||||
out = _cache_name(first, f"peaks:{n}:{hashlib.sha1(sig.encode()).hexdigest()}", ".json")
|
||||
if os.path.exists(out):
|
||||
with open(out, "rb") as f:
|
||||
return f.read()
|
||||
with _lock_for(out):
|
||||
if os.path.exists(out):
|
||||
with open(out, "rb") as f:
|
||||
return f.read()
|
||||
t0 = time.monotonic()
|
||||
jobs = {s["name"]: _pool.submit(_envelope, os.path.join(folder, s["file"]), n, entry.get("duration"))
|
||||
for s in entry["stems"]}
|
||||
body = json.dumps({"key": entry["key"], "n": n, "duration": entry.get("duration"),
|
||||
"stems": {name: job.result() for name, job in jobs.items()}},
|
||||
separators=(",", ":")).encode()
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
with open(out + ".part", "wb") as f:
|
||||
f.write(body)
|
||||
os.replace(out + ".part", out)
|
||||
log(f"peaks {entry['key']} in {time.monotonic() - t0:.1f}s")
|
||||
return body
|
||||
|
||||
|
||||
# ---- HTTP -------------------------------------------------------------------
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = f"stemserve/{VERSION}"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if os.environ.get("VERBOSE"):
|
||||
log(self.address_string(), fmt % args)
|
||||
|
||||
def _authorized(self, query):
|
||||
if not TOKEN:
|
||||
return True
|
||||
return TOKEN in (self.headers.get("X-Stem-Token"), query.get("token", [None])[0])
|
||||
|
||||
def _send(self, status, body=b"", ctype="application/json", headers=None):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for k, v in (headers or {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _error(self, status, msg):
|
||||
self._send(status, json.dumps({"error": msg}).encode())
|
||||
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
url = urllib.parse.urlsplit(self.path)
|
||||
query = urllib.parse.parse_qs(url.query)
|
||||
path = urllib.parse.unquote(url.path)
|
||||
if not self._authorized(query):
|
||||
return self._error(HTTPStatus.UNAUTHORIZED, "bad token")
|
||||
try:
|
||||
if path == "/api/info":
|
||||
INDEX.get()
|
||||
return self._send(200, json.dumps({
|
||||
"name": "stemserve", "version": VERSION, "tracks": INDEX.count,
|
||||
"ffmpeg": bool(FFMPEG), "opus": list(OPUS_RATES) if FFMPEG else [],
|
||||
"stemNow": os.access(QUEUE_DIR, os.W_OK)}).encode())
|
||||
if path == "/api/index":
|
||||
body, etag = INDEX.get()
|
||||
if self.headers.get("If-None-Match") == etag:
|
||||
return self._send(304, headers={"ETag": etag})
|
||||
return self._send(200, body, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
if path == "/api/peaks":
|
||||
return self._peaks(query)
|
||||
if path == "/api/prepare":
|
||||
return self._prepare(query)
|
||||
if path == "/api/stem":
|
||||
return self._stem_now()
|
||||
if path.startswith("/file/"):
|
||||
return self._file(path[len("/file/"):], query)
|
||||
return self._error(404, "not found")
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._error(500, f"ffmpeg failed ({e.returncode})")
|
||||
except Exception as e: # keep serving; report what broke
|
||||
log("error", self.path, repr(e))
|
||||
try:
|
||||
self._error(500, str(e) or e.__class__.__name__)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _entry(self, query):
|
||||
key = (query.get("key") or [""])[0]
|
||||
entry = INDEX.entry(key)
|
||||
if entry is None:
|
||||
self._error(404, "unknown key")
|
||||
return entry
|
||||
|
||||
def _peaks(self, query):
|
||||
if not FFMPEG:
|
||||
return self._error(501, "ffmpeg not installed")
|
||||
entry = self._entry(query)
|
||||
if entry is None:
|
||||
return
|
||||
n = max(16, min(4096, int((query.get("n") or ["512"])[0])))
|
||||
self._send(200, peaks(entry, n), headers={"Cache-Control": "max-age=86400"})
|
||||
|
||||
def _prepare(self, query):
|
||||
entry = self._entry(query)
|
||||
if entry is None:
|
||||
return
|
||||
fmt, br = _format(query)
|
||||
if fmt == "opus":
|
||||
for s in entry["stems"]:
|
||||
src = _resolve(entry["key"], s["file"])
|
||||
if src:
|
||||
_pool.submit(transcode, src, br)
|
||||
self._send(202, b'{"ok":true}')
|
||||
|
||||
def _stem_now(self):
|
||||
"""Hands a list of Plex file paths to stemd, which separates them before the rest."""
|
||||
if self.command != "POST":
|
||||
return self._error(405, "POST a JSON body: {\"paths\": [...]}")
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > MAX_BODY:
|
||||
self.close_connection = True
|
||||
return self._error(413, "too many paths")
|
||||
try:
|
||||
paths = json.loads(self.rfile.read(n) or b"{}").get("paths")
|
||||
except (ValueError, AttributeError):
|
||||
paths = None
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
return self._error(400, "expected {\"paths\": [...]}")
|
||||
if not os.access(QUEUE_DIR, os.W_OK):
|
||||
return self._error(503, f"stemd's queue folder isn't mounted at {QUEUE_DIR}")
|
||||
if paths:
|
||||
# stemd reads requests in name order; the dot keeps a half-written one out of its glob.
|
||||
name = f"{time.time_ns()}-{os.getpid()}-{threading.get_ident()}.json"
|
||||
tmp = os.path.join(QUEUE_DIR, "." + name)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump({"paths": paths, "at": time.time()}, f)
|
||||
os.replace(tmp, os.path.join(QUEUE_DIR, name))
|
||||
log(f"stem now: {len(paths)} track{'' if len(paths) == 1 else 's'} queued")
|
||||
self._send(202, json.dumps({"queued": len(paths)}).encode())
|
||||
|
||||
def _file(self, rel, query):
|
||||
key, _, fname = rel.rpartition("/")
|
||||
src = _resolve(key, fname)
|
||||
if not src:
|
||||
return self._error(404, "no such stem")
|
||||
fmt, br = _format(query)
|
||||
path, ctype = (transcode(src, br), "audio/ogg") if fmt == "opus" else (src, "audio/flac")
|
||||
self._serve_file(path, ctype)
|
||||
|
||||
def _serve_file(self, path, ctype):
|
||||
st = os.stat(path)
|
||||
size = st.st_size
|
||||
etag = '"%x-%x"' % (st.st_size, st.st_mtime_ns)
|
||||
start, end = 0, size - 1
|
||||
status = 200
|
||||
rng = self.headers.get("Range")
|
||||
if rng and rng.startswith("bytes=") and "," not in rng and self.headers.get("If-Range", etag) == etag:
|
||||
a, _, b = rng[6:].strip().partition("-")
|
||||
try:
|
||||
if a:
|
||||
start = int(a)
|
||||
end = min(int(b), size - 1) if b else size - 1
|
||||
else: # suffix range: last N bytes
|
||||
start = max(0, size - int(b))
|
||||
except ValueError:
|
||||
return self._error(400, "bad range")
|
||||
if start >= size or start > end:
|
||||
self.send_response(416)
|
||||
self.send_header("Content-Range", f"bytes */{size}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
status = 206
|
||||
length = end - start + 1
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("ETag", etag)
|
||||
self.send_header("Last-Modified", email.utils.formatdate(st.st_mtime, usegmt=True))
|
||||
self.send_header("Cache-Control", "max-age=31536000, immutable")
|
||||
if status == 206:
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.end_headers()
|
||||
if self.command == "HEAD":
|
||||
return
|
||||
with open(path, "rb") as f:
|
||||
f.seek(start)
|
||||
left = length
|
||||
while left > 0:
|
||||
buf = f.read(min(CHUNK, left))
|
||||
if not buf:
|
||||
break
|
||||
self.wfile.write(buf)
|
||||
left -= len(buf)
|
||||
|
||||
|
||||
def _format(query):
|
||||
fmt = (query.get("fmt") or ["flac"])[0]
|
||||
if fmt != "opus" or not FFMPEG:
|
||||
return "flac", 0
|
||||
try:
|
||||
br = int((query.get("br") or ["160"])[0])
|
||||
except ValueError:
|
||||
br = 160
|
||||
return "opus", min(OPUS_RATES, key=lambda r: abs(r - br))
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(STEMS_DIR):
|
||||
sys.exit(f"STEMS_DIR not found: {STEMS_DIR}")
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
INDEX.refresh()
|
||||
log(f"stemserve on :{PORT}, {INDEX.count} tracks in {STEMS_DIR}, ffmpeg={'yes' if FFMPEG else 'no'}, "
|
||||
f"token={'on' if TOKEN else 'off'}")
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
server.daemon_threads = True
|
||||
# As PID 1 in a container, SIGTERM is ignored unless handled; docker stop would hang.
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
STEM EXTRACTOR FRONTEND DESIGN DOC
|
||||
2026-10-08
|
||||
|
||||
== OVERVIEW AND GOALS ==
|
||||
A single-window desktop app that takes dropped audio files and splits them into stems (vocals, drums, bass, other) using Demucs on a local GPU, including AMD via ROCm on Linux. The UI pairs Material's layout and motion rules with Windows 7 Aero's glass and gloss, dark by default, and every animation and meter holds frame rate at 240Hz.
|
||||
|
||||
Goals:
|
||||
- Drag-drop to finished stems in 2 clicks or fewer.
|
||||
- Sustained 240fps (4.17 ms frame budget) during idle, scrolling, playback and active separation.
|
||||
- Linux first (Wayland and X11), AMD and NVIDIA GPUs, CPU fallback.
|
||||
- Python backend so the separation logic stays hackable.
|
||||
|
||||
Non-goals:
|
||||
- A DAW. Editing is limited to solo, mute, gain and export.
|
||||
- YouTube search or download in v1.
|
||||
- Light theme in v1 (tokens allow it later).
|
||||
|
||||
== VISUAL LANGUAGE ==
|
||||
Material supplies structure (8dp grid, elevation scale, type ramp, motion curves). Aero supplies surface (frosted glass, top gloss highlight, soft glow on hover). Glass is reserved for chrome (title bar, sidebar, dialogs) so content stays crisp.
|
||||
|
||||
Material x Aero mapping:
|
||||
- Title bar, sidebar: elevation 0 to 1. Glass: blur 16px, tint #10141C at 70%, 1px inner white border at 8%.
|
||||
- Cards (tracks): elevation 2, radius 8px. Top 40% gloss gradient, white 6% to 0%.
|
||||
- Buttons: filled and tonal variants, 40px height. Glossy pill, cyan glow on hover.
|
||||
- Dialogs: elevation 3, scrim 50%. Glass with stronger blur (24px).
|
||||
- Progress: linear indicator. Win7-style green-to-cyan striped bar, animated.
|
||||
|
||||
Dark tokens:
|
||||
- bg: #0B0E14 (window background)
|
||||
- surface-1: #141924 (cards)
|
||||
- surface-2: #1C2330 (raised cards, menus)
|
||||
- glass-tint: rgba(16,20,28,0.70) (chrome)
|
||||
- primary: #3BA8FF (Aero blue, actions)
|
||||
- accent-glow: #5FE3FF (hover glow, focus ring)
|
||||
- success: #4CD07D (done state)
|
||||
- error: #FF5C6C (failures)
|
||||
- text-hi: #E8EDF5 (body)
|
||||
- text-lo: #8A94A6 (secondary)
|
||||
|
||||
Stem colors: vocals #FF7AB6, drums #FFB547, bass #7C8CFF, other #4CD07D.
|
||||
|
||||
Type and motion:
|
||||
- Type: Segoe UI Variable if present, else Inter. Ramp 12 / 14 / 16 / 20 / 28px.
|
||||
- Motion: Material standard easing cubic-bezier(0.2, 0, 0, 1), durations 100 / 200 / 300 ms. No animation over 300 ms.
|
||||
|
||||
== LAYOUT AND COMPONENTS ==
|
||||
One window, three regions: a glass sidebar (220px) for the queue, a main pane for the selected track, and a bottom transport bar (64px). Minimum window 960x600.
|
||||
|
||||
Components:
|
||||
- Drop zone: empty-state card filling main pane; dashed glow border on drag-over.
|
||||
- Queue item: filename, model, status chip, striped progress bar, cancel.
|
||||
- Model picker: dropdown (htdemucs, htdemucs_ft, htdemucs_6s); toggle for 2-stem vocals mode.
|
||||
- Device picker: Auto / GPU name / CPU; shows free VRAM.
|
||||
- Stem lanes: one lane per stem with color strip, waveform, solo, mute, gain slider, export.
|
||||
- Transport: play/pause, scrub bar, time, master level meter.
|
||||
- Settings dialog: output folder, format (wav/flac/mp3), segment size, shifts.
|
||||
|
||||
User flow:
|
||||
1. Drop file(s) onto window. Each becomes a queue item.
|
||||
2. Separation starts automatically with last-used model and device.
|
||||
3. On completion, stem lanes appear with waveforms; audio plays in sync.
|
||||
4. Export all, or per stem, to the output folder.
|
||||
|
||||
== 240HZ PERFORMANCE PLAN ==
|
||||
Budget is 4.17 ms per frame, so the UI thread never touches audio decoding, inference or file IO, and live glass blur is limited to static chrome.
|
||||
|
||||
- Rendering: GPU-accelerated scene graph with vsync on; no fixed 60Hz timers. Animations driven by frame callbacks with delta-time, never step-per-tick.
|
||||
- Blur cost: chrome glass blurs a cached snapshot of the window background, refreshed only on resize. Content never sits under live blur.
|
||||
- Waveforms: precomputed min/max peaks (e.g. 512 samples per bucket) in the backend, uploaded once as a texture or vertex buffer. Zoom picks a mip level; no per-frame recompute.
|
||||
- Meters and playhead: read from a shared ring buffer, interpolated per frame.
|
||||
- Progress updates: backend sends at most 30 updates/s; UI interpolates between them.
|
||||
- Isolation: Demucs runs in a separate process so GIL and GPU stalls cannot block the UI.
|
||||
- Verification: frame-time overlay (dev flag) showing p50/p99 frame time; target p99 under 4.17 ms on a 240Hz panel during active separation.
|
||||
|
||||
Note: Wayland compositors set the refresh rate; on X11 mixed-refresh multi-monitor setups the app may fall back to the lowest rate.
|
||||
|
||||
== TECH STACK AND BACKEND INTEGRATION ==
|
||||
Recommended: PySide6 with Qt Quick (QML) for the UI, plus a separate Python worker process for Demucs. QML's scene graph renders on the GPU at display refresh, supports MultiEffect blur and shaders, and keeps everything in Python.
|
||||
|
||||
Alternatives considered:
|
||||
- PySide6 + QML (pick): strong 240Hz fit; native GPU scene graph, Python end to end.
|
||||
- Tauri + web UI: good fit; needs Rust shell plus Python sidecar; WebKitGTK on Linux can be choppy.
|
||||
- Dear PyGui: strong fit; fast, but Aero glass and Material polish are hard.
|
||||
|
||||
Backend contract:
|
||||
- Worker: demucs.api (or audio-separator) in its own process using ROCm or CUDA PyTorch; torch.cuda covers both.
|
||||
- IPC: JSON messages over a local socket:
|
||||
start {file, model, device, two_stems}
|
||||
progress {id, pct}
|
||||
done {id, stems[], peaks_path}
|
||||
error {id, msg}
|
||||
cancel {id}
|
||||
- Audio playback: decoded stems streamed via sounddevice in a third thread with its own buffer.
|
||||
- One job on GPU at a time; queue the rest.
|
||||
|
||||
== MILESTONES ==
|
||||
1. Worker process: Demucs on ROCm, IPC protocol, CLI test harness.
|
||||
2. Shell: window, glass chrome, tokens, drop zone, queue with progress.
|
||||
3. Results: peak generation, stem lanes, synced playback, export.
|
||||
4. Polish: frame-time overlay, hit 240fps p99 target, settings dialog.
|
||||
|
||||
== OPEN QUESTIONS ==
|
||||
- Frameless custom title bar, or keep the system one?
|
||||
- Include UVR models (MDX, RoFormer) alongside Demucs?
|
||||
- Package as AppImage, Flatpak, or pip only?
|
||||
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .app import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Entry point: Qt Quick window, providers, controller, dev frame-time instrumentation."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _env_defaults():
|
||||
# Basic style so every control can take the Material x Aero skin.
|
||||
os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic")
|
||||
# Native Wayland on GNOME; X11 still works if forced.
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "wayland;xcb")
|
||||
# Threaded render loop: rendering paces itself to vsync off the GUI thread.
|
||||
os.environ.setdefault("QSG_RENDER_LOOP", "threaded")
|
||||
|
||||
|
||||
class FrameStats:
|
||||
"""Frame stats for the dev overlay and --bench.
|
||||
|
||||
- frame time: sync + render work per frame, timed on the render thread.
|
||||
Hooks are only connected while the overlay is visible (a GIL hop per frame).
|
||||
- cadence: intervals from a QML FrameAnimation (one tick per presented
|
||||
frame), pushed in batches; a gap over 1.5x the vsync period is a miss.
|
||||
"""
|
||||
|
||||
def __init__(self, window, refresh_hz, render_hooks=False):
|
||||
self.refresh = refresh_hz or 240.0
|
||||
self.intervals = deque(maxlen=20000)
|
||||
self.misses = []
|
||||
self.work = deque(maxlen=20000)
|
||||
self._window = window
|
||||
self._sync_start = None
|
||||
self._pc = time.perf_counter_ns
|
||||
self._hooks = False
|
||||
if render_hooks:
|
||||
self.connect_hooks()
|
||||
|
||||
def connect_hooks(self):
|
||||
from PySide6.QtCore import Qt
|
||||
direct = Qt.ConnectionType.DirectConnection
|
||||
self._window.beforeSynchronizing.connect(self._before_sync, direct)
|
||||
self._window.afterRendering.connect(self._after_render, direct)
|
||||
self._hooks = True
|
||||
|
||||
def disconnect_hooks(self):
|
||||
if self._hooks:
|
||||
self._window.beforeSynchronizing.disconnect(self._before_sync)
|
||||
self._window.afterRendering.disconnect(self._after_render)
|
||||
self._hooks = False
|
||||
|
||||
def _before_sync(self):
|
||||
self._sync_start = self._pc()
|
||||
|
||||
def _after_render(self):
|
||||
if self._sync_start is not None:
|
||||
self.work.append((self._pc() - self._sync_start) / 1e6)
|
||||
|
||||
def push(self, intervals_ms):
|
||||
self.intervals.extend(d for d in intervals_ms if 0 < d < 250)
|
||||
|
||||
def miss(self, dt_ms):
|
||||
self.misses.append((time.monotonic(), round(dt_ms, 2)))
|
||||
|
||||
def reset(self):
|
||||
self.intervals.clear()
|
||||
self.work.clear()
|
||||
self.misses.clear()
|
||||
self.t0 = time.monotonic()
|
||||
|
||||
def summary(self, last=None):
|
||||
def pct(seq, q):
|
||||
a = sorted(seq)
|
||||
return a[min(len(a) - 1, int(q * len(a)))] if a else 0.0
|
||||
iv = list(self.intervals)[-last:] if last else list(self.intervals)
|
||||
wk = list(self.work)[-last:] if last else list(self.work)
|
||||
budget = 1000.0 / self.refresh
|
||||
missed = sum(1 for d in iv if d > budget * 1.5)
|
||||
return {
|
||||
"refresh_hz": round(self.refresh, 2), "frames": len(iv),
|
||||
"fps": round(1000.0 / (sum(iv) / len(iv)), 1) if iv else 0.0,
|
||||
"interval_p50": round(pct(iv, 0.5), 3), "interval_p99": round(pct(iv, 0.99), 3),
|
||||
"interval_max": round(max(iv), 3) if iv else 0.0,
|
||||
"work_p50": round(pct(wk, 0.5), 3) if wk else None,
|
||||
"work_p99": round(pct(wk, 0.99), 3) if wk else None,
|
||||
"missed": missed, "budget_ms": round(budget, 3),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
_env_defaults()
|
||||
ap = argparse.ArgumentParser(prog="stemroller")
|
||||
ap.add_argument("files", nargs="*", help="audio files to queue on launch")
|
||||
ap.add_argument("--frame-overlay", action="store_true", help="show p50/p99 frame-time overlay (F12 toggles)")
|
||||
ap.add_argument("--bench", type=float, default=0, metavar="SECONDS",
|
||||
help="record frame stats for N seconds, print JSON, and quit")
|
||||
ap.add_argument("--bench-delay", type=float, default=2.0)
|
||||
ap.add_argument("--bench-out", default="")
|
||||
ap.add_argument("--screenshot", action="append", default=[], metavar="PNG@SECONDS",
|
||||
help="grab the window to PNG after N seconds (repeatable)")
|
||||
ap.add_argument("--size", default="", metavar="WxH")
|
||||
ap.add_argument("--autoplay", action="store_true", help="start playback as soon as stems load (dev)")
|
||||
args = ap.parse_args()
|
||||
|
||||
from PySide6.QtCore import QTimer, QUrl
|
||||
from PySide6.QtGui import QFontDatabase, QGuiApplication, QIcon, QSurfaceFormat
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuick import QQuickWindow
|
||||
|
||||
fmt = QSurfaceFormat.defaultFormat()
|
||||
fmt.setSwapInterval(1) # vsync: pace to the panel (240Hz), never a fixed timer
|
||||
fmt.setAlphaBufferSize(8) # rounded, translucent window corners
|
||||
QSurfaceFormat.setDefaultFormat(fmt)
|
||||
QQuickWindow.setDefaultAlphaBuffer(True)
|
||||
|
||||
app = QGuiApplication(sys.argv)
|
||||
app.setApplicationName("StemRoller")
|
||||
app.setOrganizationName("stemroller")
|
||||
app.setDesktopFileName("stemroller")
|
||||
app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "stemroller.svg")))
|
||||
|
||||
font_dir = os.path.join(HERE, "assets", "fonts")
|
||||
if os.path.isdir(font_dir):
|
||||
for f in sorted(os.listdir(font_dir)):
|
||||
if f.endswith((".ttf", ".otf")):
|
||||
QFontDatabase.addApplicationFont(os.path.join(font_dir, f))
|
||||
families = set(QFontDatabase.families())
|
||||
ui_font = "Segoe UI Variable Text" if "Segoe UI Variable Text" in families else \
|
||||
"Segoe UI Variable" if "Segoe UI Variable" in families else "Inter"
|
||||
|
||||
from .audio_engine import AudioEngine
|
||||
from .controller import Controller
|
||||
from .providers import FxProvider, IconProvider, PeakStore, WaveformProvider
|
||||
|
||||
audio = AudioEngine()
|
||||
peaks = PeakStore()
|
||||
controller = Controller(audio, peaks)
|
||||
|
||||
engine = QQmlApplicationEngine()
|
||||
engine.addImageProvider("icon", IconProvider())
|
||||
engine.addImageProvider("fx", FxProvider())
|
||||
engine.addImageProvider("wave", WaveformProvider(peaks, controller.peaks_path_for))
|
||||
engine.addImportPath(os.path.join(HERE, "qml"))
|
||||
ctx = engine.rootContext()
|
||||
ctx.setContextProperty("app", controller)
|
||||
ctx.setContextProperty("appSettings", controller.settings)
|
||||
ctx.setContextProperty("jobsModel", controller.jobs)
|
||||
ctx.setContextProperty("audio", audio)
|
||||
ctx.setContextProperty("uiFont", ui_font)
|
||||
ctx.setContextProperty("devOverlay", bool(args.frame_overlay or args.bench))
|
||||
from PySide6.QtCore import QObject, Slot
|
||||
|
||||
class StatsBridge(QObject):
|
||||
stats = None
|
||||
|
||||
@Slot("QVariantList")
|
||||
def push(self, intervals):
|
||||
if self.stats is not None:
|
||||
self.stats.push(intervals)
|
||||
|
||||
@Slot(result="QVariantMap")
|
||||
def summary(self):
|
||||
return self.stats.summary(last=int(self.stats.refresh)) if self.stats else {} # last ~1s
|
||||
|
||||
@Slot()
|
||||
def reset(self):
|
||||
if self.stats is not None:
|
||||
self.stats.reset()
|
||||
|
||||
@Slot(float)
|
||||
def miss(self, dt_ms):
|
||||
if self.stats is not None:
|
||||
self.stats.miss(dt_ms)
|
||||
|
||||
@Slot(bool)
|
||||
def setHooks(self, on):
|
||||
if self.stats is None:
|
||||
return
|
||||
if on and not self.stats._hooks:
|
||||
self.stats.connect_hooks()
|
||||
elif not on:
|
||||
self.stats.disconnect_hooks()
|
||||
|
||||
bridge = StatsBridge()
|
||||
ctx.setContextProperty("frameStats", bridge)
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "qml", "StemRoller", "Main.qml")))
|
||||
if not engine.rootObjects():
|
||||
print("failed to load QML", file=sys.stderr)
|
||||
controller.shutdown()
|
||||
return 1
|
||||
window = engine.rootObjects()[0]
|
||||
if args.size:
|
||||
w, h = (int(v) for v in args.size.split("x"))
|
||||
window.resize(w, h)
|
||||
|
||||
screen = window.screen()
|
||||
stats = FrameStats(window, screen.refreshRate() if screen else 240.0)
|
||||
bridge.stats = stats
|
||||
# Render-thread hooks need the GIL; only attach once the event loop owns the
|
||||
# GUI thread (Python calls that wait on the render thread would deadlock).
|
||||
if args.frame_overlay or args.bench or controller.settings.get("frameOverlay"):
|
||||
QTimer.singleShot(0, lambda: stats._hooks or stats.connect_hooks())
|
||||
window.screenChanged.connect(lambda sc: setattr(stats, "refresh", sc.refreshRate() if sc else stats.refresh))
|
||||
|
||||
|
||||
if args.autoplay:
|
||||
audio.loadedChanged.connect(lambda: audio.loaded and QTimer.singleShot(300, audio.play))
|
||||
|
||||
if args.files:
|
||||
QTimer.singleShot(0, lambda: controller.addUrls([os.path.abspath(f) for f in args.files]))
|
||||
|
||||
if args.bench:
|
||||
def start_bench():
|
||||
stats.reset()
|
||||
QTimer.singleShot(int(args.bench * 1000), finish_bench)
|
||||
|
||||
def finish_bench():
|
||||
s = stats.summary()
|
||||
s["worker_state"] = controller._get_worker_state()
|
||||
s["misses_at"] = [(round(t - stats.t0, 3), d) for t, d in stats.misses if t >= stats.t0]
|
||||
out = json.dumps(s, indent=2)
|
||||
print(out, flush=True)
|
||||
if args.bench_out:
|
||||
with open(args.bench_out, "w") as f:
|
||||
f.write(out)
|
||||
stats.disconnect_hooks()
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(int(args.bench_delay * 1000), start_bench)
|
||||
|
||||
for shot in args.screenshot:
|
||||
path, _, after = shot.rpartition("@")
|
||||
def grab(path=path):
|
||||
# grabWindow blocks on the render thread while holding the GIL, so the
|
||||
# render-thread hooks must be gone first. A hook call already in flight
|
||||
# can still be waiting for the GIL after disconnect; yield to the event
|
||||
# loop for a few frames so it drains before blocking.
|
||||
hooks = stats._hooks
|
||||
stats.disconnect_hooks()
|
||||
|
||||
def do_grab():
|
||||
window.grabWindow().save(path)
|
||||
if hooks:
|
||||
stats.connect_hooks()
|
||||
print("screenshot saved", path, flush=True)
|
||||
QTimer.singleShot(100, do_grab)
|
||||
QTimer.singleShot(int(float(after) * 1000), grab)
|
||||
|
||||
signal.signal(signal.SIGINT, lambda *_: app.quit())
|
||||
# Let Python see SIGINT while Qt's loop runs, without a fixed-rate redraw timer.
|
||||
keepalive = QTimer()
|
||||
keepalive.start(500)
|
||||
keepalive.timeout.connect(lambda: None)
|
||||
|
||||
app.aboutToQuit.connect(stats.disconnect_hooks)
|
||||
app.aboutToQuit.connect(controller.shutdown)
|
||||
rc = app.exec()
|
||||
del engine
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#5FC8FF"/>
|
||||
<stop offset="1" stop-color="#1D5FB8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.05"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
|
||||
<rect x="16.5" y="16.5" width="223" height="223" rx="55.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="3"/>
|
||||
<rect x="62" y="70" width="22" height="116" rx="11" fill="#FF7AB6"/>
|
||||
<rect x="98" y="52" width="22" height="152" rx="11" fill="#FFB547"/>
|
||||
<rect x="134" y="88" width="22" height="80" rx="11" fill="#7C8CFF"/>
|
||||
<rect x="170" y="66" width="22" height="124" rx="11" fill="#4CD07D"/>
|
||||
<path d="M72 18 h112 a54 54 0 0 1 54 54 v18 c-60 22 -150 22 -220 0 v-18 a54 54 0 0 1 54 -54z" fill="url(#gloss)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,307 @@
|
||||
"""Stem playback: mixes loaded stems with gain/solo/mute and a master volume in a PortAudio callback.
|
||||
|
||||
The callback thread owns mixing; the UI reads position and meter levels once
|
||||
per frame via snapshot(), which interpolates from a small ring of per-block
|
||||
DAC timestamps so the playhead stays smooth at 240Hz.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import soundfile as sf
|
||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||
|
||||
RING = 64
|
||||
BLOCK = 256
|
||||
|
||||
|
||||
class AudioEngine(QObject):
|
||||
playingChanged = Signal()
|
||||
loadedChanged = Signal()
|
||||
volumeChanged = Signal()
|
||||
durationChanged = Signal()
|
||||
loadFailed = Signal(str)
|
||||
_loadedInternal = Signal(object, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._stems: list[np.ndarray] = []
|
||||
self._names: list[str] = []
|
||||
self._sr = 44100
|
||||
self._len = 0
|
||||
self._pos = 0
|
||||
self._playing = False
|
||||
self._ended = False
|
||||
self._stream: sd.OutputStream | None = None
|
||||
self._key = None
|
||||
self._load_token = 0
|
||||
self._gain_db: list[float] = []
|
||||
self._solo: list[bool] = []
|
||||
self._mute: list[bool] = []
|
||||
self._target = np.zeros(0, np.float32)
|
||||
self._current = np.zeros(0, np.float32)
|
||||
self._volume = 1.0 # master slider position, 0..1
|
||||
self._master = 1.0 # master gain the callback ramps toward
|
||||
self._master_cur = 1.0
|
||||
self._mix = np.zeros((4096, 2), np.float32)
|
||||
self._tmp = np.zeros((4096, 2), np.float32)
|
||||
# ring of (dac_time, block_start_frame, peakL, peakR); written by the audio thread
|
||||
self._ring = np.zeros((RING, 4), np.float64)
|
||||
self._ring_i = 0
|
||||
self._loadedInternal.connect(self._on_loaded)
|
||||
|
||||
# ---- loading -------------------------------------------------------
|
||||
def load(self, key, stems: list[tuple[str, str]]):
|
||||
"""Load stems [(name, path)] in a background thread; replaces the current set."""
|
||||
if key == self._key and self._stems:
|
||||
return
|
||||
self.unload()
|
||||
self._key = key
|
||||
self._load_token += 1
|
||||
token = self._load_token
|
||||
|
||||
def work():
|
||||
try:
|
||||
arrays, sr = [], None
|
||||
for name, path in stems:
|
||||
data, file_sr = sf.read(path, dtype="float32", always_2d=True)
|
||||
if data.shape[1] == 1:
|
||||
data = np.repeat(data, 2, axis=1)
|
||||
arrays.append(np.ascontiguousarray(data[:, :2]))
|
||||
sr = sr or file_sr
|
||||
n = min(a.shape[0] for a in arrays)
|
||||
self._loadedInternal.emit((token, [s[0] for s in stems], sr), [a[:n] for a in arrays])
|
||||
except Exception as e: # surfaced in the UI as a toast
|
||||
self._loadedInternal.emit((token, None, str(e)), None)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_loaded(self, info, arrays):
|
||||
token, names, sr = info
|
||||
if token != self._load_token:
|
||||
return
|
||||
if names is None:
|
||||
self.loadFailed.emit(sr)
|
||||
return
|
||||
self._names = names
|
||||
self._sr = sr
|
||||
self._stems = arrays
|
||||
self._len = arrays[0].shape[0] if arrays else 0
|
||||
self._pos = 0
|
||||
k = len(arrays)
|
||||
self._gain_db = [0.0] * k
|
||||
self._solo = [False] * k
|
||||
self._mute = [False] * k
|
||||
self._target = np.ones(k, np.float32)
|
||||
self._current = self._target.copy()
|
||||
self._ring[:] = 0
|
||||
self.loadedChanged.emit()
|
||||
self.durationChanged.emit()
|
||||
|
||||
@Slot()
|
||||
def unload(self):
|
||||
self.pause()
|
||||
self._close_stream()
|
||||
self._stems, self._names, self._len, self._pos, self._key = [], [], 0, 0, None
|
||||
self._load_token += 1
|
||||
self.loadedChanged.emit()
|
||||
self.durationChanged.emit()
|
||||
|
||||
# ---- transport -----------------------------------------------------
|
||||
@Slot()
|
||||
def toggle(self):
|
||||
self.pause() if self._playing else self.play()
|
||||
|
||||
@Slot()
|
||||
def play(self):
|
||||
if not self._stems or self._playing:
|
||||
return
|
||||
if self._pos >= self._len - BLOCK:
|
||||
self._pos = 0
|
||||
self._ended = False
|
||||
if self._stream is None or self._stream.samplerate != self._sr:
|
||||
self._close_stream()
|
||||
self._stream = sd.OutputStream(samplerate=self._sr, channels=2, dtype="float32",
|
||||
blocksize=BLOCK, latency="low", callback=self._callback)
|
||||
self._ring[:] = 0
|
||||
self._playing = True
|
||||
self._stream.start()
|
||||
self.playingChanged.emit()
|
||||
|
||||
@Slot()
|
||||
def pause(self):
|
||||
if not self._playing:
|
||||
return
|
||||
self._playing = False
|
||||
if self._stream is not None:
|
||||
# Rewind to what was actually heard, not what was queued to the device.
|
||||
heard = self._interpolated_pos()
|
||||
self._stream.stop()
|
||||
self._pos = int(heard)
|
||||
self.playingChanged.emit()
|
||||
|
||||
@Slot(float)
|
||||
def seek(self, seconds):
|
||||
self._pos = int(max(0.0, min(seconds * self._sr, self._len - 1)))
|
||||
self._ring[:] = 0
|
||||
self._ended = False
|
||||
|
||||
def _close_stream(self):
|
||||
if self._stream is not None:
|
||||
try:
|
||||
self._stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
|
||||
# ---- mixer controls ------------------------------------------------
|
||||
@Slot(int, float)
|
||||
def setGain(self, i, db):
|
||||
if 0 <= i < len(self._gain_db):
|
||||
self._gain_db[i] = db
|
||||
self._recompute()
|
||||
|
||||
@Slot(int, bool)
|
||||
def setSolo(self, i, on):
|
||||
if 0 <= i < len(self._solo):
|
||||
self._solo[i] = on
|
||||
self._recompute()
|
||||
|
||||
@Slot(int, bool)
|
||||
def setMute(self, i, on):
|
||||
if 0 <= i < len(self._mute):
|
||||
self._mute[i] = on
|
||||
self._recompute()
|
||||
|
||||
@Slot(float)
|
||||
def setVolume(self, v):
|
||||
v = max(0.0, min(1.0, float(v)))
|
||||
if v != self._volume:
|
||||
self._volume = v
|
||||
# cubic taper: roughly even loudness steps across the slider
|
||||
self._master = v ** 3
|
||||
self.volumeChanged.emit()
|
||||
|
||||
def _recompute(self):
|
||||
any_solo = any(self._solo)
|
||||
t = np.zeros(len(self._stems), np.float32)
|
||||
for i, db in enumerate(self._gain_db):
|
||||
audible = self._solo[i] if any_solo else not self._mute[i]
|
||||
t[i] = 10 ** (db / 20) if audible else 0.0
|
||||
self._target = t
|
||||
|
||||
def mix_gains(self) -> np.ndarray:
|
||||
return self._target.copy()
|
||||
|
||||
def stem_gain(self, name) -> float:
|
||||
i = self._names.index(name)
|
||||
return 10 ** (self._gain_db[i] / 20)
|
||||
|
||||
def loaded_key(self):
|
||||
return self._key
|
||||
|
||||
def arrays(self):
|
||||
return dict(zip(self._names, self._stems)), self._sr
|
||||
|
||||
# ---- audio thread --------------------------------------------------
|
||||
def _callback(self, outdata, frames, time_info, status):
|
||||
pos = self._pos
|
||||
n = max(0, min(frames, self._len - pos))
|
||||
mix = self._mix[:frames]
|
||||
mix.fill(0)
|
||||
if self._playing and n:
|
||||
target, current = self._target, self._current
|
||||
if len(target) != len(self._stems):
|
||||
target = current = np.zeros(len(self._stems), np.float32)
|
||||
ramp = None
|
||||
for i, stem in enumerate(self._stems):
|
||||
g0, g1 = current[i], target[i]
|
||||
if g0 == 0 and g1 == 0:
|
||||
continue
|
||||
tmp = self._tmp[:n]
|
||||
if g0 == g1:
|
||||
np.multiply(stem[pos:pos + n], g1, out=tmp)
|
||||
else: # click-free gain change across one block
|
||||
if ramp is None:
|
||||
ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
|
||||
np.multiply(stem[pos:pos + n], g0 + (g1 - g0) * ramp, out=tmp)
|
||||
mix[:n] += tmp
|
||||
self._current = target.copy()
|
||||
m0, m1 = self._master_cur, self._master
|
||||
if m0 != m1:
|
||||
if ramp is None:
|
||||
ramp = np.linspace(0.0, 1.0, n, dtype=np.float32)[:, None]
|
||||
mix[:n] *= m0 + (m1 - m0) * ramp
|
||||
self._master_cur = m1
|
||||
elif m1 != 1.0:
|
||||
mix[:n] *= m1
|
||||
self._pos = pos + n
|
||||
outdata[:] = mix
|
||||
peak = np.abs(mix).max(axis=0) if frames else (0.0, 0.0)
|
||||
dac = time_info.outputBufferDacTime
|
||||
if dac <= 0: # some host APIs leave it unset; fall back to the stream clock
|
||||
dac = self._stream.time + self._stream.latency
|
||||
r = self._ring[self._ring_i % RING]
|
||||
r[0] = dac
|
||||
r[1] = pos
|
||||
r[2], r[3] = peak[0], peak[1]
|
||||
self._ring_i += 1
|
||||
if self._playing and pos + n >= self._len:
|
||||
self._ended = True
|
||||
|
||||
def _played(self, now):
|
||||
ring = self._ring
|
||||
return ring[(ring[:, 0] > 0) & (ring[:, 0] <= now)]
|
||||
|
||||
def _interpolated_pos(self):
|
||||
if self._stream is None:
|
||||
return self._pos
|
||||
now = self._stream.time
|
||||
played = self._played(now)
|
||||
if not len(played):
|
||||
return self._pos if not self._playing else max(0, self._pos - BLOCK * 2)
|
||||
last = played[np.argmax(played[:, 0])]
|
||||
return min(self._len, last[1] + (now - last[0]) * self._sr)
|
||||
|
||||
# ---- per-frame UI read ---------------------------------------------
|
||||
@Slot(result="QVariantList")
|
||||
def snapshot(self):
|
||||
"""[position_seconds, peakL, peakR, playing] for the current frame."""
|
||||
if not self._stems:
|
||||
return [0.0, 0.0, 0.0, False]
|
||||
if not self._playing or self._stream is None:
|
||||
return [float(self._pos) / self._sr, 0.0, 0.0, False]
|
||||
now = self._stream.time
|
||||
played = self._played(now)
|
||||
if len(played):
|
||||
last = played[np.argmax(played[:, 0])]
|
||||
pos = min(self._len, last[1] + (now - last[0]) * self._sr)
|
||||
l, r = float(last[2]), float(last[3])
|
||||
else:
|
||||
pos, l, r = self._pos, 0.0, 0.0
|
||||
if self._ended and pos >= self._len - 1:
|
||||
self._playing = False
|
||||
self._stream.stop()
|
||||
self._pos = 0
|
||||
self.playingChanged.emit()
|
||||
return [0.0, 0.0, 0.0, False]
|
||||
return [float(pos) / self._sr, l, r, True]
|
||||
|
||||
# ---- properties ----------------------------------------------------
|
||||
def _get_playing(self):
|
||||
return self._playing
|
||||
|
||||
def _get_loaded(self):
|
||||
return bool(self._stems)
|
||||
|
||||
def _get_duration(self):
|
||||
return self._len / self._sr if self._stems else 0.0
|
||||
|
||||
def _get_volume(self):
|
||||
return self._volume
|
||||
|
||||
playing = Property(bool, _get_playing, notify=playingChanged)
|
||||
loaded = Property(bool, _get_loaded, notify=loadedChanged)
|
||||
duration = Property(float, _get_duration, notify=durationChanged)
|
||||
volume = Property(float, _get_volume, setVolume, notify=volumeChanged)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Model catalog and stem metadata shared by the UI and the worker.
|
||||
|
||||
Kept free of torch imports so the UI process stays light.
|
||||
"""
|
||||
|
||||
DEMUCS_4 = ["drums", "bass", "other", "vocals"]
|
||||
DEMUCS_6 = ["drums", "bass", "other", "vocals", "guitar", "piano"]
|
||||
|
||||
MODELS = [
|
||||
{"id": "htdemucs", "label": "HT Demucs", "family": "demucs",
|
||||
"stems": DEMUCS_4, "note": "Fast 4-stem"},
|
||||
{"id": "htdemucs_ft", "label": "HT Demucs FT", "family": "demucs",
|
||||
"stems": DEMUCS_4, "note": "Fine-tuned 4-stem, ~4x slower"},
|
||||
{"id": "htdemucs_6s", "label": "HT Demucs 6-stem", "family": "demucs",
|
||||
"stems": DEMUCS_6, "note": "Adds guitar and piano"},
|
||||
{"id": "roformer_bs_vocals", "label": "BS-RoFormer Vocals", "family": "roformer",
|
||||
"file": "model_bs_roformer_ep_317_sdr_12.9755.ckpt",
|
||||
"stems": ["vocals", "instrumental"], "note": "Viperx 1297, vocals / instrumental"},
|
||||
{"id": "roformer_mel_kim_ft", "label": "Mel-RoFormer Vocals FT", "family": "roformer",
|
||||
"file": "mel_band_roformer_kim_ft_unwa.ckpt",
|
||||
"stems": ["vocals", "instrumental"], "note": "Kim FT by unwa, cleanest vocals"},
|
||||
{"id": "roformer_bs_sw", "label": "BS-RoFormer SW 6-stem", "family": "roformer",
|
||||
"file": "BS-Roformer-SW.ckpt",
|
||||
"stems": DEMUCS_6, "note": "jarredou, 6-stem RoFormer"},
|
||||
]
|
||||
|
||||
MODELS_BY_ID = {m["id"]: m for m in MODELS}
|
||||
|
||||
STEM_COLORS = {
|
||||
"vocals": "#FF7AB6",
|
||||
"drums": "#FFB547",
|
||||
"bass": "#7C8CFF",
|
||||
"other": "#4CD07D",
|
||||
"guitar": "#FF8A5C",
|
||||
"piano": "#B48CFF",
|
||||
"instrumental": "#3DD6C6",
|
||||
}
|
||||
|
||||
# Lane order in the UI, regardless of the order a model emits stems in.
|
||||
STEM_ORDER = ["vocals", "drums", "bass", "guitar", "piano", "other", "instrumental"]
|
||||
|
||||
|
||||
def stems_for(model_id: str, two_stems: bool) -> list[str]:
|
||||
stems = MODELS_BY_ID[model_id]["stems"]
|
||||
if two_stems and "vocals" in stems:
|
||||
return ["vocals", "instrumental"]
|
||||
return stems
|
||||
|
||||
|
||||
def sort_stems(names):
|
||||
return sorted(names, key=lambda n: STEM_ORDER.index(n) if n in STEM_ORDER else 99)
|
||||
@@ -0,0 +1,542 @@
|
||||
"""App controller: queue, dispatch to the worker, cache, export. Exposed to QML as `app`."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from .catalog import MODELS, MODELS_BY_ID, STEM_COLORS, sort_stems
|
||||
from .jobs_model import Job, JobsModel
|
||||
from .settings import Settings
|
||||
from .worker_client import WorkerClient
|
||||
|
||||
CACHE_ROOT = os.path.join(os.path.expanduser("~/.cache/stemroller"), "stems")
|
||||
AUDIO_EXTS = {".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".aif", ".aiff",
|
||||
".wma", ".mp4", ".mkv", ".webm", ".mka", ".alac", ".wv", ".ape"}
|
||||
|
||||
|
||||
def _cpu_name():
|
||||
try:
|
||||
with open("/proc/cpuinfo") as f:
|
||||
for line in f:
|
||||
if line.startswith("model name"):
|
||||
return line.split(":", 1)[1].strip().replace(" 8-Core Processor", "")
|
||||
except OSError:
|
||||
pass
|
||||
return "CPU"
|
||||
|
||||
|
||||
def _fmt_mb(mb):
|
||||
return f"{mb / 1024:.1f} GB"
|
||||
|
||||
|
||||
def _dir_size(path):
|
||||
total = 0
|
||||
for root, _, files in os.walk(path):
|
||||
for f in files:
|
||||
try:
|
||||
total += os.path.getsize(os.path.join(root, f))
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
class Controller(QObject):
|
||||
selectedChanged = Signal()
|
||||
devicesChanged = Signal()
|
||||
vramChanged = Signal()
|
||||
workerStateChanged = Signal()
|
||||
cacheChanged = Signal()
|
||||
toast = Signal(str, str, str) # message, kind (info|success|error), folder to open ("" = none)
|
||||
_exportDone = Signal(str, str, str)
|
||||
_cacheSized = Signal(int)
|
||||
|
||||
def __init__(self, audio, peak_store, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = Settings(self)
|
||||
self.jobs = JobsModel(self)
|
||||
self.audio = audio
|
||||
self.peak_store = peak_store
|
||||
self._selected_id = ""
|
||||
self._set_running("")
|
||||
self._devices = [{"id": "auto", "label": "Auto", "sub": "Starting engine…"},
|
||||
{"id": "cpu", "label": "CPU", "sub": _cpu_name()}]
|
||||
self._gpus = []
|
||||
self._vram = {} # device -> (free_mb, total_mb)
|
||||
self._cache_bytes = 0
|
||||
self._exporting = False
|
||||
|
||||
self.worker = WorkerClient(self)
|
||||
self.worker.stateChanged.connect(lambda _: (self.workerStateChanged.emit(), self._dispatch()))
|
||||
self.worker.hello.connect(self._on_hello)
|
||||
self.worker.vram.connect(self._on_vram)
|
||||
self.worker.progress.connect(self._on_progress)
|
||||
self.worker.done.connect(self._on_done)
|
||||
self.worker.failed.connect(self._on_failed)
|
||||
self.worker.cancelled.connect(self._on_cancelled)
|
||||
self.worker.crashed.connect(self._on_crashed)
|
||||
self._exportDone.connect(self._on_export_done)
|
||||
self._cacheSized.connect(self._on_cache_sized)
|
||||
self.settings.deviceIdChanged.connect(self.vramChanged)
|
||||
self.settings.modelIdChanged.connect(self.selectedChanged)
|
||||
self.settings.twoStemsChanged.connect(self.selectedChanged)
|
||||
self.settings.outputModeChanged.connect(self.selectedChanged)
|
||||
self.settings.stemsDirChanged.connect(self.selectedChanged)
|
||||
self.audio.loadedChanged.connect(self.selectedChanged)
|
||||
self.audio.loadFailed.connect(lambda m: self.toast.emit(f"Could not load stems: {m}", "error", ""))
|
||||
self.audio.setVolume(self.settings.get("masterVolume"))
|
||||
self.worker.start()
|
||||
self.refreshCacheSize()
|
||||
|
||||
# ---- worker events -------------------------------------------------
|
||||
def _on_hello(self, msg):
|
||||
self._gpus = msg["devices"]
|
||||
devs = []
|
||||
if self._gpus:
|
||||
best = max(self._gpus, key=lambda d: d["total_mb"])
|
||||
devs.append({"id": "auto", "label": "Auto", "sub": best["name"]})
|
||||
for d in self._gpus:
|
||||
devs.append({"id": d["id"], "label": d["name"].replace("AMD ", ""),
|
||||
"sub": f"{d['arch']} · {_fmt_mb(d['total_mb'])}"})
|
||||
else:
|
||||
devs.append({"id": "auto", "label": "Auto", "sub": "No GPU found, using CPU"})
|
||||
devs.append({"id": "cpu", "label": "CPU", "sub": _cpu_name()})
|
||||
self._devices = devs
|
||||
self.devicesChanged.emit()
|
||||
self.selectedChanged.emit()
|
||||
|
||||
def _on_vram(self, device, free_mb, total_mb):
|
||||
self._vram[device] = (free_mb, total_mb)
|
||||
if device == self._effective_gpu():
|
||||
self.vramChanged.emit()
|
||||
|
||||
def _on_progress(self, job_id, pct, stage):
|
||||
job = self.jobs.get(job_id)
|
||||
if job and job.status == "running":
|
||||
self.jobs.update(job_id, progress=max(job.progress, pct), stage=stage)
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
|
||||
def _on_done(self, job_id, result):
|
||||
job = self.jobs.get(job_id)
|
||||
self._set_running("")
|
||||
if job:
|
||||
self.jobs.update(job_id, status="done", progress=1.0, stage="Done", result=result,
|
||||
elapsed=time.monotonic() - job.started)
|
||||
if job_id == self._selected_id:
|
||||
self._load_selected()
|
||||
self.selectedChanged.emit()
|
||||
if self.settings.get("autoExport"):
|
||||
self._export(job, [s["name"] for s in result["stems"]], mix=False, auto=True)
|
||||
self._prune_cache()
|
||||
self._dispatch()
|
||||
|
||||
def _on_failed(self, job_id, msg):
|
||||
if job_id == self._running_id:
|
||||
self._set_running("")
|
||||
job = self.jobs.get(job_id)
|
||||
if job:
|
||||
self.jobs.update(job_id, status="error", stage="Failed", message=msg)
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
self.toast.emit(f"{job.file_name}: {msg}", "error", "")
|
||||
self._dispatch()
|
||||
|
||||
def _on_cancelled(self, job_id):
|
||||
if job_id == self._running_id:
|
||||
self._set_running("")
|
||||
if self.jobs.get(job_id):
|
||||
self.jobs.update(job_id, status="cancelled", stage="Cancelled", progress=0.0)
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
self._dispatch()
|
||||
|
||||
def _on_crashed(self):
|
||||
if self._running_id:
|
||||
self._on_failed(self._running_id, "The separation engine crashed; restarting it.")
|
||||
|
||||
def _set_running(self, job_id):
|
||||
self._running_id = job_id
|
||||
self.workerStateChanged.emit()
|
||||
|
||||
# ---- queue ---------------------------------------------------------
|
||||
def _cache_dir(self, path, model_id, two_stems):
|
||||
st = os.stat(path)
|
||||
key = hashlib.sha1(f"{os.path.realpath(path)}|{st.st_size}|{st.st_mtime_ns}".encode()).hexdigest()[:16]
|
||||
return os.path.join(CACHE_ROOT, f"{key}_{model_id}{'_2s' if two_stems else ''}")
|
||||
|
||||
def _enqueue(self, path):
|
||||
model_id = self.settings.get("modelId")
|
||||
if model_id not in MODELS_BY_ID:
|
||||
model_id = "htdemucs"
|
||||
two = self.settings.get("twoStems") and "vocals" in MODELS_BY_ID[model_id]["stems"] \
|
||||
and len(MODELS_BY_ID[model_id]["stems"]) > 2
|
||||
cache_dir = self._cache_dir(path, model_id, two)
|
||||
job = Job(id=uuid.uuid4().hex[:12], file=path, file_name=os.path.basename(path),
|
||||
model_id=model_id, model_label=MODELS_BY_ID[model_id]["label"] + (" · 2-stem" if two else ""),
|
||||
two_stems=two, device_id=self.settings.get("deviceId"), cache_dir=cache_dir)
|
||||
meta_path = os.path.join(cache_dir, "meta.json")
|
||||
if os.path.exists(meta_path):
|
||||
try:
|
||||
with open(meta_path) as f:
|
||||
meta = json.load(f)
|
||||
if all(os.path.exists(s["path"]) for s in meta["stems"]):
|
||||
os.utime(cache_dir) # LRU touch
|
||||
job.status, job.progress, job.stage = "done", 1.0, "Done (cached)"
|
||||
job.result = {k: meta[k] for k in ("stems", "peaks_path", "samplerate", "duration")}
|
||||
job.result["device"] = meta.get("device", "")
|
||||
except (OSError, ValueError, KeyError):
|
||||
pass
|
||||
self.jobs.add(job)
|
||||
return job
|
||||
|
||||
@Slot("QVariantList")
|
||||
def addUrls(self, urls):
|
||||
paths = []
|
||||
for u in urls:
|
||||
if isinstance(u, QUrl):
|
||||
p = u.toLocalFile()
|
||||
else:
|
||||
p = QUrl(u).toLocalFile() if str(u).startswith("file:") else str(u)
|
||||
if os.path.isdir(p):
|
||||
for root, _, files in os.walk(p):
|
||||
paths += [os.path.join(root, f) for f in sorted(files)]
|
||||
else:
|
||||
paths.append(p)
|
||||
paths = [p for p in paths if os.path.splitext(p)[1].lower() in AUDIO_EXTS and os.path.isfile(p)]
|
||||
if not paths:
|
||||
self.toast.emit("No audio files in that drop.", "info", "")
|
||||
return
|
||||
first = None
|
||||
for p in paths:
|
||||
job = self._enqueue(p)
|
||||
first = first or job
|
||||
if not self._selected_id or not self.jobs.get(self._selected_id) or len(paths) == 1:
|
||||
self.select(first.id)
|
||||
self._dispatch()
|
||||
|
||||
def _dispatch(self):
|
||||
if self._running_id or self.worker.state != "ready":
|
||||
return
|
||||
job = next((j for j in self.jobs.jobs if j.status == "queued"), None)
|
||||
if not job:
|
||||
return
|
||||
s = self.settings
|
||||
ok = self.worker.send("start", id=job.id, file=job.file, model=job.model_id, device=job.device_id,
|
||||
two_stems=job.two_stems, out_dir=job.cache_dir, segment=s.get("segment"),
|
||||
shifts=s.get("shifts"), overlap=s.get("overlap"), fp16=s.get("fp16"))
|
||||
if ok:
|
||||
self._set_running(job.id)
|
||||
self.jobs.update(job.id, status="running", stage="Starting", progress=0.0, message="",
|
||||
started=time.monotonic())
|
||||
if job.id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
|
||||
@Slot(str)
|
||||
def cancelJob(self, job_id):
|
||||
job = self.jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.status == "running":
|
||||
self.jobs.update(job_id, stage="Cancelling")
|
||||
self.worker.send("cancel", id=job_id)
|
||||
elif job.status == "queued":
|
||||
self.jobs.update(job_id, status="cancelled", stage="Cancelled")
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
|
||||
@Slot(str)
|
||||
def removeJob(self, job_id):
|
||||
job = self.jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.status == "running":
|
||||
self.cancelJob(job_id)
|
||||
row = self.jobs.row_of(job_id)
|
||||
self.jobs.remove(job_id)
|
||||
self.peak_store.drop(job_id)
|
||||
if job_id == self._selected_id:
|
||||
nxt = self.jobs.jobs[min(row, len(self.jobs.jobs) - 1)].id if self.jobs.jobs else ""
|
||||
self.select(nxt)
|
||||
|
||||
@Slot(str)
|
||||
def retryJob(self, job_id):
|
||||
job = self.jobs.get(job_id)
|
||||
if job and job.status in ("error", "cancelled"):
|
||||
self.jobs.update(job_id, status="queued", stage="Waiting", progress=0.0, message="")
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
self._dispatch()
|
||||
|
||||
@Slot()
|
||||
def rerunSelected(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job:
|
||||
new = self._enqueue(job.file)
|
||||
self.select(new.id)
|
||||
self._dispatch()
|
||||
|
||||
@Slot(str)
|
||||
def select(self, job_id):
|
||||
if job_id == self._selected_id:
|
||||
return
|
||||
self._selected_id = job_id
|
||||
self._load_selected()
|
||||
self.selectedChanged.emit()
|
||||
|
||||
def _load_selected(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job and job.status == "done":
|
||||
stems = sort_stems([s["name"] for s in job.result["stems"]])
|
||||
by_name = {s["name"]: s["path"] for s in job.result["stems"]}
|
||||
self.audio.load(job.id, [(n, by_name[n]) for n in stems])
|
||||
else:
|
||||
self.audio.unload()
|
||||
|
||||
# ---- export --------------------------------------------------------
|
||||
def _target_dir(self, job):
|
||||
base = os.path.splitext(job.file_name)[0]
|
||||
if self.settings.get("outputMode") == "folder":
|
||||
return os.path.join(self.settings.get("stemsDir"), base)
|
||||
return os.path.join(os.path.dirname(job.file), f"{base} - stems")
|
||||
|
||||
def _export(self, job, names, mix, auto=False):
|
||||
if self._exporting:
|
||||
self.toast.emit("An export is already running.", "info", "")
|
||||
return
|
||||
self._exporting = True
|
||||
fmt = self.settings.get("exportFormat")
|
||||
target = self._target_dir(job)
|
||||
base = os.path.splitext(job.file_name)[0]
|
||||
loaded = self.audio.loaded_key() == job.id and self.audio.loaded
|
||||
if loaded:
|
||||
arrays, sr = self.audio.arrays()
|
||||
gains = {n: self.audio.stem_gain(n) for n in arrays}
|
||||
mix_gains = dict(zip(arrays.keys(), self.audio.mix_gains()))
|
||||
else:
|
||||
arrays, sr, gains, mix_gains = None, job.result["samplerate"], {}, {}
|
||||
paths = {s["name"]: s["path"] for s in job.result["stems"]}
|
||||
|
||||
def read(name):
|
||||
if arrays is not None and name in arrays:
|
||||
return arrays[name]
|
||||
data, _ = sf.read(paths[name], dtype="float32", always_2d=True)
|
||||
return data
|
||||
|
||||
def write(path, data):
|
||||
if fmt == "mp3":
|
||||
sf.write(path, np.clip(data, -1, 1), sr, format="MP3", subtype="MPEG_LAYER_III",
|
||||
bitrate_mode="CONSTANT", compression_level=0.0)
|
||||
elif fmt == "flac":
|
||||
sf.write(path, np.clip(data, -1, 1), sr, format="FLAC", subtype="PCM_24")
|
||||
else:
|
||||
subtype = "FLOAT" if np.abs(data).max() > 0.999 else "PCM_24"
|
||||
sf.write(path, data, sr, format="WAV", subtype=subtype)
|
||||
|
||||
def work():
|
||||
try:
|
||||
os.makedirs(target, exist_ok=True)
|
||||
if mix:
|
||||
out = None
|
||||
for n in paths:
|
||||
g = mix_gains.get(n, 1.0)
|
||||
if g:
|
||||
d = read(n) * g
|
||||
out = d if out is None else out + d
|
||||
write(os.path.join(target, f"{base} - mix.{fmt}"), out if out is not None else read(next(iter(paths))) * 0)
|
||||
msg = "Exported the current mix"
|
||||
else:
|
||||
for n in names:
|
||||
g = gains.get(n, 1.0)
|
||||
d = read(n)
|
||||
write(os.path.join(target, f"{base} - {n}.{fmt}"), d * g if g != 1.0 else d)
|
||||
msg = f"Exported {len(names)} stem{'s' if len(names) != 1 else ''}" if not auto else \
|
||||
f"Auto-exported {len(names)} stems"
|
||||
self._exportDone.emit(job.id, f"{msg} to {self._pretty(target)}", target)
|
||||
except Exception as e:
|
||||
self._exportDone.emit(job.id, f"Export failed: {e}", "")
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_export_done(self, job_id, msg, target):
|
||||
self._exporting = False
|
||||
if target and self.jobs.get(job_id):
|
||||
self.jobs.update(job_id, exported_to=target)
|
||||
if job_id == self._selected_id:
|
||||
self.selectedChanged.emit()
|
||||
self.toast.emit(msg, "success" if target else "error", target)
|
||||
|
||||
@staticmethod
|
||||
def _pretty(path):
|
||||
home = os.path.expanduser("~")
|
||||
return "~" + path[len(home):] if path.startswith(home) else path
|
||||
|
||||
@Slot()
|
||||
def exportAll(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job and job.status == "done":
|
||||
self._export(job, sort_stems([s["name"] for s in job.result["stems"]]), mix=False)
|
||||
|
||||
@Slot(str)
|
||||
def exportStem(self, name):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job and job.status == "done":
|
||||
self._export(job, [name], mix=False)
|
||||
|
||||
@Slot()
|
||||
def exportMix(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job and job.status == "done":
|
||||
self._export(job, [], mix=True)
|
||||
|
||||
@Slot(str)
|
||||
def openFolder(self, path):
|
||||
if path and os.path.isdir(path):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
||||
|
||||
@Slot()
|
||||
def openTargetFolder(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if job:
|
||||
t = self._target_dir(job)
|
||||
self.openFolder(t if os.path.isdir(t) else os.path.dirname(t))
|
||||
|
||||
# ---- cache ---------------------------------------------------------
|
||||
def _referenced(self):
|
||||
return {os.path.realpath(j.cache_dir) for j in self.jobs.jobs}
|
||||
|
||||
def _prune_cache(self):
|
||||
limit = self.settings.get("cacheLimitGb") * 2**30
|
||||
refs = self._referenced()
|
||||
|
||||
def work():
|
||||
if not os.path.isdir(CACHE_ROOT):
|
||||
self._cacheSized.emit(0)
|
||||
return
|
||||
entries = []
|
||||
for d in os.listdir(CACHE_ROOT):
|
||||
p = os.path.join(CACHE_ROOT, d)
|
||||
entries.append((os.path.getmtime(p), _dir_size(p), p))
|
||||
total = sum(e[1] for e in entries)
|
||||
for _, size, p in sorted(entries):
|
||||
if total <= limit:
|
||||
break
|
||||
if os.path.realpath(p) not in refs:
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
total -= size
|
||||
self._cacheSized.emit(total)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
@Slot()
|
||||
def refreshCacheSize(self):
|
||||
threading.Thread(target=lambda: self._cacheSized.emit(_dir_size(CACHE_ROOT) if os.path.isdir(CACHE_ROOT) else 0),
|
||||
daemon=True).start()
|
||||
|
||||
@Slot()
|
||||
def clearCache(self):
|
||||
refs = self._referenced()
|
||||
if os.path.isdir(CACHE_ROOT):
|
||||
for d in os.listdir(CACHE_ROOT):
|
||||
p = os.path.join(CACHE_ROOT, d)
|
||||
if os.path.realpath(p) not in refs:
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
self.refreshCacheSize()
|
||||
self.toast.emit("Cleared cached stems not in the queue.", "info", "")
|
||||
|
||||
def _on_cache_sized(self, n):
|
||||
self._cache_bytes = n
|
||||
self.cacheChanged.emit()
|
||||
|
||||
# ---- QML-facing properties -----------------------------------------
|
||||
def _effective_gpu(self):
|
||||
dev = self.settings.get("deviceId")
|
||||
if dev == "cpu" or not self._gpus:
|
||||
return ""
|
||||
if dev == "auto" or not any(g["id"] == dev for g in self._gpus):
|
||||
return max(self._gpus, key=lambda d: d["total_mb"])["id"]
|
||||
return dev
|
||||
|
||||
def peaks_path_for(self, job_id):
|
||||
job = self.jobs.get(job_id)
|
||||
return job.result.get("peaks_path") if job and job.status == "done" else None
|
||||
|
||||
def _get_selected(self):
|
||||
job = self.jobs.get(self._selected_id)
|
||||
if not job:
|
||||
return {"valid": False}
|
||||
stems = []
|
||||
if job.status == "done":
|
||||
stems = [{"name": n, "color": STEM_COLORS.get(n, "#9B9DA3")}
|
||||
for n in sort_stems([s["name"] for s in job.result["stems"]])]
|
||||
dev = job.result.get("device", "") if job.result else ""
|
||||
dev_label = "CPU" if dev == "cpu" else next((g["name"].replace("AMD ", "") for g in self._gpus if g["id"] == dev), dev)
|
||||
return {
|
||||
"valid": True, "id": job.id, "fileName": job.file_name, "filePath": job.file,
|
||||
"folder": self._pretty(os.path.dirname(job.file)),
|
||||
"modelId": job.model_id, "modelLabel": job.model_label, "status": job.status,
|
||||
"progress": job.progress, "stage": job.stage, "message": job.message,
|
||||
"elapsed": job.elapsed, "duration": job.result.get("duration", 0.0) if job.result else 0.0,
|
||||
"deviceLabel": dev_label, "stems": stems,
|
||||
"audioReady": self.audio.loaded and self.audio.loaded_key() == job.id,
|
||||
"target": self._pretty(self._target_dir(job)), "exportedTo": job.exported_to,
|
||||
"rerunLabel": self._rerun_label(job),
|
||||
}
|
||||
|
||||
def _rerun_label(self, job):
|
||||
m = self.settings.get("modelId")
|
||||
two = self.settings.get("twoStems") and len(MODELS_BY_ID.get(m, {"stems": []})["stems"]) > 2
|
||||
if m == job.model_id and two == job.two_stems:
|
||||
return ""
|
||||
return MODELS_BY_ID[m]["label"] + (" · 2-stem" if two else "")
|
||||
|
||||
def _get_models(self):
|
||||
return [{"id": m["id"], "label": m["label"], "note": m["note"], "family": m["family"],
|
||||
"stems": len(m["stems"])} for m in MODELS]
|
||||
|
||||
def _get_devices(self):
|
||||
return self._devices
|
||||
|
||||
def _get_vram(self):
|
||||
gpu = self._effective_gpu()
|
||||
if not gpu or gpu not in self._vram:
|
||||
return {"valid": False}
|
||||
free, total = self._vram[gpu]
|
||||
return {"valid": True, "free": free, "total": total,
|
||||
"text": f"{_fmt_mb(free)} free of {_fmt_mb(total)}"}
|
||||
|
||||
def _get_worker_state(self):
|
||||
if self.worker.state == "ready" and self._running_id:
|
||||
return "busy"
|
||||
return self.worker.state
|
||||
|
||||
def _get_cache_text(self):
|
||||
return f"{self._cache_bytes / 2**30:.2f} GB"
|
||||
|
||||
def _get_settings(self):
|
||||
return self.settings
|
||||
|
||||
def _get_jobs(self):
|
||||
return self.jobs
|
||||
|
||||
selected = Property("QVariantMap", _get_selected, notify=selectedChanged)
|
||||
models = Property("QVariantList", _get_models, constant=True)
|
||||
devices = Property("QVariantList", _get_devices, notify=devicesChanged)
|
||||
vram = Property("QVariantMap", _get_vram, notify=vramChanged)
|
||||
workerState = Property(str, _get_worker_state, notify=workerStateChanged)
|
||||
cacheText = Property(str, _get_cache_text, notify=cacheChanged)
|
||||
settingsObj = Property(QObject, _get_settings, constant=True)
|
||||
jobsModel = Property(QObject, _get_jobs, constant=True)
|
||||
|
||||
def shutdown(self):
|
||||
self.settings.masterVolume = self.audio.volume
|
||||
self.audio.unload()
|
||||
self.worker.shutdown()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""How busy the GPU is with work that isn't ours.
|
||||
|
||||
Two signals, both read from the kernel (no GPU calls):
|
||||
- DRM fdinfo: per-client gfx/compute engine time. Games and other apps show
|
||||
up here; our ROCm worker runs through KFD and our own UI is excluded by pid.
|
||||
- amdgpu gpu_busy_percent: catches other ROCm/compute users that fdinfo
|
||||
misses, but also counts our worker, so it only means something while our
|
||||
worker is idle.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
_KV = re.compile(r"^(drm-[\w-]+):\s+(\S+)", re.M)
|
||||
_ENGINES = ("drm-engine-gfx", "drm-engine-compute")
|
||||
|
||||
|
||||
class GpuMonitor:
|
||||
def __init__(self):
|
||||
self.pdev = None # "0000:03:00.0"; None counts every GPU
|
||||
self.exclude: set[int] = {os.getpid()}
|
||||
self._last: dict[str, int] = {} # drm-client-id -> engine ns
|
||||
self._last_t = None
|
||||
|
||||
def set_device(self, pci):
|
||||
if pci != self.pdev:
|
||||
self.pdev = pci
|
||||
self._last, self._last_t = {}, None
|
||||
|
||||
def _busy_path(self):
|
||||
if self.pdev:
|
||||
p = f"/sys/bus/pci/devices/{self.pdev}/gpu_busy_percent"
|
||||
return p if os.path.exists(p) else None
|
||||
return None
|
||||
|
||||
def _clients(self):
|
||||
out = {}
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdigit() or int(pid) in self.exclude:
|
||||
continue
|
||||
try:
|
||||
fds = os.listdir(f"/proc/{pid}/fd")
|
||||
except OSError:
|
||||
continue
|
||||
for fd in fds:
|
||||
try:
|
||||
if not os.readlink(f"/proc/{pid}/fd/{fd}").startswith("/dev/dri/"):
|
||||
continue
|
||||
with open(f"/proc/{pid}/fdinfo/{fd}") as f:
|
||||
kv = dict(_KV.findall(f.read()))
|
||||
except OSError:
|
||||
continue
|
||||
cid = kv.get("drm-client-id")
|
||||
if not cid or (self.pdev and kv.get("drm-pdev") != self.pdev):
|
||||
continue
|
||||
# one client can sit behind several dup'd fds; count it once
|
||||
out[cid] = sum(int(kv[k]) for k in _ENGINES if kv.get(k, "").isdigit())
|
||||
return out
|
||||
|
||||
def sample(self, now):
|
||||
"""(other_fraction, busy_percent). other_fraction is engine time used by
|
||||
other processes over the last interval, as a fraction of wall time."""
|
||||
clients = self._clients()
|
||||
other = 0.0
|
||||
if self._last_t is not None and now > self._last_t:
|
||||
delta = sum(ns - self._last[c] for c, ns in clients.items() if c in self._last and ns >= self._last[c])
|
||||
other = delta / ((now - self._last_t) * 1e9)
|
||||
self._last, self._last_t = clients, now
|
||||
pct = -1
|
||||
path = self._busy_path()
|
||||
if path:
|
||||
try:
|
||||
with open(path) as f:
|
||||
pct = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return other, pct
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Queue of separation jobs as a QML list model."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from PySide6.QtCore import QAbstractListModel, QByteArray, QModelIndex, Qt, Signal
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
file: str
|
||||
file_name: str
|
||||
model_id: str
|
||||
model_label: str
|
||||
two_stems: bool
|
||||
device_id: str
|
||||
cache_dir: str
|
||||
status: str = "queued" # queued | running | done | error | cancelled
|
||||
progress: float = 0.0
|
||||
stage: str = "Waiting"
|
||||
message: str = ""
|
||||
started: float = 0.0
|
||||
elapsed: float = 0.0
|
||||
result: dict = field(default_factory=dict)
|
||||
exported_to: str = ""
|
||||
|
||||
|
||||
ROLES = ["jobId", "fileName", "filePath", "modelLabel", "status", "progress", "stage", "message", "elapsed"]
|
||||
_ATTR = {"jobId": "id", "fileName": "file_name", "filePath": "file", "modelLabel": "model_label"}
|
||||
|
||||
|
||||
class JobsModel(QAbstractListModel):
|
||||
countChanged = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.jobs: list[Job] = []
|
||||
self._roles = {Qt.ItemDataRole.UserRole + i: name for i, name in enumerate(ROLES)}
|
||||
|
||||
def roleNames(self):
|
||||
return {k: QByteArray(v.encode()) for k, v in self._roles.items()}
|
||||
|
||||
def rowCount(self, parent=QModelIndex()):
|
||||
return 0 if parent.isValid() else len(self.jobs)
|
||||
|
||||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid() or role not in self._roles:
|
||||
return None
|
||||
name = self._roles[role]
|
||||
return getattr(self.jobs[index.row()], _ATTR.get(name, name))
|
||||
|
||||
def get(self, job_id) -> Job | None:
|
||||
return next((j for j in self.jobs if j.id == job_id), None)
|
||||
|
||||
def row_of(self, job_id) -> int:
|
||||
return next((i for i, j in enumerate(self.jobs) if j.id == job_id), -1)
|
||||
|
||||
def add(self, job: Job):
|
||||
n = len(self.jobs)
|
||||
self.beginInsertRows(QModelIndex(), n, n)
|
||||
self.jobs.append(job)
|
||||
self.endInsertRows()
|
||||
self.countChanged.emit()
|
||||
|
||||
def remove(self, job_id):
|
||||
row = self.row_of(job_id)
|
||||
if row >= 0:
|
||||
self.beginRemoveRows(QModelIndex(), row, row)
|
||||
del self.jobs[row]
|
||||
self.endRemoveRows()
|
||||
self.countChanged.emit()
|
||||
|
||||
def update(self, job_id, **changes):
|
||||
row = self.row_of(job_id)
|
||||
if row < 0:
|
||||
return
|
||||
job = self.jobs[row]
|
||||
for k, v in changes.items():
|
||||
setattr(job, k, v)
|
||||
idx = self.index(row)
|
||||
roles = [r for r, name in self._roles.items() if _ATTR.get(name, name) in changes or name in changes]
|
||||
self.dataChanged.emit(idx, idx, roles)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Library rules for the server daemon (server/stemd.py): which file is a track's source,
|
||||
where its stems go, and what marks it done. stemserve reads the same layout and marker.
|
||||
|
||||
No Qt here, so the server container doesn't need PySide6.
|
||||
|
||||
<src>/Artist/Album/01 Song.flac -> <dst>/Artist/Album/01 Song/01 Song - vocals.flac, ...
|
||||
"""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from .catalog import sort_stems
|
||||
|
||||
MARKER = ".stemroller.json"
|
||||
BEST_MODEL = "roformer_bs_sw"
|
||||
MAX_SECONDS = 40 * 60 # longer files are mixes and radio rips
|
||||
# Lower wins when one folder holds the same track in several formats.
|
||||
SOURCE_RANK = {".flac": 0, ".wav": 0, ".aif": 0, ".aiff": 0, ".wv": 0, ".ape": 0, ".alac": 0,
|
||||
".m4a": 1, ".opus": 2, ".ogg": 2, ".oga": 2, ".mp3": 3, ".aac": 3, ".wma": 4}
|
||||
# Errors that mean the destination as a whole is unusable, not just this track.
|
||||
SHARE_ERRNOS = {errno.ENOSPC, errno.EROFS, errno.EDQUOT, errno.ENOTCONN, errno.EHOSTDOWN,
|
||||
errno.EHOSTUNREACH, errno.ETIMEDOUT, errno.EIO, errno.EACCES}
|
||||
|
||||
|
||||
def duration(path):
|
||||
if os.path.splitext(path)[1].lower() in (".flac", ".wav", ".aif", ".aiff"):
|
||||
try:
|
||||
return sf.info(path).duration
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path],
|
||||
capture_output=True, text=True, timeout=30).stdout
|
||||
return float(out.strip())
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def is_share_error(e):
|
||||
return isinstance(e, OSError) and e.errno in SHARE_ERRNOS
|
||||
|
||||
|
||||
def iter_tracks(src, dst):
|
||||
"""Yield (source path, stems folder) for every track under src, best format per name."""
|
||||
dst_real = os.path.realpath(dst)
|
||||
for root, dirs, files in os.walk(src):
|
||||
dirs[:] = sorted((d for d in dirs if not d.startswith(".")
|
||||
and os.path.realpath(os.path.join(root, d)) != dst_real), key=str.casefold)
|
||||
best = {}
|
||||
for f in files:
|
||||
stem, ext = os.path.splitext(f)
|
||||
rank = SOURCE_RANK.get(ext.lower())
|
||||
if rank is None or f.startswith("."):
|
||||
continue
|
||||
key = stem.casefold()
|
||||
if key not in best or rank < best[key][0]:
|
||||
best[key] = (rank, f)
|
||||
for _, f in sorted(best.values(), key=lambda v: v[1].casefold()):
|
||||
path = os.path.join(root, f)
|
||||
yield path, os.path.join(dst, os.path.splitext(os.path.relpath(path, src))[0])
|
||||
|
||||
|
||||
def is_done(dest):
|
||||
return os.path.exists(os.path.join(dest, MARKER))
|
||||
|
||||
|
||||
def signature(path):
|
||||
"""[size, mtime_ns]: a failed track is retried once its source changes."""
|
||||
st = os.stat(path)
|
||||
return [st.st_size, st.st_mtime_ns]
|
||||
|
||||
|
||||
def write_track(dest, stems, meta):
|
||||
"""Write stems into dest as 24-bit FLAC, then the marker, so a half-written track is redone.
|
||||
|
||||
stems maps name -> a path to read, or float32 (samples, channels) audio; meta needs
|
||||
"samplerate" and is stored in the marker along with the stem list.
|
||||
"""
|
||||
base = os.path.basename(dest)
|
||||
parent = os.path.dirname(dest)
|
||||
part = os.path.join(parent, f".{base}.partial")
|
||||
shutil.rmtree(part, ignore_errors=True)
|
||||
os.makedirs(part)
|
||||
written = []
|
||||
for name in sort_stems(list(stems)):
|
||||
data, sr = stems[name], meta["samplerate"]
|
||||
if isinstance(data, str):
|
||||
data, sr = sf.read(data, dtype="float32", always_2d=True)
|
||||
fname = f"{base} - {name}.flac"
|
||||
sf.write(os.path.join(part, fname), np.clip(data, -1, 1), sr, format="FLAC", subtype="PCM_24")
|
||||
written.append({"name": name, "file": fname})
|
||||
meta = dict(meta, stems=written, finished=time.time())
|
||||
with open(os.path.join(part, MARKER), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
if not os.path.exists(dest):
|
||||
os.rename(part, dest)
|
||||
else: # keep whatever else is in there; the marker lands last
|
||||
for name in [s["file"] for s in written] + [MARKER]:
|
||||
os.replace(os.path.join(part, name), os.path.join(dest, name))
|
||||
os.rmdir(part)
|
||||
@@ -0,0 +1,59 @@
|
||||
# StemRoller Player
|
||||
|
||||
The desktop counterpart to the phone app: your Plex library and its stems, with per-stem
|
||||
faders, mute/solo, mix presets and a master volume. It is dressed as Microsoft Excel 2016.
|
||||
|
||||
```sh
|
||||
stemroller-player # or "StemRoller Player" in the app grid (scripts/install-desktop.sh)
|
||||
```
|
||||
|
||||
## First run
|
||||
|
||||
**Data → Connect to Plex** (or click **Sign in** on the title bar), then enter the 4-character
|
||||
code at plex.tv/link. It picks the music library at `…/Flacs`. Until then the workbook is built
|
||||
from the stem server's index alone (artist / album / song from the stems folders), so every
|
||||
separated song is already playable.
|
||||
|
||||
## Where things are
|
||||
|
||||
| Excel | Here |
|
||||
|---|---|
|
||||
| Sheets: Songs, Artists, Albums, Playlists, Stems, Queue | The library. Double-click (or Enter) plays a song from that point, or opens an artist, album or playlist as a new sheet. Backspace / middle-click closes it |
|
||||
| Row 1 filter buttons, Sort & Filter | Sort by any column |
|
||||
| "Tell me what you want to do" | Search (Ctrl+F). Results open as a sheet |
|
||||
| Status bar Average / Count / Sum | Select a range of times and it adds them up |
|
||||
| Zoom slider | Master volume (scroll on it, too). Ctrl+scroll on the grid is the real zoom |
|
||||
| Home → Cell Styles | Mix presets: Full Mix, Karaoke ("Bad"), A Cappella ("Good"), No Drums, Rhythm, No Guitar, No Bass |
|
||||
| Home → Cells | Play Next, Add to Queue, Delete Rows (queue or playlist) |
|
||||
| Insert → Playlists | New Plex playlist from the selection, or add the selection to one |
|
||||
| AutoSum | AutoShuffle: shuffle the whole sheet |
|
||||
| Quick Access Toolbar | Play/Pause, Undo = previous song, Redo = next song |
|
||||
| Format Mix task pane | Now playing, a sparkline of the song to seek on, and the stems as a column chart: drag a column to set its level, double-click for 0 dB, M / S below |
|
||||
| Green triangle in a cell | The song that's playing |
|
||||
| `#GETTING_DATA` | A playlist loading from Plex |
|
||||
| File → Info / Account / Options | Library and server status, Plex sign-in, server addresses and folders |
|
||||
|
||||
Keys: Space play/pause, Ctrl+←/→ previous/next, V vocals on/off, arrows/Shift+arrows select,
|
||||
Ctrl+C copies cells as text, Ctrl+Q quits. The media keys, the desktop's media controls and
|
||||
KDE Connect on the phone work through MPRIS.
|
||||
|
||||
## How it plays
|
||||
|
||||
- **Stems** come straight off the `Stems` share (`/var/mnt/unraid/Music/Stems`) when it's
|
||||
mounted, else they're downloaded from stemserve once into `~/.cache/stemroller/player/files`
|
||||
(4 GB, oldest first out). Songs without stems play the original from the music share, or from
|
||||
Plex if the share isn't there.
|
||||
- A decoder thread reads every stem about 1.5 s ahead of the playhead, so songs start at once
|
||||
and nothing is decoded whole; the audio callback mixes with click-free gain ramps. The fader
|
||||
taper matches the phone (0.8 = 0 dB, top = +3.9 dB).
|
||||
- Plays are reported to Plex (now playing, and the play count at 90%).
|
||||
- Album art: Plex's, or the album folder's `cover.*`, or the picture embedded in the file.
|
||||
- The queue, position, mix and volume come back on the next launch (`~/.config/stemroller/`).
|
||||
|
||||
## Checking it without a window
|
||||
|
||||
```sh
|
||||
QT_QPA_PLATFORM=offscreen stemroller-player --no-mpris --screenshot /tmp/p.png@5 --quit-after 6
|
||||
```
|
||||
|
||||
`--eval 'JS@seconds'` runs JavaScript against the window (e.g. `library.activate(0, 0)@2`).
|
||||
Whitespace-only changes.
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user