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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user