diff --git a/.gitignore b/.gitignore
index 85d5d92..b2a9db7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ build_android/
build_ios/
build_macos/
build_windows/
+.kotlin
icons
*.png
vamp-plugin/
diff --git a/Cargo.toml b/Cargo.toml
index 6974ce4..812980e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -54,6 +54,10 @@ bytemuck = { version = "1", features = ["derive"] }
rayon = "1.10"
arc-swap = "1.7"
+# settings persistence
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
# desktop shell deps
[target.'cfg(all(not(target_os = "ios"), not(target_os = "android")))'.dependencies]
winit = "0.30"
@@ -62,7 +66,7 @@ rfd = "0.15"
# android shell deps
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
-ndk = "0.9"
+ndk = { version = "0.9", features = ["api-level-28"] }
ndk-context = "0.1"
android_logger = "0.14"
log = "0.4"
diff --git a/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive b/android/.kotlin/sessions/kotlin-compiler-13414114603619315478.salive
deleted file mode 100644
index e69de29..0000000
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index eb5687e..4dffd99 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -23,6 +23,15 @@ android {
getByName("debug") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
}
+ create("release") {
+ val ks = System.getenv("YRXTALS_KEYSTORE")
+ if (ks != null) {
+ storeFile = file(ks)
+ storePassword = System.getenv("YRXTALS_STORE_PASSWORD")
+ keyAlias = System.getenv("YRXTALS_KEY_ALIAS") ?: "yrxtals"
+ keyPassword = System.getenv("YRXTALS_KEY_PASSWORD")
+ }
+ }
}
buildTypes {
@@ -32,7 +41,11 @@ android {
}
getByName("release") {
isMinifyEnabled = false
- signingConfig = signingConfigs.getByName("debug")
+ signingConfig = if (System.getenv("YRXTALS_KEYSTORE") != null) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -42,6 +55,7 @@ android {
initWith(getByName("release"))
isDebuggable = true
isJniDebuggable = true
+ signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 06e9480..6b4afd3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
@@ -23,6 +26,16 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt b/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt
new file mode 100644
index 0000000..57a1cb8
--- /dev/null
+++ b/android/app/src/main/java/org/elseif/yrxtals/CaptureController.kt
@@ -0,0 +1,63 @@
+package org.elseif.yrxtals
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+
+private const val TAG = "YrXtalsCaptureCtrl"
+
+/// gates mic-input start on RECORD_AUDIO and forwards the start/stop calls into the rust AAudio input stream.
+class CaptureController(private val activity: ComponentActivity) {
+
+ var view: IcedSurfaceView? = null
+
+ /// true while the rust AAudio mic input stream is running.
+ var isCapturing: Boolean = false
+ private set
+
+ private val recordPermissionLauncher: ActivityResultLauncher =
+ activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) {
+ startMicNative()
+ } else {
+ Log.w(TAG, "RECORD_AUDIO permission denied")
+ }
+ }
+
+ /// requests RECORD_AUDIO if missing; starts the rust AAudio mic input stream on grant or when already granted.
+ fun start() {
+ if (!hasPermission(Manifest.permission.RECORD_AUDIO)) {
+ recordPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ return
+ }
+ startMicNative()
+ }
+
+ /// stops the rust AAudio mic input stream.
+ fun stop() {
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportStopMicCapture(h)
+ }
+ }
+ isCapturing = false
+ }
+
+ private fun startMicNative() {
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportStartMicCapture(h)
+ }
+ }
+ isCapturing = true
+ }
+
+ private fun hasPermission(name: String): Boolean =
+ ContextCompat.checkSelfPermission(activity, name) == PackageManager.PERMISSION_GRANTED
+}
diff --git a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt
index 81140cf..9f7f45c 100644
--- a/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt
+++ b/android/app/src/main/java/org/elseif/yrxtals/IcedSurfaceView.kt
@@ -1,6 +1,8 @@
package org.elseif.yrxtals
+import android.app.Activity
import android.content.Context
+import android.os.Build
import android.util.AttributeSet
import android.view.Choreographer
import android.view.KeyEvent
@@ -16,6 +18,7 @@ class IcedSurfaceView @JvmOverloads constructor(
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback, Choreographer.FrameCallback {
var controller: LibraryController? = null
+ var captureController: CaptureController? = null
private var handle: Long = 0
private var frameCallbackPosted: Boolean = false
@@ -31,15 +34,29 @@ class IcedSurfaceView @JvmOverloads constructor(
isFocusableInTouchMode = true
}
- /// allocates the rust viewport bound to the new surface, sized in logical dp at the screen density.
+ /// allocates the rust viewport bound to the new surface and restores Playback capture state when a session is active.
override fun surfaceCreated(holder: SurfaceHolder) {
val s = density
val wDp = holder.surfaceFrame.width() / s
val hDp = holder.surfaceFrame.height() / s
- handle = NativeBridge.viewportCreate(holder.surface, wDp, hDp, s)
- if (handle == 0L) {
+ val created = NativeBridge.viewportCreate(holder.surface, wDp, hDp, s)
+ if (created == 0L) {
return
}
+ synchronized(NativeBridge.viewportLock) {
+ handle = created
+ NativeBridge.currentViewportHandle = created
+ }
+ if (captureController?.isCapturing == true) {
+ NativeBridge.viewportRestoreCaptureSession(created)
+ NativeBridge.viewportStartMicCapture(created)
+ }
+ val act = context as? MainActivity
+ act?.pushPersistedSettings()
+ act?.pushNotificationAccess()
+ if (MediaSessionReader.hasAccess(context)) {
+ MediaSessionReader.instance?.refreshNow()
+ }
startFrames()
requestFocus()
}
@@ -51,17 +68,28 @@ class IcedSurfaceView @JvmOverloads constructor(
NativeBridge.viewportResize(handle, width / s, height / s, s)
}
- /// drops the viewport on surface detach.
+ /// drops the viewport on surface detach and atomically clears the shared handle ahead of FFI destroy.
override fun surfaceDestroyed(holder: SurfaceHolder) {
stopFrames()
- if (handle != 0L) {
- NativeBridge.viewportDestroy(handle)
+ val toDestroy: Long
+ synchronized(NativeBridge.viewportLock) {
+ toDestroy = handle
handle = 0
+ if (NativeBridge.currentViewportHandle == toDestroy) {
+ NativeBridge.currentViewportHandle = 0L
+ }
+ }
+ if (toDestroy != 0L) {
+ NativeBridge.viewportDestroy(toDestroy)
}
}
- /// pauses the choreographer loop while the activity is backgrounded.
+ /// pauses the choreographer loop while the activity is backgrounded, skipping the pause inside PiP.
fun onActivityPause() {
+ val act = context as? Activity
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && act?.isInPictureInPictureMode == true) {
+ return
+ }
paused = true
stopFrames()
}
@@ -84,7 +112,7 @@ class IcedSurfaceView @JvmOverloads constructor(
frameCallbackPosted = false
}
- /// renders one frame and surfaces any pending picker request to the controller.
+ /// renders one frame and surfaces any pending picker or capture-action request to the matching controller.
override fun doFrame(frameTimeNanos: Long) {
frameCallbackPosted = false
if (handle == 0L || paused) return
@@ -93,6 +121,28 @@ class IcedSurfaceView @JvmOverloads constructor(
if (pending != 0) {
controller?.presentPicker(pending)
}
+ val captureAction = NativeBridge.viewportTakePendingCaptureAction(handle)
+ when (captureAction) {
+ 1 -> captureController?.start()
+ 2 -> captureController?.stop()
+ }
+ if (NativeBridge.viewportTakePendingPipRequest(handle)) {
+ (context as? MainActivity)?.requestPip()
+ }
+ val transport = NativeBridge.viewportTakePendingCaptureTransport(handle)
+ if (transport != 0) {
+ MediaSessionReader.instance?.transportCommand(transport)
+ }
+ val seek = NativeBridge.viewportTakePendingCaptureSeek(handle)
+ if (seek >= 0f) {
+ MediaSessionReader.instance?.seekFraction(seek)
+ }
+ if (NativeBridge.viewportTakePendingOpenListenerSettings(handle)) {
+ (context as? MainActivity)?.openListenerSettings()
+ }
+ if (NativeBridge.viewportTakePendingPersistSettings(handle)) {
+ (context as? MainActivity)?.persistSettings()
+ }
startFrames()
}
diff --git a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt
index 7094ee2..4f54ec5 100644
--- a/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt
+++ b/android/app/src/main/java/org/elseif/yrxtals/MainActivity.kt
@@ -1,6 +1,13 @@
package org.elseif.yrxtals
+import android.app.PictureInPictureParams
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Build
import android.os.Bundle
+import android.util.Log
+import android.util.Rational
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.core.view.WindowCompat
@@ -12,6 +19,7 @@ class MainActivity : ComponentActivity() {
private lateinit var surfaceView: IcedSurfaceView
private lateinit var controller: LibraryController
+ private lateinit var captureController: CaptureController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -21,8 +29,13 @@ class MainActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
controller = LibraryController(this)
- surfaceView = IcedSurfaceView(this).also { it.controller = controller }
+ captureController = CaptureController(this)
+ surfaceView = IcedSurfaceView(this).also {
+ it.controller = controller
+ it.captureController = captureController
+ }
controller.view = surfaceView
+ captureController.view = surfaceView
setContentView(surfaceView)
WindowInsetsControllerCompat(window, surfaceView).let { c ->
@@ -34,10 +47,98 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
surfaceView.onActivityResume()
+ if (MediaSessionReader.hasAccess(this)) {
+ if (MediaSessionReader.instance == null) {
+ MediaSessionReader.requestBind(this)
+ } else {
+ MediaSessionReader.instance?.refreshNow()
+ }
+ }
+ pushNotificationAccess()
+ }
+
+ /// publishes the current notification-listener access flag into the rust viewport under the shared lock.
+ fun pushNotificationAccess() {
+ val granted = MediaSessionReader.hasAccess(this)
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportSetNotificationAccess(h, granted)
+ }
+ }
+ }
+
+ private val prefs by lazy { getSharedPreferences("yrxtals", Context.MODE_PRIVATE) }
+
+ /// pushes the persisted settings JSON into the rust viewport at surface create. silent no-op if no value stored yet.
+ fun pushPersistedSettings() {
+ val json = prefs.getString("settings_json", null) ?: return
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportSetSettingsJson(h, json)
+ }
+ }
+ }
+
+ /// reads the current settings JSON out of rust and writes it to SharedPreferences.
+ fun persistSettings() {
+ val json = synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) NativeBridge.viewportGetSettingsJson(h) else null
+ }
+ if (json != null && json.isNotEmpty()) {
+ prefs.edit().putString("settings_json", json).apply()
+ }
+ }
+
+ /// opens the system Notification Listener access screen.
+ fun openListenerSettings() {
+ try {
+ startActivity(Intent(android.provider.Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ } catch (e: Throwable) {
+ Log.w("YrXtals", "open notification listener settings failed: ${e.message}")
+ }
}
override fun onPause() {
super.onPause()
surfaceView.onActivityPause()
}
+
+ /// enters Picture-in-Picture mode while a capture session runs.
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ if (!captureController.isCapturing) return
+ enterPipIfSupported()
+ }
+
+ /// fires Picture-in-Picture entry from the in-app toggle.
+ fun requestPip() {
+ enterPipIfSupported()
+ }
+
+ /// shared PiP entry path used by both the user-leave hint and the manual in-app toggle.
+ private fun enterPipIfSupported() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ try {
+ val params = PictureInPictureParams.Builder()
+ .setAspectRatio(Rational(16, 9))
+ .build()
+ enterPictureInPictureMode(params)
+ } catch (e: IllegalStateException) {
+ Log.w("YrXtals", "enterPictureInPictureMode failed: ${e.message}")
+ }
+ }
+
+ /// keeps the surface view rendering after a PiP transition.
+ override fun onPictureInPictureModeChanged(
+ isInPictureInPictureMode: Boolean,
+ newConfig: Configuration,
+ ) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+ if (isInPictureInPictureMode) {
+ surfaceView.onActivityResume()
+ }
+ }
}
diff --git a/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt b/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt
new file mode 100644
index 0000000..182640a
--- /dev/null
+++ b/android/app/src/main/java/org/elseif/yrxtals/MediaSessionReader.kt
@@ -0,0 +1,209 @@
+package org.elseif.yrxtals
+
+import android.content.ComponentName
+import android.content.Context
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.service.notification.NotificationListenerService
+import android.util.Log
+
+private const val TAG = "YrXtalsMediaReader"
+
+/// notification-listener that mirrors the active foreign media session into the rust viewport.
+class MediaSessionReader : NotificationListenerService() {
+
+ private var sessionManager: MediaSessionManager? = null
+ private var primary: MediaController? = null
+ private val callback = object : MediaController.Callback() {
+ override fun onMetadataChanged(metadata: MediaMetadata?) {
+ primary?.let { pushMetadata(it, metadata) }
+ }
+ override fun onPlaybackStateChanged(state: PlaybackState?) {
+ pushPlaybackState(state)
+ }
+ override fun onSessionDestroyed() {
+ detachPrimary()
+ refreshActiveSessions()
+ }
+ }
+
+ private val sessionsListener = MediaSessionManager.OnActiveSessionsChangedListener { _ ->
+ refreshActiveSessions()
+ }
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ Log.i(TAG, "listener connected")
+ instance = this
+ val mgr = getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager ?: return
+ sessionManager = mgr
+ val component = ComponentName(this, MediaSessionReader::class.java)
+ try {
+ mgr.addOnActiveSessionsChangedListener(
+ sessionsListener,
+ component,
+ Handler(Looper.getMainLooper()),
+ )
+ refreshActiveSessions()
+ } catch (e: SecurityException) {
+ Log.w(TAG, "MediaSessionManager listener registration denied: ${e.message}")
+ }
+ }
+
+ override fun onListenerDisconnected() {
+ super.onListenerDisconnected()
+ Log.i(TAG, "listener disconnected")
+ sessionManager?.removeOnActiveSessionsChangedListener(sessionsListener)
+ detachPrimary()
+ pushClear()
+ if (instance === this) instance = null
+ }
+
+ private fun refreshActiveSessions() {
+ val mgr = sessionManager ?: return
+ val component = ComponentName(this, MediaSessionReader::class.java)
+ val active = try {
+ mgr.getActiveSessions(component)
+ } catch (e: SecurityException) {
+ Log.w(TAG, "getActiveSessions denied: ${e.message}")
+ emptyList()
+ }
+ val pick = active.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING }
+ ?: active.firstOrNull()
+ attachPrimary(pick)
+ }
+
+ private fun attachPrimary(controller: MediaController?) {
+ if (controller === primary) {
+ if (controller != null) {
+ pushMetadata(controller, controller.metadata)
+ pushPlaybackState(controller.playbackState)
+ }
+ return
+ }
+ detachPrimary()
+ primary = controller
+ if (controller == null) {
+ Log.i(TAG, "no active controller, clearing capture session")
+ pushClear()
+ return
+ }
+ Log.i(TAG, "attached primary controller: ${controller.packageName}")
+ controller.registerCallback(callback, Handler(Looper.getMainLooper()))
+ pushMetadata(controller, controller.metadata)
+ pushPlaybackState(controller.playbackState)
+ }
+
+ private fun detachPrimary() {
+ primary?.unregisterCallback(callback)
+ primary = null
+ }
+
+ private fun pushMetadata(controller: MediaController, metadata: MediaMetadata?) {
+ val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)
+ ?: metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
+ val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
+ ?: metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
+ ?: metadata?.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
+ val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.coerceAtLeast(0L) ?: 0L
+ val positionMs = controller.playbackState?.position?.coerceAtLeast(0L) ?: 0L
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportSetCaptureMetadata(h, title, artist, positionMs, durationMs)
+ }
+ }
+ }
+
+ private fun pushPlaybackState(state: PlaybackState?) {
+ val playing = state?.state == PlaybackState.STATE_PLAYING
+ val positionMs = state?.position?.coerceAtLeast(0L) ?: 0L
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportSetCapturePlaybackState(h, playing, positionMs)
+ }
+ }
+ }
+
+ private fun pushClear() {
+ synchronized(NativeBridge.viewportLock) {
+ val h = NativeBridge.currentViewportHandle
+ if (h != 0L) {
+ NativeBridge.viewportClearCaptureSession(h)
+ }
+ }
+ }
+
+ /// re-pushes the currently attached MediaController state. invoked by the host after a surface re-create.
+ fun refreshNow() {
+ val controller = primary
+ if (controller == null) {
+ refreshActiveSessions()
+ return
+ }
+ pushMetadata(controller, controller.metadata)
+ pushPlaybackState(controller.playbackState)
+ }
+
+ /// forwards a transport command code (1 toggle, 2 prev, 3 next) to the current foreign controller.
+ fun transportCommand(code: Int) {
+ val controls = primary?.transportControls ?: return
+ when (code) {
+ 1 -> {
+ if (primary?.playbackState?.state == PlaybackState.STATE_PLAYING) {
+ controls.pause()
+ } else {
+ controls.play()
+ }
+ }
+ 2 -> controls.skipToPrevious()
+ 3 -> controls.skipToNext()
+ }
+ }
+
+ /// seeks the current foreign controller to the given normalised position.
+ fun seekFraction(fraction: Float) {
+ val duration = primary?.metadata
+ ?.getLong(MediaMetadata.METADATA_KEY_DURATION)
+ ?.coerceAtLeast(0L) ?: 0L
+ if (duration <= 0L) return
+ val target = (fraction.coerceIn(0f, 1f) * duration).toLong()
+ primary?.transportControls?.seekTo(target)
+ }
+
+ companion object {
+ @Volatile
+ @JvmStatic
+ var instance: MediaSessionReader? = null
+ private set
+
+ /// returns true when YrXtals' notification-listener has been granted notification access.
+ fun hasAccess(context: Context): Boolean {
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ "enabled_notification_listeners",
+ ) ?: return false
+ val self = ComponentName(context, MediaSessionReader::class.java).flattenToString()
+ return enabled.split(':').any { it == self }
+ }
+
+ /// asks the system to (re)bind the notification listener so onListenerConnected fires.
+ fun requestBind(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
+ try {
+ NotificationListenerService.requestRebind(
+ ComponentName(context, MediaSessionReader::class.java),
+ )
+ } catch (e: Throwable) {
+ Log.w(TAG, "requestRebind failed: ${e.message}")
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt
index 4655180..b12a515 100644
--- a/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt
+++ b/android/app/src/main/java/org/elseif/yrxtals/NativeBridge.kt
@@ -9,6 +9,15 @@ object NativeBridge {
System.loadLibrary("yr_crystals")
}
+ /// live viewport handle observed by audio threads. zero indicates no current surface.
+ @Volatile
+ @JvmField
+ var currentViewportHandle: Long = 0L
+
+ /// guards every FFI call against viewport destruction on the main thread.
+ @JvmField
+ val viewportLock: Any = Any()
+
external fun initContext(activity: Context)
external fun viewportCreate(surface: Surface, width: Float, height: Float, scale: Float): Long
@@ -30,4 +39,30 @@ object NativeBridge {
external fun viewportSetTrackArt(handle: Long, idx: Int, bytes: ByteArray)
external fun viewportTakePendingPick(handle: Long): Int
+
+ external fun viewportSetPlaybackMode(handle: Long, mode: Int)
+ external fun viewportPushCapturePcm(handle: Long, samples: FloatArray, sampleRate: Int, channels: Int)
+ external fun viewportSetCaptureMetadata(
+ handle: Long,
+ title: String?,
+ artist: String?,
+ positionMs: Long,
+ durationMs: Long,
+ )
+ external fun viewportTakePendingCaptureAction(handle: Long): Int
+ external fun viewportTakePendingPipRequest(handle: Long): Boolean
+ external fun viewportRestoreCaptureSession(handle: Long)
+ external fun viewportSetCapturePlaybackState(handle: Long, playing: Boolean, positionMs: Long)
+ external fun viewportClearCaptureSession(handle: Long)
+ external fun viewportTakePendingCaptureTransport(handle: Long): Int
+ external fun viewportTakePendingCaptureSeek(handle: Long): Float
+ external fun viewportSetNotificationAccess(handle: Long, granted: Boolean)
+ external fun viewportTakePendingOpenListenerSettings(handle: Long): Boolean
+
+ external fun viewportStartMicCapture(handle: Long)
+ external fun viewportStopMicCapture(handle: Long)
+
+ external fun viewportGetSettingsJson(handle: Long): String?
+ external fun viewportSetSettingsJson(handle: Long, json: String)
+ external fun viewportTakePendingPersistSettings(handle: Long): Boolean
}
diff --git a/include/yr_xtals.h b/include/yr_xtals.h
index e3c001c..256eed5 100644
--- a/include/yr_xtals.h
+++ b/include/yr_xtals.h
@@ -33,6 +33,9 @@ void viewport_key_event(struct ViewportHandle *handle,
/// drains the pending-picker flag (0 none, 1 folder, 2 file) and returns the prior value.
uint8_t viewport_take_pending_pick(struct ViewportHandle *handle);
+/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop).
+uint8_t viewport_take_pending_capture_action(struct ViewportHandle *handle);
+
void viewport_apply_picked_folder(struct ViewportHandle *handle, const char *path);
void viewport_apply_picked_file(struct ViewportHandle *handle, const char *path);
@@ -66,6 +69,32 @@ void viewport_set_track_art(struct ViewportHandle *handle,
const uint8_t *bytes,
size_t len);
+/// switches the top-level playback mode: 0 = Local file playback, 1 = Playback capture from the OS.
+void viewport_set_playback_mode(struct ViewportHandle *handle, uint32_t mode);
+
+/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline.
+void viewport_push_capture_pcm(struct ViewportHandle *handle,
+ const float *samples,
+ size_t sample_count,
+ uint32_t sample_rate,
+ uint32_t channels);
+
+/// updates the now-playing metadata for Playback capture mode. clears fields on null pointers.
+void viewport_set_capture_metadata(struct ViewportHandle *handle,
+ const char *title,
+ const char *artist,
+ uint64_t position_ms,
+ uint64_t duration_ms);
+
+/// serializes both settings slots as a JSON string. caller frees via viewport_free_string.
+char *viewport_get_settings_json(struct ViewportHandle *handle);
+
+/// applies a JSON settings blob loaded by the shell at startup. silent no-op on parse failure.
+void viewport_set_settings_json(struct ViewportHandle *handle, const char *json);
+
+/// drains the pending-persist flag set after a settings change.
+bool viewport_take_pending_persist_settings(struct ViewportHandle *handle);
+
void viewport_free_string(char *s);
#endif
diff --git a/ios/Info.plist b/ios/Info.plist
index 2110b22..1374fe7 100644
--- a/ios/Info.plist
+++ b/ios/Info.plist
@@ -48,6 +48,8 @@
17.0
NSAppleMusicUsageDescription
Yr Xtals plays audio files from your library or Files app for offline visualization.
+ NSMicrophoneUsageDescription
+ Yr Xtals listens through the microphone in Playback capture mode so the visualizer reacts to whatever audio is playing around you.
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/ios/src/CaptureController.swift b/ios/src/CaptureController.swift
new file mode 100644
index 0000000..ea96940
--- /dev/null
+++ b/ios/src/CaptureController.swift
@@ -0,0 +1,46 @@
+import AVFoundation
+
+/// gates mic capture on AVCaptureDevice audio permission and exposes start/stop to the iced viewport.
+class CaptureController {
+
+ private let session = CaptureSession()
+ weak var view: IcedViewportView?
+
+ /// true while AVAudioEngine is running through CaptureSession.
+ var isCapturing: Bool { session.isCapturing }
+
+ /// requests AVCaptureDevice audio permission if missing; starts the mic session on grant or when already granted.
+ func start() {
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized:
+ startSession()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
+ DispatchQueue.main.async {
+ if granted { self?.startSession() }
+ else { print("[YrXtals] mic permission denied") }
+ }
+ }
+ case .denied, .restricted:
+ print("[YrXtals] mic permission previously denied")
+ @unknown default:
+ print("[YrXtals] mic permission status unknown")
+ }
+ }
+
+ /// stops the mic session.
+ func stop() {
+ session.stop()
+ }
+
+ private func startSession() {
+ session.viewportHandleProvider = { [weak view = self.view] in
+ view?.viewportHandle
+ }
+ do {
+ try session.start()
+ } catch {
+ print("[YrXtals] CaptureSession start failed: \(error)")
+ }
+ }
+}
diff --git a/ios/src/CaptureSession.swift b/ios/src/CaptureSession.swift
new file mode 100644
index 0000000..c6d8919
--- /dev/null
+++ b/ios/src/CaptureSession.swift
@@ -0,0 +1,167 @@
+import AVFoundation
+import UIKit
+
+/// drives an AVAudioEngine input tap on the device mic and pushes mono float PCM into the rust capture pipeline.
+class CaptureSession {
+
+ private var engine: AVAudioEngine?
+ private(set) var isCapturing = false
+ private var observers: [NSObjectProtocol] = []
+
+ /// supplies the live viewport handle each tap. captured weakly to avoid extending the view's lifetime.
+ var viewportHandleProvider: (() -> OpaquePointer?)?
+
+ /// configures AVAudioSession for low-latency mic capture, installs a tap, and starts the engine.
+ func start() throws {
+ if isCapturing { return }
+
+ try configureSession()
+ try startEngine()
+ registerObservers()
+ isCapturing = true
+ print("[YrXtals] CaptureSession started")
+ }
+
+ /// removes the tap, stops the engine, and deactivates the audio session.
+ func stop() {
+ if !isCapturing { return }
+ unregisterObservers()
+ teardownEngine()
+ isCapturing = false
+ try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+ print("[YrXtals] CaptureSession stopped")
+ }
+
+ /// applies the low-latency mic AVAudioSession configuration.
+ private func configureSession() throws {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(
+ .playAndRecord,
+ mode: .measurement,
+ options: [.defaultToSpeaker, .mixWithOthers],
+ )
+ try session.setPreferredSampleRate(48_000)
+ try session.setPreferredIOBufferDuration(0.005)
+ try session.setActive(true, options: [])
+ }
+
+ /// allocates a fresh AVAudioEngine, taps the input bus, and starts the engine.
+ private func startEngine() throws {
+ let engine = AVAudioEngine()
+ let input = engine.inputNode
+ let format = input.outputFormat(forBus: 0)
+ let sampleRate = UInt32(format.sampleRate)
+ let channels = UInt32(format.channelCount)
+
+ input.installTap(onBus: 0, bufferSize: 256, format: format) { [weak self] buffer, _ in
+ guard let self = self,
+ let handle = self.viewportHandleProvider?() else { return }
+ self.push(buffer: buffer, handle: handle, sampleRate: sampleRate, channels: channels)
+ }
+
+ try engine.start()
+ self.engine = engine
+ }
+
+ /// removes the tap and stops the AVAudioEngine. safe to call repeatedly.
+ private func teardownEngine() {
+ engine?.inputNode.removeTap(onBus: 0)
+ engine?.stop()
+ engine = nil
+ }
+
+ /// re-establishes the engine after an interruption or a return to the foreground. keeps the session active.
+ private func bounceEngine() {
+ teardownEngine()
+ do {
+ try AVAudioSession.sharedInstance().setActive(true, options: [])
+ try startEngine()
+ print("[YrXtals] CaptureSession bounced")
+ } catch {
+ print("[YrXtals] CaptureSession bounce failed: \(error)")
+ }
+ }
+
+ /// listens for interruption, foreground activation, and media-services-reset notifications.
+ private func registerObservers() {
+ let center = NotificationCenter.default
+ observers.append(center.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: nil,
+ queue: .main,
+ ) { [weak self] note in
+ self?.handleInterruption(note)
+ })
+ observers.append(center.addObserver(
+ forName: UIApplication.didBecomeActiveNotification,
+ object: nil,
+ queue: .main,
+ ) { [weak self] _ in
+ guard let self = self, self.isCapturing else { return }
+ self.bounceEngine()
+ })
+ observers.append(center.addObserver(
+ forName: AVAudioSession.mediaServicesWereResetNotification,
+ object: nil,
+ queue: .main,
+ ) { [weak self] _ in
+ guard let self = self, self.isCapturing else { return }
+ do { try self.configureSession() } catch {
+ print("[YrXtals] post-reset session reconfigure failed: \(error)")
+ }
+ self.bounceEngine()
+ })
+ }
+
+ private func unregisterObservers() {
+ let center = NotificationCenter.default
+ for o in observers { center.removeObserver(o) }
+ observers.removeAll()
+ }
+
+ /// reacts to AVAudioSession interruptions: stops the engine on .began, restarts it on .ended.
+ private func handleInterruption(_ note: Notification) {
+ guard let info = note.userInfo,
+ let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
+ let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
+ switch type {
+ case .began:
+ teardownEngine()
+ print("[YrXtals] audio interruption began")
+ case .ended:
+ if isCapturing { bounceEngine() }
+ print("[YrXtals] audio interruption ended")
+ @unknown default:
+ break
+ }
+ }
+
+ /// interleaves the per-channel float buffers and forwards the chunk through the FFI.
+ private func push(buffer: AVAudioPCMBuffer, handle: OpaquePointer, sampleRate: UInt32, channels: UInt32) {
+ guard let channelData = buffer.floatChannelData else { return }
+ let frames = Int(buffer.frameLength)
+ if frames == 0 { return }
+ let chCount = Int(channels)
+
+ if chCount == 1 {
+ viewport_push_capture_pcm(handle, channelData[0], frames, sampleRate, channels)
+ return
+ }
+
+ let total = frames * chCount
+ var interleaved = [Float](repeating: 0, count: total)
+ interleaved.withUnsafeMutableBufferPointer { dst in
+ for c in 0..&2
+YRXTALS_KEYSTORE not set.
+
+create a release keystore once with:
+ keytool -genkey -v -keystore ~/.android/yrxtals-release.jks \\
+ -keyalg RSA -keysize 4096 -validity 25000 -alias yrxtals
+
+then export:
+ export YRXTALS_KEYSTORE=\$HOME/.android/yrxtals-release.jks
+ export YRXTALS_KEY_ALIAS=yrxtals
+ export YRXTALS_STORE_PASSWORD=
+ export YRXTALS_KEY_PASSWORD=
+EOF
+ exit 2
+fi
+
+if [[ -z "${YRXTALS_STORE_PASSWORD:-}" || -z "${YRXTALS_KEY_PASSWORD:-}" ]]; then
+ echo "YRXTALS_STORE_PASSWORD and YRXTALS_KEY_PASSWORD must be set" >&2
+ exit 2
+fi
+
+if [[ ! -f "$YRXTALS_KEYSTORE" ]]; then
+ echo "keystore not found at $YRXTALS_KEYSTORE" >&2
+ exit 2
+fi
+
+export YRXTALS_KEY_ALIAS="${YRXTALS_KEY_ALIAS:-yrxtals}"
+
+bash "$SCRIPT_DIR/generate-icons.sh"
+
+JNILIBS="$REPO_ROOT/android/app/src/main/jniLibs"
+mkdir -p "$JNILIBS/arm64-v8a"
+
+echo "→ cargo ndk -t arm64-v8a -P 28 -o $JNILIBS build --profile release"
+( cd "$REPO_ROOT" && cargo ndk -t arm64-v8a -P 28 -o "$JNILIBS" build --profile release )
+
+echo "→ gradlew :app:bundleRelease"
+( cd "$REPO_ROOT/android" && ./gradlew :app:bundleRelease )
+
+AAB="$REPO_ROOT/android/app/build/outputs/bundle/release/app-release.aab"
+if [[ ! -f "$AAB" ]]; then
+ echo "expected aab missing at $AAB" >&2
+ exit 1
+fi
+
+echo
+echo "signed aab: $AAB"
+echo "applicationId: org.elseif.yrxtals"
+echo "upload at https://play.google.com/console"
diff --git a/src/analyzer.rs b/src/analyzer.rs
index bbc4594..681647e 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -1,5 +1,6 @@
+use std::collections::VecDeque;
use std::sync::Arc;
use num_complex::Complex64;
@@ -35,6 +36,18 @@ pub struct Analyzer {
track: Option>,
last_frames: Vec,
+
+ /// most recently observed live-PCM sample rate.
+ live_sample_rate: u32,
+
+ /// interleaved-stereo PCM accumulated by push_live_pcm and drained one hop at a time by step_live.
+ live_buffer: VecDeque,
+
+ /// soft cap on the live buffer length.
+ live_buffer_max: usize,
+
+ /// smoothed AGC gain applied to incoming live PCM before it enters the analyzer buffer.
+ live_gain: f64,
}
impl Analyzer {
@@ -87,6 +100,10 @@ impl Analyzer {
last_hilbert_sample: 0,
track: None,
last_frames: Vec::new(),
+ live_sample_rate: 0,
+ live_buffer: VecDeque::with_capacity(192_000),
+ live_buffer_max: 192_000,
+ live_gain: 1.0,
}
}
@@ -222,6 +239,115 @@ impl Analyzer {
}
}
+ /// appends interleaved PCM into the live-mode buffer with stereo conversion. propagates sample-rate changes and caps the buffer length.
+ pub fn push_live_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) {
+ if samples.is_empty() || channels == 0 {
+ return;
+ }
+ if sample_rate != self.live_sample_rate {
+ self.live_sample_rate = sample_rate;
+ for p in self
+ .main
+ .iter_mut()
+ .chain(self.transient.iter_mut())
+ .chain(self.deep.iter_mut())
+ {
+ p.set_sample_rate(sample_rate);
+ }
+ self.hilbert_needs_reset = true;
+ }
+
+ // chunk-RMS AGC: keep mic levels near a fixed target so a quiet phone-mic still drives the visualizer.
+ const TARGET_RMS: f64 = 0.15;
+ const MIN_RMS: f64 = 0.001;
+ const MAX_GAIN: f64 = 50.0;
+ const MIN_GAIN: f64 = 0.01;
+ const ATTACK: f64 = 0.30;
+ const RELEASE: f64 = 0.02;
+
+ let mut sum_sq = 0.0_f64;
+ for &s in samples {
+ sum_sq += (s as f64) * (s as f64);
+ }
+ let rms = (sum_sq / samples.len() as f64).sqrt();
+ let target_gain = if rms > MIN_RMS {
+ (TARGET_RMS / rms).clamp(MIN_GAIN, MAX_GAIN)
+ } else {
+ self.live_gain.clamp(MIN_GAIN, MAX_GAIN)
+ };
+ let alpha = if target_gain < self.live_gain { ATTACK } else { RELEASE };
+ self.live_gain += alpha * (target_gain - self.live_gain);
+ let gain = self.live_gain as f32;
+
+ let ch = channels as usize;
+ match ch {
+ 2 => {
+ for &s in samples {
+ self.live_buffer.push_back(s * gain);
+ }
+ }
+ 1 => {
+ for &s in samples {
+ let scaled = s * gain;
+ self.live_buffer.push_back(scaled);
+ self.live_buffer.push_back(scaled);
+ }
+ }
+ _ => {
+ let mut i = 0;
+ while i + ch <= samples.len() {
+ self.live_buffer.push_back(samples[i] * gain);
+ self.live_buffer.push_back(samples[i + 1] * gain);
+ i += ch;
+ }
+ }
+ }
+ while self.live_buffer.len() > self.live_buffer_max {
+ self.live_buffer.pop_front();
+ self.live_buffer.pop_front();
+ }
+ }
+
+ /// consumes one hop's worth of interleaved stereo from the live buffer and publishes a frame pair.
+ pub fn step_live(&mut self) -> Option<&[FrameData]> {
+ let hop = self.hilbert_hop_size;
+ let fft = self.hilbert_fft_size;
+ if hop == 0 || fft == 0 {
+ return None;
+ }
+ if self.hilbert_needs_reset {
+ let warmup_blocks = fft / hop;
+ let warmup_stereo = warmup_blocks * hop * 2;
+ if self.live_buffer.len() < warmup_stereo + hop * 2 {
+ return None;
+ }
+ self.hilbert.reinit(fft);
+ for _ in 0..warmup_blocks {
+ let mut left = Vec::with_capacity(hop);
+ let mut right = Vec::with_capacity(hop);
+ for _ in 0..hop {
+ left.push(self.live_buffer.pop_front().unwrap() as f64);
+ right.push(self.live_buffer.pop_front().unwrap() as f64);
+ }
+ let _ = self.hilbert.process(&left, &right);
+ }
+ self.hilbert_needs_reset = false;
+ }
+ if self.live_buffer.len() < hop * 2 {
+ return None;
+ }
+ let mut left = Vec::with_capacity(hop);
+ let mut right = Vec::with_capacity(hop);
+ for _ in 0..hop {
+ left.push(self.live_buffer.pop_front().unwrap() as f64);
+ right.push(self.live_buffer.pop_front().unwrap() as f64);
+ }
+ let (cl, cr) = self.hilbert.process(&left, &right);
+ self.push_to_processors(&cl, &cr);
+ self.compute_and_publish();
+ Some(&self.last_frames)
+ }
+
/// distributes one analytic-signal hop into all three band processors per channel.
fn push_to_processors(&mut self, complex_l: &[Complex64], complex_r: &[Complex64]) {
self.main[0].push_data(complex_l);
diff --git a/src/analyzer_worker.rs b/src/analyzer_worker.rs
index f9a007f..be63ed7 100644
--- a/src/analyzer_worker.rs
+++ b/src/analyzer_worker.rs
@@ -11,6 +11,13 @@ use crossbeam_channel::{bounded, Receiver, RecvTimeoutError, Sender};
use crate::analyzer::{Analyzer, FrameData};
use crate::track::TrackData;
+/// analyzer pipeline mode: track-anchored playback vs. live captured stream.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AnalyzerMode {
+ Track,
+ Live,
+}
+
/// command messages routed from the ui thread into the analyzer thread.
enum Cmd {
SetTrack(Arc),
@@ -18,6 +25,8 @@ enum Cmd {
SetNumBins(usize),
SetSmoothing { granularity: i32, detail: i32, strength: f32 },
SetGpuBlend(f32),
+ SetMode(AnalyzerMode),
+ PushLivePcm { samples: Vec, sample_rate: u32, channels: u32 },
Shutdown,
}
@@ -30,6 +39,19 @@ pub struct AnalyzerWorker {
join: Option>,
}
+/// cloneable handle that pushes live PCM into the analyzer worker without holding a reference to it.
+#[derive(Clone)]
+pub struct PcmSender {
+ tx: Sender,
+}
+
+impl PcmSender {
+ /// queues a chunk of interleaved live PCM into the analyzer worker. drops the chunk on a full channel.
+ pub fn push(&self, samples: Vec, sample_rate: u32, channels: u32) {
+ let _ = self.tx.try_send(Cmd::PushLivePcm { samples, sample_rate, channels });
+ }
+}
+
impl AnalyzerWorker {
/// launches the background analyzer thread and returns an owning handle.
pub fn spawn(device: wgpu::Device, queue: wgpu::Queue) -> Self {
@@ -87,6 +109,16 @@ impl AnalyzerWorker {
let _ = self.cmd_tx.send(Cmd::SetGpuBlend(blend));
}
+ /// queues a switch between Track and Live analyzer modes.
+ pub fn set_mode(&self, mode: AnalyzerMode) {
+ let _ = self.cmd_tx.send(Cmd::SetMode(mode));
+ }
+
+ /// queues a live-PCM chunk for the analyzer, dropping the chunk on a full worker channel.
+ pub fn push_live_pcm(&self, samples: Vec, sample_rate: u32, channels: u32) {
+ let _ = self.cmd_tx.try_send(Cmd::PushLivePcm { samples, sample_rate, channels });
+ }
+
/// stores the audio thread's normalised playhead atomically.
pub fn publish_playhead(&self, normalised: f32) {
let total = self.total_frames.load(Ordering::Acquire);
@@ -101,6 +133,11 @@ impl AnalyzerWorker {
pub fn latest_frames(&self) -> Arc> {
self.frames.load_full()
}
+
+ /// builds a cloneable PcmSender that bypasses the AnalyzerWorker handle and pushes directly into the command channel.
+ pub fn pcm_sender(&self) -> PcmSender {
+ PcmSender { tx: self.cmd_tx.clone() }
+ }
}
impl Drop for AnalyzerWorker {
@@ -122,6 +159,7 @@ fn run(
queue: wgpu::Queue,
) {
let mut analyzer = Analyzer::new(device, queue);
+ let mut mode = AnalyzerMode::Track;
let interval = Duration::from_micros(16_667);
let mut next_tick = Instant::now() + interval;
@@ -132,6 +170,14 @@ fn run(
match cmd_rx.recv_timeout(timeout) {
Ok(Cmd::Shutdown) => break,
+ Ok(Cmd::SetMode(m)) => {
+ mode = m;
+ continue;
+ }
+ Ok(Cmd::PushLivePcm { samples, sample_rate, channels }) => {
+ analyzer.push_live_pcm(&samples, sample_rate, channels);
+ continue;
+ }
Ok(cmd) => {
apply(&mut analyzer, cmd);
continue;
@@ -141,18 +187,39 @@ fn run(
}
while let Ok(cmd) = cmd_rx.try_recv() {
- if matches!(cmd, Cmd::Shutdown) {
- return;
+ match cmd {
+ Cmd::Shutdown => return,
+ Cmd::SetMode(m) => mode = m,
+ Cmd::PushLivePcm { samples, sample_rate, channels } => {
+ analyzer.push_live_pcm(&samples, sample_rate, channels);
+ }
+ other => apply(&mut analyzer, other),
}
- apply(&mut analyzer, cmd);
}
- let total = total_frames.load(Ordering::Acquire);
- if total > 0 {
- let frame = playhead_frame.load(Ordering::Acquire);
- let pos = (frame as f64) / (total as f64);
- if let Some(latest) = analyzer.step(pos) {
- frames.store(Arc::new(latest.to_vec()));
+ match mode {
+ AnalyzerMode::Track => {
+ let total = total_frames.load(Ordering::Acquire);
+ if total > 0 {
+ let frame = playhead_frame.load(Ordering::Acquire);
+ let pos = (frame as f64) / (total as f64);
+ if let Some(latest) = analyzer.step(pos) {
+ frames.store(Arc::new(latest.to_vec()));
+ }
+ }
+ }
+ AnalyzerMode::Live => {
+ // drains all hops queued in the live buffer per tick so small hops (e.g. 64) don't fall behind the audio thread; publishes only the most recent frame pair.
+ let mut latest_owned: Option> = None;
+ for _ in 0..200 {
+ match analyzer.step_live() {
+ Some(latest) => latest_owned = Some(latest.to_vec()),
+ None => break,
+ }
+ }
+ if let Some(f) = latest_owned {
+ frames.store(Arc::new(f));
+ }
}
}
@@ -177,6 +244,7 @@ fn apply(analyzer: &mut Analyzer, cmd: Cmd) {
strength,
} => analyzer.set_smoothing_params(granularity, detail, strength),
Cmd::SetGpuBlend(b) => analyzer.set_gpu_blend(b),
+ Cmd::SetMode(_) | Cmd::PushLivePcm { .. } => {}
Cmd::Shutdown => {}
}
}
diff --git a/src/android.rs b/src/android.rs
index e701d14..c9f8dd3 100644
--- a/src/android.rs
+++ b/src/android.rs
@@ -1,4 +1,4 @@
-use jni::objects::{JByteArray, JClass, JIntArray, JObject, JObjectArray, JString};
+use jni::objects::{JByteArray, JClass, JFloatArray, JIntArray, JObject, JObjectArray, JString};
use jni::sys::{jboolean, jbyte, jfloat, jint, jlong, jobject};
use jni::JNIEnv;
use ndk::native_window::NativeWindow;
@@ -9,6 +9,7 @@ use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::Once;
+use crate::mic_input::MicInput;
use crate::viewport::ViewportHandle;
macro_rules! dbg_log {
@@ -18,10 +19,11 @@ macro_rules! dbg_log {
};
}
-/// owns the inner viewport plus the ANativeWindow whose lifetime backs the wgpu surface.
+/// owns the inner viewport, the ANativeWindow whose lifetime backs the wgpu surface, and the AAudio mic input stream.
struct AndroidViewport {
inner: ViewportHandle,
_window: NativeWindow,
+ mic_input: Option,
}
/// initializes android logger and the ndk_context singleton consumed by cpal's AAudio backend.
@@ -99,6 +101,7 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportCreate<'a>(
let boxed = Box::new(AndroidViewport {
inner,
_window: window,
+ mic_input: None,
});
Box::into_raw(boxed) as jlong
}
@@ -345,6 +348,168 @@ pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingP
av.inner.take_pending_pick() as jint
}
+/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop).
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureAction<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jint {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 };
+ av.inner.take_pending_capture_action() as jint
+}
+
+/// drains the pending Picture-in-Picture entry-request flag.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPipRequest<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jboolean {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 };
+ if av.inner.take_pending_pip_request() { 1 } else { 0 }
+}
+
+/// re-applies Playback capture mode onto a freshly created viewport without firing the start action.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportRestoreCaptureSession<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.inner.restore_capture_session();
+}
+
+/// stores the foreign-session playback flag and live position pushed by the host MediaController.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetCapturePlaybackState<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ playing: jboolean,
+ position_ms: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.inner.set_capture_playback_state(playing != 0, position_ms.max(0) as u64);
+}
+
+/// resets every Playback capture metadata field after the host loses its primary MediaController.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportClearCaptureSession<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.inner.clear_capture_session();
+}
+
+/// stores the notification-listener access flag pushed by the host shell.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetNotificationAccess<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ granted: jboolean,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.inner.set_notification_access(granted != 0);
+}
+
+/// drains the pending request to open the system Notification Listener settings.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingOpenListenerSettings<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jboolean {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 };
+ if av.inner.take_pending_open_listener_settings() { 1 } else { 0 }
+}
+
+/// drains the pending capture-mode transport command (0 idle, 1 toggle, 2 prev, 3 next).
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureTransport<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jint {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 };
+ av.inner.take_pending_capture_transport() as jint
+}
+
+/// drains the pending capture-mode seek fraction. negative result means idle.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingCaptureSeek<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jfloat {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return -1.0 };
+ av.inner.take_pending_capture_seek()
+}
+
+/// switches the top-level playback mode: 0=Local file playback, 1=Playback capture from the OS.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetPlaybackMode<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ mode: jint,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.inner.set_playback_mode(mode.max(0) as u32);
+}
+
+/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportPushCapturePcm<'a>(
+ env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ samples: JFloatArray<'a>,
+ sample_rate: jint,
+ channels: jint,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ let Ok(len) = env.get_array_length(&samples) else { return };
+ if len <= 0 { return; }
+ let mut buf = vec![0f32; len as usize];
+ if env.get_float_array_region(&samples, 0, &mut buf).is_err() { return; }
+ av.inner.push_capture_pcm(&buf, sample_rate.max(0) as u32, channels.max(0) as u32);
+}
+
+/// updates the now-playing metadata for Playback capture mode. clears fields on null Java strings.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetCaptureMetadata<'a>(
+ mut env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ title: JString<'a>,
+ artist: JString<'a>,
+ position_ms: jlong,
+ duration_ms: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ let title_str = if JObjectExt::is_null(&title) {
+ None
+ } else {
+ env.get_string(&title).ok().map(String::from)
+ };
+ let artist_str = if JObjectExt::is_null(&artist) {
+ None
+ } else {
+ env.get_string(&artist).ok().map(String::from)
+ };
+ av.inner.set_capture_metadata(
+ title_str,
+ artist_str,
+ position_ms.max(0) as u64,
+ duration_ms.max(0) as u64,
+ );
+}
+
/// dup2s stdout and stderr onto a pipe and pumps each line into logcat under the `yr_crystals.io` tag.
fn redirect_stdio_to_logcat() {
use std::io::{BufRead, BufReader};
@@ -372,6 +537,75 @@ fn redirect_stdio_to_logcat() {
.ok();
}
+/// opens the AAudio mic input stream and stores the handle on the AndroidViewport. no-op if a stream is already running.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStartMicCapture<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ if av.mic_input.is_some() {
+ return;
+ }
+ let pusher = av.inner.pcm_sender();
+ match MicInput::start(pusher) {
+ Ok(mic) => av.mic_input = Some(mic),
+ Err(e) => {
+ dbg_log!("viewportStartMicCapture failed: {e}");
+ }
+ }
+}
+
+/// closes the AAudio mic input stream by dropping the stored handle.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportStopMicCapture<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ av.mic_input = None;
+}
+
+/// serializes both settings slots as a JSON java string for the shell to persist.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportGetSettingsJson<'a>(
+ env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jni::sys::jstring {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else {
+ return std::ptr::null_mut();
+ };
+ let s = av.inner.settings_json();
+ env.new_string(s).map(|j| j.into_raw()).unwrap_or(std::ptr::null_mut())
+}
+
+/// applies a JSON settings blob loaded by the shell at startup.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportSetSettingsJson<'a>(
+ mut env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+ json: JString<'a>,
+) {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return };
+ let Ok(s) = env.get_string(&json) else { return };
+ av.inner.apply_settings_json(&String::from(s));
+}
+
+/// drains the pending-persist flag set after a settings change.
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_org_elseif_yrxtals_NativeBridge_viewportTakePendingPersistSettings<'a>(
+ _env: JNIEnv<'a>,
+ _class: JClass<'a>,
+ handle: jlong,
+) -> jboolean {
+ let Some(av) = (unsafe { (handle as *mut AndroidViewport).as_mut() }) else { return 0 };
+ if av.inner.take_pending_persist_settings() { 1 } else { 0 }
+}
+
/// nullability check on a JObject through its raw pointer.
trait JObjectExt {
fn is_null(&self) -> bool;
diff --git a/src/ios.rs b/src/ios.rs
index 91a8a73..f5744c6 100644
--- a/src/ios.rs
+++ b/src/ios.rs
@@ -301,6 +301,86 @@ pub extern "C" fn viewport_take_pending_pick(handle: *mut ViewportHandle) -> u8
h.take_pending_pick()
}
+/// drains the pending playback-capture action flag (0 idle, 1 start, 2 stop).
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_take_pending_capture_action(handle: *mut ViewportHandle) -> u8 {
+ let Some(h) = (unsafe { handle.as_mut() }) else {
+ return 0;
+ };
+ h.take_pending_capture_action()
+}
+
+/// switches the top-level playback mode: 0=Local file playback, 1=Playback capture from the OS.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_set_playback_mode(handle: *mut ViewportHandle, mode: u32) {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return };
+ h.set_playback_mode(mode);
+}
+
+/// pushes a chunk of interleaved f32 PCM captured by the shell into the playback-capture pipeline.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_push_capture_pcm(
+ handle: *mut ViewportHandle,
+ samples: *const f32,
+ sample_count: usize,
+ sample_rate: u32,
+ channels: u32,
+) {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return };
+ if samples.is_null() || sample_count == 0 {
+ return;
+ }
+ let slice = unsafe { std::slice::from_raw_parts(samples, sample_count) };
+ h.push_capture_pcm(slice, sample_rate, channels);
+}
+
+/// updates the now-playing metadata for Playback capture mode. clears fields on null pointers.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_set_capture_metadata(
+ handle: *mut ViewportHandle,
+ title: *const c_char,
+ artist: *const c_char,
+ position_ms: u64,
+ duration_ms: u64,
+) {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return };
+ let title = if title.is_null() {
+ None
+ } else {
+ unsafe { CStr::from_ptr(title) }.to_str().ok().map(String::from)
+ };
+ let artist = if artist.is_null() {
+ None
+ } else {
+ unsafe { CStr::from_ptr(artist) }.to_str().ok().map(String::from)
+ };
+ h.set_capture_metadata(title, artist, position_ms, duration_ms);
+}
+
+/// serializes both settings slots as JSON. caller must release the returned pointer via viewport_free_string.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_get_settings_json(handle: *mut ViewportHandle) -> *mut c_char {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return std::ptr::null_mut() };
+ let s = h.settings_json();
+ CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
+}
+
+/// applies a JSON settings blob loaded by the shell at startup.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_set_settings_json(handle: *mut ViewportHandle, json: *const c_char) {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return };
+ if json.is_null() { return; }
+ let Ok(s) = unsafe { CStr::from_ptr(json) }.to_str() else { return };
+ h.apply_settings_json(s);
+}
+
+/// drains the pending-persist flag set after a settings change.
+#[unsafe(no_mangle)]
+pub extern "C" fn viewport_take_pending_persist_settings(handle: *mut ViewportHandle) -> bool {
+ let Some(h) = (unsafe { handle.as_mut() }) else { return false };
+ h.take_pending_persist_settings()
+}
+
/// releases a CString previously handed to Swift over the FFI boundary.
#[unsafe(no_mangle)]
pub extern "C" fn viewport_free_string(s: *mut c_char) {
diff --git a/src/lib.rs b/src/lib.rs
index ef73191..f02ced0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -26,3 +26,6 @@ pub mod ios;
#[cfg(target_os = "android")]
pub mod android;
+
+#[cfg(target_os = "android")]
+pub mod mic_input;
diff --git a/src/mic_input.rs b/src/mic_input.rs
new file mode 100644
index 0000000..04b0f93
--- /dev/null
+++ b/src/mic_input.rs
@@ -0,0 +1,88 @@
+use ndk::audio::{
+ AudioCallbackResult, AudioDirection, AudioFormat, AudioInputPreset, AudioPerformanceMode,
+ AudioSharingMode, AudioStream, AudioStreamBuilder,
+};
+use std::ffi::c_void;
+
+use crate::analyzer_worker::PcmSender;
+
+/// owns the AAudio mic-input stream that pushes raw mic PCM into the analyzer worker.
+pub struct MicInput {
+ _stream: AudioStream,
+}
+
+impl MicInput {
+ /// opens a LOW_LATENCY mic input stream with the Unprocessed preset and starts the data callback.
+ pub fn start(pusher: PcmSender) -> Result {
+ let mut frames_since_log: u64 = 0;
+ let mut callbacks_since_log: u64 = 0;
+ let mut peak_since_log: f32 = 0.0;
+ let callback: ndk::audio::AudioStreamDataCallback = Box::new(
+ move |stream: &AudioStream, audio_data: *mut c_void, num_frames: i32| {
+ let channels = stream.channel_count().max(1) as usize;
+ let frames = num_frames.max(0) as usize;
+ let total = frames * channels;
+ if total == 0 {
+ return AudioCallbackResult::Continue;
+ }
+ let samples =
+ unsafe { std::slice::from_raw_parts(audio_data as *const f32, total) };
+ let sr = stream.sample_rate() as u32;
+ #[cfg(debug_assertions)]
+ {
+ for &s in samples {
+ let a = s.abs();
+ if a > peak_since_log {
+ peak_since_log = a;
+ }
+ }
+ frames_since_log = frames_since_log.saturating_add(frames as u64);
+ callbacks_since_log = callbacks_since_log.saturating_add(1);
+ if frames_since_log >= sr as u64 {
+ log::info!(
+ "mic_input tick: callbacks={} frames={} sr={} ch={} peak={:.4}",
+ callbacks_since_log,
+ frames_since_log,
+ sr,
+ channels,
+ peak_since_log,
+ );
+ frames_since_log = 0;
+ callbacks_since_log = 0;
+ peak_since_log = 0.0;
+ }
+ }
+ pusher.push(samples.to_vec(), sr, channels as u32);
+ AudioCallbackResult::Continue
+ },
+ );
+
+ let stream = AudioStreamBuilder::new()
+ .map_err(|e| format!("AAudio input builder: {e:?}"))?
+ .direction(AudioDirection::Input)
+ .input_preset(AudioInputPreset::Unprocessed)
+ .format(AudioFormat::PCM_Float)
+ .channel_count(1)
+ .performance_mode(AudioPerformanceMode::LowLatency)
+ .sharing_mode(AudioSharingMode::Shared)
+ .data_callback(callback)
+ .open_stream()
+ .map_err(|e| format!("AAudio input open: {e:?}"))?;
+
+ #[cfg(debug_assertions)]
+ log::info!(
+ "mic_input opened: sr={} ch={} format={:?} perf={:?} share={:?}",
+ stream.sample_rate(),
+ stream.channel_count(),
+ stream.format(),
+ stream.performance_mode(),
+ stream.sharing_mode(),
+ );
+
+ stream
+ .request_start()
+ .map_err(|e| format!("AAudio input start: {e:?}"))?;
+
+ Ok(MicInput { _stream: stream })
+ }
+}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index dd63182..70f1d91 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -5,30 +5,101 @@ use iced_wgpu::core::{Element, Theme};
use iced_widget::scrollable::AbsoluteOffset;
use crate::analyzer::FrameData;
-use crate::analyzer_worker::AnalyzerWorker;
+use crate::analyzer_worker::{AnalyzerMode, AnalyzerWorker};
use crate::engine::{AudioEngine, EngineEvent};
use crate::library::{self, Track};
use crate::library_worker::{LibraryUpdate, LibraryWorker};
use super::player;
+/// top-level playback mode: file-based local playback vs. live audio captured from the OS.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlaybackMode {
+ Local,
+ Capture,
+}
+
+impl PlaybackMode {
+ /// builds a mode from the u32 FFI wire format, falling back to Local on unknown values.
+ pub fn from_u32(v: u32) -> Self {
+ match v {
+ 1 => Self::Capture,
+ _ => Self::Local,
+ }
+ }
+}
+
+/// metadata and counters for Playback capture mode.
+#[derive(Debug, Default, Clone)]
+pub struct CaptureState {
+ pub title: Option,
+ pub artist: Option,
+ pub position_ms: u64,
+ pub duration_ms: u64,
+
+ /// foreign-session playback flag pushed by the host MediaController callback.
+ pub playing: bool,
+
+ /// flips true once any media session has reported metadata. drives the transport-enable state.
+ pub has_session: bool,
+
+ /// host-pushed flag: true when the user has granted notification listener access for now-playing info.
+ pub notification_access: bool,
+
+ /// running total of audio frames received over the FFI capture channel since launch.
+ pub samples_received: u64,
+
+ /// sample rate (Hz) of the most recent FFI capture push.
+ pub sample_rate: u32,
+}
+
/// owns the library, audio engine, background workers, and every UI-toggleable setting.
pub struct App {
pub library: Library,
pub selected_track: Option,
pub playing: bool,
pub immersive: bool,
+ pub playback_mode: PlaybackMode,
+ pub capture: CaptureState,
+
+ /// flags whether App::view renders the main mode-selection menu in place of the player.
+ pub show_main_menu: bool,
+
+ /// flips true once the Playback capture mode setup wizard completes.
+ pub capture_setup_complete: bool,
pub engine: Option,
pub worker: AnalyzerWorker,
pub library_worker: LibraryWorker,
pub frame_data: Arc>,
pub current_palette: Option>>,
pub settings: Settings,
+
+ /// stash of the other mode's settings. swapped with `settings` on every mode transition.
+ pub settings_inactive: Settings,
+
pub show_settings: bool,
/// shell-side picker request flag drained by the iOS host once per tick.
pub pending_pick: u8,
+ /// pending playback-capture action flag for the host shell (0 idle, 1 start, 2 stop).
+ pub pending_capture_action: u8,
+
+ /// flag drained by the Android shell to request Picture-in-Picture entry.
+ pub pending_pip_request: bool,
+
+ /// pending capture-mode transport command for the host shell (0 idle, 1 toggle, 2 prev, 3 next).
+ pub pending_capture_transport: u8,
+
+ /// pending capture-mode seek fraction in 0..=1, or negative to mean idle.
+ pub pending_capture_seek: f32,
+
+ /// flag drained by the Android shell to open the system Notification Listener settings.
+ pub pending_open_listener_settings: bool,
+
+ /// flag drained by the host shell to persist current settings (both slots) to disk.
+ pub pending_persist_settings: bool,
+
/// monotonic id stamped onto every decode request, matched against returning results.
pub current_decode_id: u64,
pub next_decode_id: u64,
@@ -58,7 +129,7 @@ pub struct App {
}
/// every visualizer toggle, slider value, and DSP parameter the settings panel exposes.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct Settings {
pub glass: bool,
pub entropy_on: bool,
@@ -87,7 +158,7 @@ impl Default for Settings {
entropy_on: false,
album_colors: false,
mirrored: true,
- inverted: false,
+ inverted: true,
entropy_strength: 0.0,
hue: 0.9,
contrast: 1.0,
@@ -124,6 +195,13 @@ pub enum Message {
ToggleImmersive,
ToggleChrome,
ToggleSettings,
+ SetPlaybackMode(PlaybackMode),
+ OpenLocalMode,
+ OpenCaptureMode,
+ ReturnToMainMenu,
+ EnterPip,
+ OpenListenerSettings,
+ ResetSettings,
NoOp,
SettingsScrolled(AbsoluteOffset),
SetGlass(bool),
@@ -147,10 +225,49 @@ pub enum Message {
PickedFiles(Vec),
}
+/// returns the platform-tuned (fft, hop, num_bins) triple for Playback capture mode. android/desktop keep the existing 26-bin default.
+#[cfg(target_os = "ios")]
+fn capture_dsp_defaults() -> (usize, usize, usize) {
+ (4096, 1024, 15)
+}
+
+#[cfg(target_os = "android")]
+fn capture_dsp_defaults() -> (usize, usize, usize) {
+ (8192, 64, 26)
+}
+
+#[cfg(not(any(target_os = "ios", target_os = "android")))]
+fn capture_dsp_defaults() -> (usize, usize, usize) {
+ (8192, 64, 26)
+}
+
+/// returns a fresh Settings populated with the platform-tuned defaults for the given playback mode.
+pub fn settings_for_mode(mode: PlaybackMode) -> Settings {
+ match mode {
+ PlaybackMode::Local => Settings::default(),
+ PlaybackMode::Capture => {
+ let mut s = Settings::default();
+ let (fft, hop, bins) = capture_dsp_defaults();
+ s.fft = fft as u32;
+ s.hop = hop as u32;
+ s.num_bins = bins as u32;
+ s
+ }
+ }
+}
+
+/// json wire shape persisted by the host shell, holding one Settings per mode.
+#[derive(serde::Serialize, serde::Deserialize)]
+struct PersistedSettings {
+ local: Settings,
+ capture: Settings,
+}
+
impl App {
/// builds the analyzer worker, library worker, audio engine, and seeds defaults.
pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self {
- let settings = Settings::default();
+ let settings = settings_for_mode(PlaybackMode::Local);
+ let settings_inactive = settings_for_mode(PlaybackMode::Capture);
let worker = AnalyzerWorker::spawn(device, queue);
worker.set_num_bins(settings.num_bins as usize);
worker.set_dsp_params(settings.fft as usize, settings.hop as usize);
@@ -162,6 +279,10 @@ impl App {
selected_track: None,
playing: false,
immersive: false,
+ playback_mode: PlaybackMode::Local,
+ capture: CaptureState::default(),
+ show_main_menu: true,
+ capture_setup_complete: false,
engine: AudioEngine::new()
.map_err(|e| eprintln!("yr_crystals: audio engine unavailable: {e}"))
.ok(),
@@ -170,8 +291,15 @@ impl App {
frame_data: Arc::new(Vec::new()),
current_palette: None,
settings,
+ settings_inactive,
show_settings: false,
pending_pick: 0,
+ pending_capture_action: 0,
+ pending_pip_request: false,
+ pending_capture_transport: 0,
+ pending_capture_seek: -1.0,
+ pending_open_listener_settings: false,
+ pending_persist_settings: false,
current_decode_id: 0,
next_decode_id: 0,
track_loading: false,
@@ -185,6 +313,167 @@ impl App {
}
}
+ /// sets the top-level playback mode. swaps the active/inactive settings slots and re-applies the worker config when the mode actually changes.
+ pub fn set_playback_mode(&mut self, mode: PlaybackMode) {
+ let prev = self.playback_mode;
+ self.playback_mode = mode;
+ let analyzer_mode = match mode {
+ PlaybackMode::Local => AnalyzerMode::Track,
+ PlaybackMode::Capture => AnalyzerMode::Live,
+ };
+ self.worker.set_mode(analyzer_mode);
+ if prev != mode {
+ std::mem::swap(&mut self.settings, &mut self.settings_inactive);
+ self.apply_settings_to_worker();
+ self.pending_capture_action = match mode {
+ PlaybackMode::Capture => 1,
+ PlaybackMode::Local => 2,
+ };
+ }
+ }
+
+ /// pushes every worker-relevant settings value to the analyzer worker.
+ pub fn apply_settings_to_worker(&self) {
+ self.worker.set_num_bins(self.settings.num_bins as usize);
+ self.worker.set_dsp_params(self.settings.fft as usize, self.settings.hop as usize);
+ self.worker.set_smoothing(
+ self.settings.granularity,
+ self.settings.detail,
+ self.settings.strength,
+ );
+ self.worker.set_gpu_blend(self.settings.gpu_blend);
+ }
+
+ /// replaces the active settings slot with the platform defaults for the current mode and re-applies them.
+ pub fn reset_settings_to_defaults(&mut self) {
+ self.settings = settings_for_mode(self.playback_mode);
+ self.apply_settings_to_worker();
+ }
+
+ /// drains the pending-persist flag set after a settings change.
+ pub fn take_pending_persist_settings(&mut self) -> bool {
+ let v = self.pending_persist_settings;
+ self.pending_persist_settings = false;
+ v
+ }
+
+ /// serializes both settings slots as JSON, keyed by mode. used by the host shell to write to disk.
+ pub fn settings_json(&self) -> String {
+ let (local, capture) = match self.playback_mode {
+ PlaybackMode::Local => (self.settings, self.settings_inactive),
+ PlaybackMode::Capture => (self.settings_inactive, self.settings),
+ };
+ let snapshot = PersistedSettings { local, capture };
+ serde_json::to_string(&snapshot).unwrap_or_default()
+ }
+
+ /// applies a settings JSON blob (both mode slots) loaded by the host shell at startup. silent no-op on parse failure.
+ pub fn apply_settings_json(&mut self, json: &str) {
+ let Ok(parsed) = serde_json::from_str::(json) else { return };
+ match self.playback_mode {
+ PlaybackMode::Local => {
+ self.settings = parsed.local;
+ self.settings_inactive = parsed.capture;
+ }
+ PlaybackMode::Capture => {
+ self.settings = parsed.capture;
+ self.settings_inactive = parsed.local;
+ }
+ }
+ self.apply_settings_to_worker();
+ }
+
+ /// returns the capture-action flag and clears the slot in one step.
+ pub fn take_pending_capture_action(&mut self) -> u8 {
+ let v = self.pending_capture_action;
+ self.pending_capture_action = 0;
+ v
+ }
+
+ /// drains the pending Picture-in-Picture request flag for the host shell.
+ pub fn take_pending_pip_request(&mut self) -> bool {
+ let v = self.pending_pip_request;
+ self.pending_pip_request = false;
+ v
+ }
+
+ /// re-applies Playback capture mode onto a freshly created viewport. skips firing the start action.
+ pub fn restore_capture_session(&mut self) {
+ self.playback_mode = PlaybackMode::Capture;
+ self.worker.set_mode(AnalyzerMode::Live);
+ self.show_main_menu = false;
+ self.capture_setup_complete = true;
+ }
+
+ /// updates the capture stats. forwards the chunk to the analyzer worker.
+ pub fn push_capture_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) {
+ if samples.is_empty() || channels == 0 {
+ return;
+ }
+ self.capture.sample_rate = sample_rate;
+ let frames = (samples.len() / channels as usize) as u64;
+ self.capture.samples_received = self.capture.samples_received.saturating_add(frames);
+ self.worker.push_live_pcm(samples.to_vec(), sample_rate, channels);
+ }
+
+ /// overwrites the four now-playing metadata fields in App.capture.
+ pub fn set_capture_metadata(
+ &mut self,
+ title: Option,
+ artist: Option,
+ position_ms: u64,
+ duration_ms: u64,
+ ) {
+ self.capture.title = title;
+ self.capture.artist = artist;
+ self.capture.position_ms = position_ms;
+ self.capture.duration_ms = duration_ms;
+ self.capture.has_session = true;
+ }
+
+ /// stores the foreign-session playback flag and live position from the host MediaController callback.
+ pub fn set_capture_playback_state(&mut self, playing: bool, position_ms: u64) {
+ self.capture.playing = playing;
+ self.capture.position_ms = position_ms;
+ self.capture.has_session = true;
+ }
+
+ /// resets every Playback capture metadata field. invoked when the host loses its primary MediaController.
+ pub fn clear_capture_session(&mut self) {
+ self.capture.title = None;
+ self.capture.artist = None;
+ self.capture.position_ms = 0;
+ self.capture.duration_ms = 0;
+ self.capture.playing = false;
+ self.capture.has_session = false;
+ }
+
+ /// stores the notification-listener access flag pushed by the host shell.
+ pub fn set_notification_access(&mut self, granted: bool) {
+ self.capture.notification_access = granted;
+ }
+
+ /// drains the pending request to open the system Notification Listener settings.
+ pub fn take_pending_open_listener_settings(&mut self) -> bool {
+ let v = self.pending_open_listener_settings;
+ self.pending_open_listener_settings = false;
+ v
+ }
+
+ /// drains the pending capture-mode transport command flag.
+ pub fn take_pending_capture_transport(&mut self) -> u8 {
+ let v = self.pending_capture_transport;
+ self.pending_capture_transport = 0;
+ v
+ }
+
+ /// drains the pending capture-mode seek fraction. negative means idle.
+ pub fn take_pending_capture_seek(&mut self) -> f32 {
+ let v = self.pending_capture_seek;
+ self.pending_capture_seek = -1.0;
+ v
+ }
+
/// returns the picker flag and clears the slot in one step.
pub fn take_pending_pick(&mut self) -> u8 {
let p = self.pending_pick;
@@ -509,6 +798,26 @@ impl App {
/// dispatches a UI message into the matching state mutation and worker call.
pub fn update(&mut self, msg: Message) {
+ let settings_changed = matches!(
+ msg,
+ Message::SetGlass(_)
+ | Message::SetEntropy(_)
+ | Message::SetAlbumColors(_)
+ | Message::SetMirrored(_)
+ | Message::SetInverted(_)
+ | Message::SetEntropyStrength(_)
+ | Message::SetHue(_)
+ | Message::SetContrast(_)
+ | Message::SetBrightness(_)
+ | Message::SetNumBins(_)
+ | Message::SetFft(_)
+ | Message::SetHop(_)
+ | Message::SetGranularity(_)
+ | Message::SetDetail(_)
+ | Message::SetStrength(_)
+ | Message::SetGpuBlend(_)
+ | Message::ResetSettings,
+ );
match msg {
Message::OpenFolder => {
#[cfg(all(not(target_os = "ios"), not(target_os = "android")))]
@@ -558,37 +867,49 @@ impl App {
self.load_index(idx);
}
}
- Message::TogglePlayPause => {
- if self.selected_track.is_some() {
- self.playing = !self.playing;
+ Message::TogglePlayPause => match self.playback_mode {
+ PlaybackMode::Local => {
+ if self.selected_track.is_some() {
+ self.playing = !self.playing;
+ if let Some(eng) = &self.engine {
+ if self.playing { eng.play(); } else { eng.pause(); }
+ }
+ }
+ }
+ PlaybackMode::Capture => self.pending_capture_transport = 1,
+ },
+ Message::Next => match self.playback_mode {
+ PlaybackMode::Local => {
+ if let Some(i) = self.selected_track {
+ if i + 1 < self.library.tracks.len() {
+ let next = i + 1;
+ self.selected_track = Some(next);
+ self.load_index(next);
+ }
+ }
+ }
+ PlaybackMode::Capture => self.pending_capture_transport = 3,
+ },
+ Message::Prev => match self.playback_mode {
+ PlaybackMode::Local => {
+ if let Some(i) = self.selected_track {
+ if i > 0 {
+ let prev = i - 1;
+ self.selected_track = Some(prev);
+ self.load_index(prev);
+ }
+ }
+ }
+ PlaybackMode::Capture => self.pending_capture_transport = 2,
+ },
+ Message::Seek(pos) => match self.playback_mode {
+ PlaybackMode::Local => {
if let Some(eng) = &self.engine {
- if self.playing { eng.play(); } else { eng.pause(); }
+ eng.seek_normalised(pos.clamp(0.0, 1.0));
}
}
- }
- Message::Next => {
- if let Some(i) = self.selected_track {
- if i + 1 < self.library.tracks.len() {
- let next = i + 1;
- self.selected_track = Some(next);
- self.load_index(next);
- }
- }
- }
- Message::Prev => {
- if let Some(i) = self.selected_track {
- if i > 0 {
- let prev = i - 1;
- self.selected_track = Some(prev);
- self.load_index(prev);
- }
- }
- }
- Message::Seek(pos) => {
- if let Some(eng) = &self.engine {
- eng.seek_normalised(pos.clamp(0.0, 1.0));
- }
- }
+ PlaybackMode::Capture => self.pending_capture_seek = pos.clamp(0.0, 1.0),
+ },
Message::ToggleImmersive => self.immersive = !self.immersive,
Message::ToggleChrome => {
if self.immersive {
@@ -610,6 +931,19 @@ impl App {
self.restore_settings_scroll = true;
}
}
+ Message::SetPlaybackMode(mode) => self.set_playback_mode(mode),
+ Message::OpenLocalMode => {
+ self.set_playback_mode(PlaybackMode::Local);
+ self.show_main_menu = false;
+ }
+ Message::OpenCaptureMode => {
+ self.set_playback_mode(PlaybackMode::Capture);
+ self.show_main_menu = false;
+ }
+ Message::ReturnToMainMenu => self.show_main_menu = true,
+ Message::EnterPip => self.pending_pip_request = true,
+ Message::OpenListenerSettings => self.pending_open_listener_settings = true,
+ Message::ResetSettings => self.reset_settings_to_defaults(),
Message::NoOp => {}
Message::SettingsScrolled(off) => self.settings_scroll = off,
Message::SetGlass(on) => self.settings.glass = on,
@@ -668,6 +1002,9 @@ impl App {
self.worker.set_gpu_blend(self.settings.gpu_blend);
}
}
+ if settings_changed {
+ self.pending_persist_settings = true;
+ }
}
/// queues a fresh decode for the given track, stamping a new request id.
@@ -697,7 +1034,11 @@ impl App {
/// builds the iced widget tree for the current frame.
pub fn view(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
- player::view(self)
+ if self.show_main_menu {
+ super::main_menu::view(self)
+ } else {
+ player::view(self)
+ }
}
}
diff --git a/src/ui/main_menu.rs b/src/ui/main_menu.rs
new file mode 100644
index 0000000..71fd6eb
--- /dev/null
+++ b/src/ui/main_menu.rs
@@ -0,0 +1,91 @@
+use iced_wgpu::core::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
+use iced_widget::{
+ button::{self, Status as ButtonStatus},
+ column, container, text, Space,
+};
+
+use super::app::{App, Message};
+use super::theme::palette;
+
+/// renders the top-level mode-selection screen.
+pub fn view(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
+ let title = text("YrXtls").size(56).color(palette::text());
+ let subtitle = text("Choose a mode").size(16).color(palette::text_dim());
+
+ let local = mode_button(
+ "Local mode",
+ "Play files from your device.",
+ Message::OpenLocalMode,
+ );
+ let capture = mode_button(
+ "Playback capture mode",
+ "Visualize whatever audio your system is playing.",
+ Message::OpenCaptureMode,
+ );
+
+ let body = column![
+ title,
+ subtitle,
+ Space::new().height(Length::Fixed(28.0)),
+ local,
+ capture,
+ ]
+ .spacing(16)
+ .align_x(Alignment::Center);
+
+ container(body)
+ .center_x(Length::Fill)
+ .center_y(Length::Fill)
+ .width(Length::Fill)
+ .height(Length::Fill)
+ .style(background_style)
+ .into()
+}
+
+/// builds one of the main-menu mode buttons with a title line and a one-line description below.
+fn mode_button<'a>(
+ title: &'a str,
+ subtitle: &'a str,
+ msg: Message,
+) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
+ let content = column![
+ text(title).size(22).color(palette::text()),
+ text(subtitle).size(13).color(palette::text_dim()),
+ ]
+ .spacing(6);
+
+ iced_widget::button(content)
+ .padding(Padding::from([20, 28]))
+ .width(Length::Fixed(420.0))
+ .on_press(msg)
+ .style(mode_button_style)
+ .into()
+}
+
+/// dark card style for the main-menu mode buttons with a brighter background on hover.
+fn mode_button_style(_t: &Theme, status: ButtonStatus) -> button::Style {
+ let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
+ let bg = if hovered {
+ Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 }
+ } else {
+ Color { r: 0.05, g: 0.05, b: 0.07, a: 1.0 }
+ };
+ button::Style {
+ background: Some(Background::Color(bg)),
+ text_color: palette::text(),
+ border: Border {
+ color: palette::border(),
+ width: 1.0,
+ radius: 12.0.into(),
+ },
+ ..Default::default()
+ }
+}
+
+/// pure-black full-screen background for the menu container.
+fn background_style(_t: &Theme) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(palette::bg())),
+ ..Default::default()
+ }
+}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index bd84e55..88342c8 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -1,5 +1,7 @@
/// top-level App state, message enum, and update loop.
pub mod app;
+/// first-launch screen with the Local / Playback capture mode selection.
+pub mod main_menu;
/// iced widget tree for sidebar, transport, settings overlay, and visualizer surface.
pub mod player;
/// dark palette tokens and the compositor clear color.
diff --git a/src/ui/player.rs b/src/ui/player.rs
index 84951fe..fb097db 100644
--- a/src/ui/player.rs
+++ b/src/ui/player.rs
@@ -22,7 +22,7 @@ const LOADING_DEG_PER_SEC: f32 = 120.0;
use crate::library::Track;
-use super::app::{App, Message};
+use super::app::{App, Message, PlaybackMode};
use super::theme::palette;
pub const SIDEBAR_W: f32 = 280.0;
@@ -108,7 +108,8 @@ fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::R
/// builds the title row plus folder, file, and settings chip buttons.
fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
- let title = text("Yr Xtals").size(16).color(palette::text());
+ let title = mouse_area(text("Yr Xtals").size(16).color(palette::text()))
+ .on_press(Message::ReturnToMainMenu);
let folder_btn = chip_button("Folder", Message::OpenFolder);
#[cfg(target_os = "ios")]
let file_btn = chip_button("Library", Message::OpenFile);
@@ -277,6 +278,7 @@ fn track_row_owned(
/// shader-backed visualizer surface stacked under loading and empty-state overlays.
fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let no_track = app.selected_track.is_none();
+ let needs_track_prompt = no_track && app.playback_mode == PlaybackMode::Local;
let viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader(
VisualizerProgram::new(
@@ -291,7 +293,7 @@ fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading {
loading_overlay()
- } else if no_track {
+ } else if needs_track_prompt {
centered_overlay_text("Pick a track from the sidebar", palette::text())
} else {
Space::new().width(Length::Fill).height(Length::Fill).into()
@@ -401,9 +403,22 @@ pub const SETTINGS_W: f32 = 340.0;
fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let s = &app.settings;
+ let header = row![
+ text("Settings").size(15).color(palette::text()),
+ Space::new().width(Length::Fill),
+ chip_button(
+ match app.playback_mode {
+ PlaybackMode::Local => "Reset Local",
+ PlaybackMode::Capture => "Reset Capture",
+ },
+ Message::ResetSettings,
+ ),
+ ]
+ .align_y(iced_wgpu::core::Alignment::Center);
+
let body = column![
Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)),
- text("Settings").size(15).color(palette::text()),
+ header,
Space::new().height(Length::Fixed(10.0)),
section_label("style"),
toggle_row("glass", s.glass, Message::SetGlass),
@@ -639,8 +654,22 @@ fn settings_panel_style(_theme: &Theme) -> container::Style {
}
}
-/// bottom transport bar with skip, play/pause, scrub slider, and position readout.
+/// bottom transport bar. file-mode renders the local skip/play/scrub set; capture mode renders the metadata strip with a PiP toggle.
fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
+ let bar: Element<'_, Message, Theme, iced_wgpu::Renderer> = match app.playback_mode {
+ PlaybackMode::Local => local_transport_bar(app),
+ PlaybackMode::Capture => capture_transport_bar(app),
+ };
+
+ container(bar)
+ .width(Length::Fill)
+ .height(Length::Fixed(TRANSPORT_H))
+ .style(panel_style)
+ .into()
+}
+
+/// row of skip, play/pause, scrub slider, and position readout for local file playback.
+fn local_transport_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let prev = transport_button(BSKIP_SVG, Message::Prev, app.selected_track.is_some());
let engine_playing = app
.engine
@@ -659,7 +688,7 @@ fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
.size(11)
.color(palette::text_dim());
- let bar = row![
+ row![
prev,
Space::new().width(Length::Fixed(6.0)),
play,
@@ -672,13 +701,125 @@ fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
]
.padding(Padding::from([0, 16]))
.align_y(iced_wgpu::core::Alignment::Center)
- .height(Length::Fill);
+ .height(Length::Fill)
+ .into()
+}
- container(bar)
- .width(Length::Fill)
- .height(Length::Fixed(TRANSPORT_H))
- .style(panel_style)
- .into()
+/// capture-mode transport row. dispatches into the session-active layout or one of the two awareness states (no access / no session).
+fn capture_transport_bar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
+ #[cfg(target_os = "android")]
+ if !app.capture.notification_access {
+ return capture_no_access_row(app);
+ }
+ if !app.capture.has_session {
+ return capture_no_session_row(app);
+ }
+ capture_session_row(app)
+}
+
+/// status row shown when notification listener access has not been granted yet. taps open Android's listener settings.
+#[cfg(target_os = "android")]
+fn capture_no_access_row<'a>(_app: &App) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
+ let hint = text("Now-playing controls require notification access.")
+ .size(13)
+ .color(palette::text_dim());
+ row![
+ hint,
+ Space::new().width(Length::Fill),
+ chip_button("Enable", Message::OpenListenerSettings),
+ Space::new().width(Length::Fixed(8.0)),
+ chip_button("PiP", Message::EnterPip),
+ ]
+ .padding(Padding::from([0, 16]))
+ .align_y(iced_wgpu::core::Alignment::Center)
+ .height(Length::Fill)
+ .into()
+}
+
+/// status row shown after listener access is granted while no foreign media session is publishing.
+fn capture_no_session_row<'a>(_app: &App) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
+ let hint = text("Play something nearby through your speaker to see it react.")
+ .size(13)
+ .color(palette::text_dim());
+ row![
+ hint,
+ Space::new().width(Length::Fill),
+ chip_button("PiP", Message::EnterPip),
+ ]
+ .padding(Padding::from([0, 16]))
+ .align_y(iced_wgpu::core::Alignment::Center)
+ .height(Length::Fill)
+ .into()
+}
+
+/// full transport row used while a foreign MediaSession is active: metadata, prev/play/next, scrub, time, PiP toggle.
+fn capture_session_row(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
+ let title = app
+ .capture
+ .title
+ .clone()
+ .unwrap_or_else(|| String::from("Capturing system audio"));
+ let artist = app.capture.artist.clone().unwrap_or_default();
+
+ let title_label = text(title).size(14).color(palette::text());
+ let artist_label = text(artist).size(11).color(palette::text_dim());
+ let meta = column![title_label, artist_label].spacing(2).width(Length::Fixed(200.0));
+
+ let prev = transport_button(BSKIP_SVG, Message::Prev, true);
+ let play_glyph = if app.capture.playing { PAUSE_SVG } else { PLAY_SVG };
+ let play = transport_button(play_glyph, Message::TogglePlayPause, true);
+ let next = transport_button(FSKIP_SVG, Message::Next, true);
+
+ let dur = app.capture.duration_ms.max(1) as f32;
+ let pos_fraction = (app.capture.position_ms as f32 / dur).clamp(0.0, 1.0);
+ let scrub_enabled = app.capture.duration_ms > 0;
+ let scrub = if scrub_enabled {
+ slider(0.0..=1.0, pos_fraction, Message::Seek)
+ .step(0.001_f32)
+ .width(Length::Fill)
+ } else {
+ slider(0.0..=1.0, 0.0_f32, |_| Message::NoOp)
+ .step(0.001_f32)
+ .width(Length::Fill)
+ };
+ let pos_label = text(format_capture_time(app.capture.position_ms, app.capture.duration_ms))
+ .size(11)
+ .color(palette::text_dim());
+
+ row![
+ meta,
+ Space::new().width(Length::Fixed(12.0)),
+ prev,
+ Space::new().width(Length::Fixed(6.0)),
+ play,
+ Space::new().width(Length::Fixed(6.0)),
+ next,
+ Space::new().width(Length::Fixed(16.0)),
+ scrub,
+ Space::new().width(Length::Fixed(12.0)),
+ pos_label,
+ Space::new().width(Length::Fixed(12.0)),
+ chip_button("PiP", Message::EnterPip),
+ ]
+ .padding(Padding::from([0, 16]))
+ .align_y(iced_wgpu::core::Alignment::Center)
+ .height(Length::Fill)
+ .into()
+}
+
+/// formats a position/duration pair as `m:ss / m:ss`, falling back to a blank duration on unknown length.
+fn format_capture_time(position_ms: u64, duration_ms: u64) -> String {
+ fn fmt(ms: u64) -> String {
+ let total_s = ms / 1000;
+ let m = total_s / 60;
+ let s = total_s % 60;
+ format!("{m}:{s:02}")
+ }
+ if duration_ms == 0 {
+ fmt(position_ms)
+ } else {
+ format!("{} / {}", fmt(position_ms), fmt(duration_ms))
+ }
}
/// small soft accent-tinted text-label button.
diff --git a/src/viewport.rs b/src/viewport.rs
index f19efb5..2d42599 100644
--- a/src/viewport.rs
+++ b/src/viewport.rs
@@ -386,6 +386,99 @@ impl ViewportHandle {
pub fn take_pending_pick(&mut self) -> u8 {
self.state.take_pending_pick()
}
+
+ /// drains the App's pending playback-capture action flag.
+ pub fn take_pending_capture_action(&mut self) -> u8 {
+ self.state.take_pending_capture_action()
+ }
+
+ /// drains the App's pending Picture-in-Picture request flag.
+ pub fn take_pending_pip_request(&mut self) -> bool {
+ self.state.take_pending_pip_request()
+ }
+
+ /// re-applies Playback capture mode onto a freshly created viewport without firing the start action.
+ pub fn restore_capture_session(&mut self) {
+ self.state.restore_capture_session();
+ self.needs_redraw = true;
+ }
+
+ /// switches the top-level playback mode from a u32 wire value (0=Local, 1=Capture).
+ pub fn set_playback_mode(&mut self, mode: u32) {
+ self.state.set_playback_mode(crate::ui::app::PlaybackMode::from_u32(mode));
+ self.needs_redraw = true;
+ }
+
+ /// forwards captured PCM from the host shell into the App.
+ pub fn push_capture_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) {
+ self.state.push_capture_pcm(samples, sample_rate, channels);
+ }
+
+ /// updates the displayed playback-capture metadata from the host shell.
+ pub fn set_capture_metadata(
+ &mut self,
+ title: Option,
+ artist: Option,
+ position_ms: u64,
+ duration_ms: u64,
+ ) {
+ self.state.set_capture_metadata(title, artist, position_ms, duration_ms);
+ self.needs_redraw = true;
+ }
+
+ /// stores the foreign-session playback flag and live position from the host MediaController callback.
+ pub fn set_capture_playback_state(&mut self, playing: bool, position_ms: u64) {
+ self.state.set_capture_playback_state(playing, position_ms);
+ self.needs_redraw = true;
+ }
+
+ /// resets every Playback capture metadata field.
+ pub fn clear_capture_session(&mut self) {
+ self.state.clear_capture_session();
+ self.needs_redraw = true;
+ }
+
+ /// stores the notification-listener access flag pushed by the host shell.
+ pub fn set_notification_access(&mut self, granted: bool) {
+ self.state.set_notification_access(granted);
+ self.needs_redraw = true;
+ }
+
+ /// drains the pending request to open the system Notification Listener settings.
+ pub fn take_pending_open_listener_settings(&mut self) -> bool {
+ self.state.take_pending_open_listener_settings()
+ }
+
+ /// drains the pending capture-mode transport command flag.
+ pub fn take_pending_capture_transport(&mut self) -> u8 {
+ self.state.take_pending_capture_transport()
+ }
+
+ /// returns a cloneable handle that pushes live PCM directly into the analyzer worker.
+ pub fn pcm_sender(&self) -> crate::analyzer_worker::PcmSender {
+ self.state.worker.pcm_sender()
+ }
+
+ /// serializes both settings slots as JSON for the host shell to persist.
+ pub fn settings_json(&self) -> String {
+ self.state.settings_json()
+ }
+
+ /// applies a settings JSON blob loaded by the host shell at startup.
+ pub fn apply_settings_json(&mut self, json: &str) {
+ self.state.apply_settings_json(json);
+ self.needs_redraw = true;
+ }
+
+ /// drains the pending-persist flag set after a settings change.
+ pub fn take_pending_persist_settings(&mut self) -> bool {
+ self.state.take_pending_persist_settings()
+ }
+
+ /// drains the pending capture-mode seek fraction.
+ pub fn take_pending_capture_seek(&mut self) -> f32 {
+ self.state.take_pending_capture_seek()
+ }
}
/// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller.
@@ -509,7 +602,8 @@ fn render(handle: &mut ViewportHandle) {
let animating = playing
|| handle.state.track_loading
- || handle.state.library_progress.is_some();
+ || handle.state.library_progress.is_some()
+ || handle.state.playback_mode == crate::ui::app::PlaybackMode::Capture;
if !animating && !handle.needs_redraw && !pending {
return;
}
diff --git a/src/visualizer/state.rs b/src/visualizer/state.rs
index a863309..d2cd4a6 100644
--- a/src/visualizer/state.rs
+++ b/src/visualizer/state.rs
@@ -111,6 +111,15 @@ impl VisState {
});
}
}
+
+ // flushes the rightmost bin to the viewport edge.
+ let max_x = out.iter().map(|b| b.log_x).fold(f32::NEG_INFINITY, f32::max);
+ if max_x.is_finite() && max_x > 0.0 {
+ let inv = 1.0 / max_x;
+ for b in out.iter_mut() {
+ b.log_x *= inv;
+ }
+ }
}
/// derives a hue from spectral midpoint and mean amplitude, smoothed by a circular running mean.
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 6e9bb36..0363fcb 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -17,7 +17,11 @@ fn main() -> ExitCode {
}
let extra_args: Vec<&String> = args.iter().skip(1).collect();
- let (action, platform) = parse(cmd);
+ let (action, platform) = if cmd == "release-playstore" {
+ ("release-playstore".to_string(), "android".to_string())
+ } else {
+ parse(cmd)
+ };
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -119,6 +123,7 @@ fn print_help() {
eprintln!(" xcodeproj-ios generate ios/YrXtals.xcodeproj via xcodegen");
eprintln!(" release-ios build an App Store-signed .ipa for Transporter");
eprintln!(" release-macos build a Mac App Store-signed .pkg for Transporter");
+ eprintln!(" release-playstore build a release-signed .aab for Google Play (org.elseif.yrxtals)");
eprintln!(" clean wipe build/, target/, /tmp/yr_crystals-target");
eprintln!(" clean-ios wipe iOS build dirs + generated assets (--cargo also runs cargo clean)");
eprintln!(" clean-android wipe android build dirs + generated assets (--cargo also runs cargo clean)");