Preparing for next release which will add a lot of new features. Mainly, playback capture mode which allows the visualizer to work with any external media through microphone capture.
This will also be the first android release upcoming.
This commit is contained in:
parent
b26d60b735
commit
00dded1c1d
|
|
@ -3,6 +3,7 @@ build_android/
|
|||
build_ios/
|
||||
build_macos/
|
||||
build_windows/
|
||||
.kotlin
|
||||
icons
|
||||
*.png
|
||||
vamp-plugin/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
|
@ -15,6 +17,7 @@
|
|||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|density|smallestScreenSize|uiMode"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
|
|
@ -23,6 +26,16 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MediaSessionReader"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@
|
|||
<string>17.0</string>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>Yr Xtals plays audio files from your library or Files app for offline visualization.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Yr Xtals listens through the microphone in Playback capture mode so the visualizer reacts to whatever audio is playing around you.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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..<chCount {
|
||||
let src = channelData[c]
|
||||
for f in 0..<frames {
|
||||
dst[f * chCount + c] = src[f]
|
||||
}
|
||||
}
|
||||
}
|
||||
interleaved.withUnsafeBufferPointer { buf in
|
||||
if let base = buf.baseAddress {
|
||||
viewport_push_capture_pcm(handle, base, total, sampleRate, channels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ class IcedViewportView: UIView {
|
|||
private var displayLink: CADisplayLink?
|
||||
private var isTornDown = false
|
||||
weak var controller: LibraryController?
|
||||
weak var captureController: CaptureController?
|
||||
#if DEBUG
|
||||
private var dbgFrameCount: UInt64 = 0
|
||||
#endif
|
||||
|
|
@ -62,6 +63,10 @@ class IcedViewportView: UIView {
|
|||
let viewPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||
viewportHandle = viewport_create(viewPtr, w, h, scale)
|
||||
print("[YrXtals] createViewport \(w)x\(h)@\(scale) handle=\(String(describing: viewportHandle))")
|
||||
if let handle = viewportHandle,
|
||||
let json = UserDefaults.standard.string(forKey: "yrxtals.settings_json") {
|
||||
json.withCString { viewport_set_settings_json(handle, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// releases the Rust handle and clears the local reference.
|
||||
|
|
@ -108,6 +113,23 @@ class IcedViewportView: UIView {
|
|||
if pending != 0 {
|
||||
controller?.presentPicker(kind: pending)
|
||||
}
|
||||
|
||||
let captureAction = viewport_take_pending_capture_action(handle)
|
||||
switch captureAction {
|
||||
case 1: captureController?.start()
|
||||
case 2: captureController?.stop()
|
||||
default: break
|
||||
}
|
||||
|
||||
if viewport_take_pending_persist_settings(handle) {
|
||||
if let cstr = viewport_get_settings_json(handle) {
|
||||
let json = String(cString: cstr)
|
||||
viewport_free_string(cstr)
|
||||
if !json.isEmpty {
|
||||
UserDefaults.standard.set(json, forKey: "yrxtals.settings_json")
|
||||
}
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
dbgFrameCount &+= 1
|
||||
if dbgFrameCount % 120 == 0 {
|
||||
|
|
|
|||
|
|
@ -168,8 +168,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
let libraryController = LibraryController()
|
||||
let captureController = CaptureController()
|
||||
|
||||
/// builds the window with IcedViewportController as the root and binds the library controller.
|
||||
/// builds the window with IcedViewportController as the root and binds the library + capture controllers.
|
||||
func scene(_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions) {
|
||||
|
|
@ -177,8 +178,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
let win = UIWindow(windowScene: windowScene)
|
||||
let root = IcedViewportController()
|
||||
root.viewport.controller = libraryController
|
||||
root.viewport.captureController = captureController
|
||||
libraryController.view = root.viewport
|
||||
libraryController.presentationHost = root
|
||||
captureController.view = root.viewport
|
||||
win.rootViewController = root
|
||||
win.overrideUserInterfaceStyle = .dark
|
||||
win.makeKeyAndVisible()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
# Builds a release-signed Android App Bundle (.aab) for the Google Play Console.
|
||||
# Requires env: YRXTALS_KEYSTORE, YRXTALS_STORE_PASSWORD, YRXTALS_KEY_PASSWORD
|
||||
# Optional: YRXTALS_KEY_ALIAS (defaults to "yrxtals")
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/_env.sh"
|
||||
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
if [[ -z "${YRXTALS_KEYSTORE:-}" ]]; then
|
||||
cat <<EOF >&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=<store-password>
|
||||
export YRXTALS_KEY_PASSWORD=<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"
|
||||
126
src/analyzer.rs
126
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<Arc<TrackData>>,
|
||||
last_frames: Vec<FrameData>,
|
||||
|
||||
/// 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<f32>,
|
||||
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -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<TrackData>),
|
||||
|
|
@ -18,6 +25,8 @@ enum Cmd {
|
|||
SetNumBins(usize),
|
||||
SetSmoothing { granularity: i32, detail: i32, strength: f32 },
|
||||
SetGpuBlend(f32),
|
||||
SetMode(AnalyzerMode),
|
||||
PushLivePcm { samples: Vec<f32>, sample_rate: u32, channels: u32 },
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +39,19 @@ pub struct AnalyzerWorker {
|
|||
join: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// cloneable handle that pushes live PCM into the analyzer worker without holding a reference to it.
|
||||
#[derive(Clone)]
|
||||
pub struct PcmSender {
|
||||
tx: Sender<Cmd>,
|
||||
}
|
||||
|
||||
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<f32>, 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<f32>, 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<Vec<FrameData>> {
|
||||
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,12 +187,18 @@ 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);
|
||||
}
|
||||
|
||||
match mode {
|
||||
AnalyzerMode::Track => {
|
||||
let total = total_frames.load(Ordering::Acquire);
|
||||
if total > 0 {
|
||||
let frame = playhead_frame.load(Ordering::Acquire);
|
||||
|
|
@ -155,6 +207,21 @@ fn run(
|
|||
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<Vec<FrameData>> = 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next_tick += interval;
|
||||
let now = Instant::now();
|
||||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
238
src/android.rs
238
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<MicInput>,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
80
src/ios.rs
80
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) {
|
||||
|
|
|
|||
|
|
@ -26,3 +26,6 @@ pub mod ios;
|
|||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod mic_input;
|
||||
|
|
|
|||
|
|
@ -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<Self, String> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
357
src/ui/app.rs
357
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<String>,
|
||||
pub artist: Option<String>,
|
||||
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<usize>,
|
||||
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<AudioEngine>,
|
||||
pub worker: AnalyzerWorker,
|
||||
pub library_worker: LibraryWorker,
|
||||
pub frame_data: Arc<Vec<FrameData>>,
|
||||
pub current_palette: Option<Arc<Vec<[f32; 3]>>>,
|
||||
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<PathBuf>),
|
||||
}
|
||||
|
||||
/// 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::<PersistedSettings>(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<String>,
|
||||
artist: Option<String>,
|
||||
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,7 +867,8 @@ impl App {
|
|||
self.load_index(idx);
|
||||
}
|
||||
}
|
||||
Message::TogglePlayPause => {
|
||||
Message::TogglePlayPause => match self.playback_mode {
|
||||
PlaybackMode::Local => {
|
||||
if self.selected_track.is_some() {
|
||||
self.playing = !self.playing;
|
||||
if let Some(eng) = &self.engine {
|
||||
|
|
@ -566,7 +876,10 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::Next => {
|
||||
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;
|
||||
|
|
@ -575,7 +888,10 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::Prev => {
|
||||
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;
|
||||
|
|
@ -584,11 +900,16 @@ impl App {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::Seek(pos) => {
|
||||
PlaybackMode::Capture => self.pending_capture_transport = 2,
|
||||
},
|
||||
Message::Seek(pos) => match self.playback_mode {
|
||||
PlaybackMode::Local => {
|
||||
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> {
|
||||
if self.show_main_menu {
|
||||
super::main_menu::view(self)
|
||||
} else {
|
||||
player::view(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
165
src/ui/player.rs
165
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,15 +701,127 @@ 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);
|
||||
|
||||
container(bar)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(TRANSPORT_H))
|
||||
.style(panel_style)
|
||||
.height(Length::Fill)
|
||||
.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.
|
||||
fn chip_button<'a>(
|
||||
label: &'a str,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
artist: Option<String>,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
Loading…
Reference in New Issue