mirror of
https://github.com/Michatec/Radio.git
synced 2026-05-31 10:32:40 +02:00
feat(ui): add spectrum analyzer visualizer
This commit is contained in:
@@ -27,6 +27,7 @@ object Keys {
|
||||
const val EXTRA_START_LAST_PLAYED_STATION: String = "START_LAST_PLAYED_STATION"
|
||||
const val EXTRA_SLEEP_TIMER_REMAINING: String = "SLEEP_TIMER_REMAINING"
|
||||
const val EXTRA_METADATA_HISTORY: String = "METADATA_HISTORY"
|
||||
const val EXTRA_VISUALIZER_DATA: String = "VISUALIZER_DATA"
|
||||
|
||||
// arguments
|
||||
const val ARG_UPDATE_COLLECTION: String = "ArgUpdateCollection"
|
||||
@@ -43,6 +44,7 @@ object Keys {
|
||||
const val CMD_PLAY_STREAM: String = "PLAY_STREAM"
|
||||
const val CMD_REQUEST_SLEEP_TIMER_REMAINING: String = "REQUEST_SLEEP_TIMER_REMAINING"
|
||||
const val CMD_REQUEST_METADATA_HISTORY: String = "REQUEST_METADATA_HISTORY"
|
||||
const val CMD_GET_VISUALIZER_DATA: String = "GET_VISUALIZER_DATA"
|
||||
|
||||
// preferences
|
||||
const val PREF_RADIO_BROWSER_API: String = "RADIO_BROWSER_API"
|
||||
|
||||
@@ -375,6 +375,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
|
||||
builder.add(SessionCommand(Keys.CMD_CANCEL_SLEEP_TIMER, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_REQUEST_SLEEP_TIMER_REMAINING, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY))
|
||||
builder.add(SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY))
|
||||
return MediaSession.ConnectionResult.accept(builder.build(), connectionResult.availablePlayerCommands)
|
||||
}
|
||||
|
||||
@@ -461,6 +462,19 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
|
||||
)
|
||||
)
|
||||
}
|
||||
Keys.CMD_GET_VISUALIZER_DATA -> {
|
||||
val resultBundle = Bundle()
|
||||
resultBundle.putFloatArray(
|
||||
Keys.EXTRA_VISUALIZER_DATA,
|
||||
nativeAudioProcessor.getVisualizer()
|
||||
)
|
||||
return Futures.immediateFuture(
|
||||
SessionResult(
|
||||
SessionResult.RESULT_SUCCESS,
|
||||
resultBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
}
|
||||
|
||||
@@ -250,6 +250,14 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
val preferenceVisualizer = Preference(context)
|
||||
preferenceVisualizer.title = getString(R.string.pref_visualizer_title)
|
||||
preferenceVisualizer.setIcon(R.drawable.ic_music_note_24dp)
|
||||
preferenceVisualizer.setOnPreferenceClickListener {
|
||||
findNavController().navigate(R.id.action_settings_to_visualizer)
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
|
||||
// set up "App Version" preference
|
||||
val preferenceAppVersion = Preference(context)
|
||||
preferenceAppVersion.title = getString(R.string.pref_app_version_title)
|
||||
@@ -299,7 +307,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
||||
// set preference categories
|
||||
val preferenceCategoryGeneral = PreferenceCategory(activity as Context)
|
||||
preferenceCategoryGeneral.title = getString(R.string.pref_general_title)
|
||||
|
||||
|
||||
val preferenceCategoryAudioEffects = PreferenceCategory(context)
|
||||
preferenceCategoryAudioEffects.title = getString(R.string.pref_audio_effects_title)
|
||||
|
||||
@@ -328,6 +336,7 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
||||
preferenceCategoryAudioEffects.addPreference(preferenceDrc)
|
||||
preferenceCategoryAudioEffects.addPreference(preferencePresetSelection)
|
||||
preferenceCategoryAudioEffects.addPreference(preferenceEqualizer)
|
||||
preferenceCategoryAudioEffects.addPreference(preferenceVisualizer)
|
||||
|
||||
screen.addPreference(preferenceCategoryMaintenance)
|
||||
preferenceCategoryMaintenance.addPreference(preferenceUpdateStationImages)
|
||||
@@ -394,13 +403,13 @@ class SettingsFragment : PreferenceFragmentCompat(), YesNoDialog.YesNoDialogList
|
||||
|
||||
// Update Bass Boost
|
||||
findPreference<Preference>(Keys.PREF_BASS_BOOST)?.isEnabled = !isPresetSelected
|
||||
|
||||
|
||||
// Update Reverb
|
||||
findPreference<Preference>(Keys.PREF_REVERB)?.isEnabled = !isPresetSelected
|
||||
|
||||
|
||||
// Update DRC
|
||||
findPreference<Preference>(Keys.PREF_DRC)?.isEnabled = !isPresetSelected
|
||||
|
||||
|
||||
// Update Equalizer with proper key
|
||||
val preferenceEqualizer = findPreference<Preference>(Keys.PREF_EQUALIZER)
|
||||
if (preferenceEqualizer != null) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.michatec.radio
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.michatec.radio.extensions.requestVisualizerData
|
||||
import com.michatec.radio.helpers.ExtrasHelper
|
||||
|
||||
/*
|
||||
* VisualizerFragment class: Handles audio visualization
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class VisualizerFragment : PreferenceFragmentCompat() {
|
||||
|
||||
private val TAG = "VisualizerFragment"
|
||||
private lateinit var controllerFuture: ListenableFuture<MediaController>
|
||||
private val controller: MediaController?
|
||||
get() = if (this::controllerFuture.isInitialized && controllerFuture.isDone) {
|
||||
try { controllerFuture.get() } catch (_: Exception) { null }
|
||||
} else null
|
||||
|
||||
private var visualizerPref: ExtrasHelper.VisualizerPreference? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
(activity as AppCompatActivity).supportActionBar?.title = getString(R.string.pref_visualizer_title)
|
||||
(activity as AppCompatActivity).supportActionBar?.show()
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
val context = preferenceManager.context
|
||||
val screen = preferenceManager.createPreferenceScreen(context)
|
||||
|
||||
visualizerPref = ExtrasHelper.VisualizerPreference(context)
|
||||
visualizerPref?.key = "visualizer_key"
|
||||
screen.addPreference(visualizerPref!!)
|
||||
|
||||
preferenceScreen = screen
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
initializeController()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
releaseController()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
private fun initializeController() {
|
||||
controllerFuture = MediaController.Builder(
|
||||
requireContext(),
|
||||
SessionToken(requireContext(), ComponentName(requireContext(), PlayerService::class.java))
|
||||
).buildAsync()
|
||||
controllerFuture.addListener({
|
||||
Log.d(TAG, "MediaController connected: ${controller != null}")
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
private fun releaseController() {
|
||||
if (this::controllerFuture.isInitialized) {
|
||||
MediaController.releaseFuture(controllerFuture)
|
||||
}
|
||||
}
|
||||
|
||||
private val pollRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
val c = controller
|
||||
if (c != null && c.isPlaying) {
|
||||
val resultFuture = c.requestVisualizerData()
|
||||
resultFuture.addListener({
|
||||
try {
|
||||
val result = resultFuture.get()
|
||||
if (result.resultCode == androidx.media3.session.SessionResult.RESULT_SUCCESS) {
|
||||
val data = result.extras.getFloatArray(Keys.EXTRA_VISUALIZER_DATA)
|
||||
if (data != null && data.isNotEmpty()) {
|
||||
visualizerPref?.update(data)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Custom command failed with result code: ${result.resultCode}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching visualizer data", e)
|
||||
}
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
handler.postDelayed(this, 25) // ~40 FPS
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
handler.removeCallbacks(pollRunnable)
|
||||
handler.post(pollRunnable)
|
||||
}
|
||||
|
||||
private fun stopPolling() {
|
||||
handler.removeCallbacks(pollRunnable)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fun MediaController.requestSleepTimerRemaining(): ListenableFuture<SessionResult
|
||||
}
|
||||
|
||||
|
||||
/* Request sleep timer remaining */
|
||||
/* Request metadata history */
|
||||
fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_REQUEST_METADATA_HISTORY, Bundle.EMPTY),
|
||||
@@ -43,6 +43,14 @@ fun MediaController.requestMetadataHistory(): ListenableFuture<SessionResult> {
|
||||
)
|
||||
}
|
||||
|
||||
/* Request visualizer data */
|
||||
fun MediaController.requestVisualizerData(): ListenableFuture<SessionResult> {
|
||||
return sendCustomCommand(
|
||||
SessionCommand(Keys.CMD_GET_VISUALIZER_DATA, Bundle.EMPTY),
|
||||
Bundle.EMPTY
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Starts playback with a new media item */
|
||||
fun MediaController.play(context: Context, station: Station) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceViewHolder
|
||||
import com.michatec.radio.R
|
||||
|
||||
class ExtrasHelper {
|
||||
companion object {
|
||||
private const val TAG = "ExtrasHelper"
|
||||
init {
|
||||
try {
|
||||
System.loadLibrary("extra")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load extra library", e)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private external fun visualize(surface: Surface, data: FloatArray)
|
||||
|
||||
fun render(surface: Surface, data: FloatArray) {
|
||||
if (!surface.isValid) return
|
||||
try {
|
||||
visualize(surface, data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Native visualize failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VisualizerPreference(context: Context, attrs: AttributeSet? = null) : Preference(context, attrs) {
|
||||
private var visualizerView: VisualizerView? = null
|
||||
|
||||
init {
|
||||
// We can use a standard layout and inject our view
|
||||
layoutResource = R.layout.preference_visualizer
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreferenceViewHolder) {
|
||||
super.onBindViewHolder(holder)
|
||||
|
||||
// Try to find the container in the inflated layout
|
||||
var container = holder.findViewById(R.id.visualizer_container) as? ViewGroup
|
||||
|
||||
// Fallback: If not found by ID, maybe the root is the container?
|
||||
if (container == null && holder.itemView is ViewGroup) {
|
||||
container = holder.itemView as ViewGroup
|
||||
}
|
||||
|
||||
if (container != null) {
|
||||
if (visualizerView == null) {
|
||||
visualizerView = VisualizerView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val currentParent = visualizerView?.parent as? ViewGroup
|
||||
if (currentParent != container) {
|
||||
currentParent?.removeView(visualizerView)
|
||||
// If we injected into a standard preference, don't clear everything, just add
|
||||
if (container is FrameLayout || container.childCount == 0) {
|
||||
container.removeAllViews()
|
||||
}
|
||||
container.addView(visualizerView)
|
||||
}
|
||||
} else {
|
||||
Log.e("VisualizerPreference", "Could not find any container to attach VisualizerView!")
|
||||
}
|
||||
}
|
||||
|
||||
fun update(data: FloatArray) {
|
||||
visualizerView?.update(data)
|
||||
}
|
||||
}
|
||||
|
||||
class VisualizerView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
|
||||
|
||||
private var surface: Surface? = null
|
||||
|
||||
init {
|
||||
Log.d("VisualizerView", "VisualizerView initialized")
|
||||
holder.addCallback(this)
|
||||
}
|
||||
|
||||
fun update(data: FloatArray) {
|
||||
val s = surface
|
||||
if (s != null && s.isValid) {
|
||||
render(s, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
surface = holder.surface
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
surface = holder.surface
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surface = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.michatec.radio.helpers
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
@@ -13,11 +14,18 @@ import java.nio.ByteOrder
|
||||
class NativeAudioProcessor : BaseAudioProcessor() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeAudioProcessor"
|
||||
init {
|
||||
System.loadLibrary("dsp")
|
||||
try {
|
||||
System.loadLibrary("dsp")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load dsp library", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var directBuffer: ByteBuffer? = null
|
||||
|
||||
// ===== JNI =====
|
||||
private external fun setDrcEnabled(enabled: Boolean)
|
||||
private external fun setReverbMix(mix: Float)
|
||||
@@ -37,7 +45,6 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
||||
fun enableBassBoost(gainDb: Float) = setBassBoost(gainDb)
|
||||
fun setWidth(width: Float) = setStereoWidth(width)
|
||||
|
||||
@Suppress("unused")
|
||||
fun getVisualizer(): FloatArray {
|
||||
val raw = getFftData()
|
||||
val out = FloatArray(raw.size)
|
||||
@@ -47,8 +54,11 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
||||
|
||||
// ===== AudioProcessor Overrides =====
|
||||
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat {
|
||||
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT)
|
||||
// Always try to support the input format if it is PCM 16-bit
|
||||
if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) {
|
||||
Log.e(TAG, "Unsupported encoding: ${inputAudioFormat.encoding}")
|
||||
throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat)
|
||||
}
|
||||
return inputAudioFormat
|
||||
}
|
||||
|
||||
@@ -56,20 +66,33 @@ class NativeAudioProcessor : BaseAudioProcessor() {
|
||||
val size = inputBuffer.remaining()
|
||||
if (size == 0) return
|
||||
|
||||
// Direct ByteBuffer -> JNI
|
||||
inputBuffer.order(ByteOrder.nativeOrder())
|
||||
processAudioDirect(inputBuffer, size)
|
||||
// Always ensure we have a direct buffer for JNI
|
||||
if (directBuffer == null || directBuffer!!.capacity() < size) {
|
||||
directBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
|
||||
}
|
||||
|
||||
directBuffer!!.clear()
|
||||
inputBuffer.position()
|
||||
directBuffer!!.put(inputBuffer)
|
||||
|
||||
directBuffer!!.flip()
|
||||
|
||||
// Process audio in JNI
|
||||
processAudioDirect(directBuffer!!, size)
|
||||
|
||||
// Replace output buffer
|
||||
// Copy processed data back to output
|
||||
val out = replaceOutputBuffer(size)
|
||||
out.order(ByteOrder.nativeOrder())
|
||||
|
||||
// Mark as processed and copy to output
|
||||
val currentPos = inputBuffer.position()
|
||||
out.put(inputBuffer)
|
||||
inputBuffer.position(currentPos + size)
|
||||
|
||||
|
||||
directBuffer!!.position(0)
|
||||
out.put(directBuffer!!)
|
||||
out.flip()
|
||||
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
super.onReset()
|
||||
directBuffer = null
|
||||
}
|
||||
|
||||
// ===== Presets =====
|
||||
|
||||
Reference in New Issue
Block a user