Twelve: Add visualizer

Co-authored-by: Luca Stefani <luca.stefani.ge1@gmail.com>
Change-Id: Ib7630cdb939e16afa6e1edf9c215656cee99ff8f
diff --git a/app/Android.bp b/app/Android.bp
index dad3ac2..0b76895 100644
--- a/app/Android.bp
+++ b/app/Android.bp
@@ -56,6 +56,7 @@
         "androidx.viewpager2_viewpager2",
         "kotlinx_coroutines_guava",
         "Twelve_com.google.android.material_material",
+        "Twelve_com.github.bogerchan_Nier-Visualizer",
     ],
 
     optimize: {
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1ee5846..b287211 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -92,6 +92,9 @@
     implementation(libs.androidx.viewpager2)
     implementation(libs.kotlinx.coroutines.guava)
     implementation(libs.material)
+    implementation(libs.nier.visualizer) {
+        exclude(group = "com.android.support")
+    }
 }
 
 configure<GenerateBpPluginExtension> {
diff --git a/app/libs/Android.bp b/app/libs/Android.bp
index dcde572..03ef205 100644
--- a/app/libs/Android.bp
+++ b/app/libs/Android.bp
@@ -409,6 +409,36 @@
     java_version: "1.7",
 }
 
+android_library_import {
+    name: "Twelve_com.github.bogerchan_Nier-Visualizer-nodeps",
+    aars: ["com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar"],
+    sdk_version: "35",
+    min_sdk_version: "14",
+    apex_available: [
+        "//apex_available:platform",
+        "//apex_available:anyapex",
+    ],
+    static_libs: [
+        "org.jetbrains.kotlin_kotlin-stdlib-jre7",
+    ],
+}
+
+android_library {
+    name: "Twelve_com.github.bogerchan_Nier-Visualizer",
+    sdk_version: "35",
+    min_sdk_version: "14",
+    apex_available: [
+        "//apex_available:platform",
+        "//apex_available:anyapex",
+    ],
+    manifest: "com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml",
+    static_libs: [
+        "Twelve_com.github.bogerchan_Nier-Visualizer-nodeps",
+        "org.jetbrains.kotlin_kotlin-stdlib-jre7",
+    ],
+    java_version: "1.7",
+}
+
 java_import {
     name: "Twelve_com.github.philburk_jsyn-nodeps",
     jars: ["com/github/philburk/jsyn/40a41092cbab558d7d410ec43d93bb1e4121e86a/jsyn-40a41092cbab558d7d410ec43d93bb1e4121e86a.jar"],
diff --git a/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml
new file mode 100644
index 0000000..9ea6a36
--- /dev/null
+++ b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="me.bogerchan.niervisualizer.core"
+    android:versionCode="1"
+    android:versionName="1.0" >
+
+    <uses-sdk
+        android:minSdkVersion="19"
+        android:targetSdkVersion="26" />
+
+</manifest>
\ No newline at end of file
diff --git a/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml.license b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml.license
new file mode 100644
index 0000000..ce7d22b
--- /dev/null
+++ b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/AndroidManifest.xml.license
@@ -0,0 +1,2 @@
+SPDX-FileCopyrightText: 2017-2024 Boger Chan
+
diff --git a/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar
new file mode 100644
index 0000000..8523637
--- /dev/null
+++ b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar
Binary files differ
diff --git a/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar.license b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar.license
new file mode 100644
index 0000000..ce7d22b
--- /dev/null
+++ b/app/libs/com/github/bogerchan/Nier-Visualizer/v0.1.3/Nier-Visualizer-v0.1.3.aar.license
@@ -0,0 +1,2 @@
+SPDX-FileCopyrightText: 2017-2024 Boger Chan
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e048618..112da23 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,6 +17,7 @@
         android:name="android.permission.READ_EXTERNAL_STORAGE"
         android:maxSdkVersion="32" />
     <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
 
     <application
         android:name=".TwelveApplication"
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
index 092eb6b..041bd78 100644
--- a/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
+++ b/app/src/main/java/org/lineageos/twelve/fragments/NowPlayingFragment.kt
@@ -8,10 +8,12 @@
 import android.animation.ValueAnimator
 import android.content.Intent
 import android.graphics.ImageDecoder
+import android.graphics.PixelFormat
 import android.icu.text.DecimalFormat
 import android.icu.text.DecimalFormatSymbols
 import android.media.audiofx.AudioEffect
 import android.os.Bundle
+import android.view.SurfaceView
 import android.view.View
 import android.view.animation.LinearInterpolator
 import android.widget.ImageView
@@ -26,7 +28,9 @@
 import androidx.core.widget.NestedScrollView
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.viewModels
+import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
 import androidx.media3.common.Player
@@ -38,6 +42,7 @@
 import com.google.android.material.slider.Slider
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
+import me.bogerchan.niervisualizer.NierVisualizerManager
 import org.lineageos.twelve.R
 import org.lineageos.twelve.TwelveApplication
 import org.lineageos.twelve.ext.getViewProperty
@@ -62,7 +67,6 @@
     private val albumTitleTextView by getViewProperty<TextView>(R.id.albumTitleTextView)
     private val audioTitleTextView by getViewProperty<TextView>(R.id.audioTitleTextView)
     private val artistNameTextView by getViewProperty<TextView>(R.id.artistNameTextView)
-    private val castMaterialButton by getViewProperty<MaterialButton>(R.id.castMaterialButton)
     private val currentTimestampTextView by getViewProperty<TextView>(R.id.currentTimestampTextView)
     private val durationTimestampTextView by getViewProperty<TextView>(R.id.durationTimestampTextView)
     private val equalizerMaterialButton by getViewProperty<MaterialButton>(R.id.equalizerMaterialButton)
@@ -81,17 +85,75 @@
     private val shuffleMarkerImageView by getViewProperty<ImageView>(R.id.shuffleMarkerImageView)
     private val shuffleMaterialButton by getViewProperty<MaterialButton>(R.id.shuffleMaterialButton)
     private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+    private val visualizerMaterialButton by getViewProperty<MaterialButton>(R.id.visualizerMaterialButton)
+    private val visualizerSurfaceView by getViewProperty<SurfaceView>(R.id.visualizerSurfaceView)
 
     // Progress slider state
     private var isProgressSliderDragging = false
     private var animator: ValueAnimator? = null
 
     // AudioFX
+    private val audioSessionId: Int
+        get() = (requireActivity().application as TwelveApplication).audioSessionId
     private val audioEffectsStartForResult =
         registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
             // Empty
         }
 
+    // Visualizer
+    private val visualizerManager = NierVisualizerManager()
+    private val visualizerViewLifecycleObserver = object : DefaultLifecycleObserver {
+        private var isVisualizerInitialized = false
+        private var isVisualizerStarted = false
+
+        override fun onCreate(owner: LifecycleOwner) {
+            val initResult = visualizerManager.init(audioSessionId)
+            isVisualizerInitialized = initResult == NierVisualizerManager.SUCCESS
+
+            owner.lifecycleScope.launch {
+                owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    viewModel.currentVisualizerType.collectLatest { currentVisualizerType ->
+                        if (isVisualizerInitialized) {
+                            currentVisualizerType.factory.invoke()?.let {
+                                visualizerManager.start(visualizerSurfaceView, it)
+                                isVisualizerStarted = true
+                            } ?: run {
+                                visualizerManager.stop()
+                                isVisualizerStarted = false
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        override fun onResume(owner: LifecycleOwner) {
+            if (isVisualizerStarted) {
+                visualizerManager.resume()
+            }
+        }
+
+        override fun onPause(owner: LifecycleOwner) {
+            if (isVisualizerStarted) {
+                visualizerManager.pause()
+            }
+        }
+
+        override fun onStop(owner: LifecycleOwner) {
+            if (isVisualizerStarted) {
+                visualizerManager.stop()
+            }
+            isVisualizerStarted = false
+        }
+
+        override fun onDestroy(owner: LifecycleOwner) {
+            if (isVisualizerInitialized) {
+                visualizerManager.release()
+            }
+            isVisualizerInitialized = false
+        }
+    }
+
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
@@ -117,6 +179,12 @@
             )
         }
 
+        // Visualizer
+        visualizerSurfaceView.setZOrderOnTop(true)
+        visualizerSurfaceView.holder.setFormat(PixelFormat.TRANSPARENT)
+
+        viewLifecycleOwner.lifecycle.addObserver(visualizerViewLifecycleObserver)
+
         // Audio informations
         audioTitleTextView.isSelected = true
         artistNameTextView.isSelected = true
@@ -166,22 +234,25 @@
         }
 
         equalizerMaterialButton.setOnClickListener {
-            val activity = requireActivity()
-
             // Open system equalizer
             audioEffectsStartForResult.launch(
                 Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply {
-                    putExtra(AudioEffect.EXTRA_PACKAGE_NAME, activity.packageName)
-                    putExtra(
-                        AudioEffect.EXTRA_AUDIO_SESSION,
-                        (activity.application as TwelveApplication).audioSessionId
-                    )
+                    putExtra(AudioEffect.EXTRA_PACKAGE_NAME, requireContext().packageName)
+                    putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
                     putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
                 },
                 null
             )
         }
 
+        visualizerMaterialButton.setOnClickListener {
+            viewModel.nextVisualizerType()
+        }
+        visualizerMaterialButton.setOnLongClickListener {
+            viewModel.disableVisualizer()
+            true
+        }
+
         viewLifecycleOwner.lifecycleScope.launch {
             viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                 launch {
@@ -438,6 +509,13 @@
                         }
                     }
                 }
+
+                launch {
+                    viewModel.currentVisualizerType.collectLatest {
+                        visualizerSurfaceView.isVisible =
+                            it != NowPlayingViewModel.VisualizerType.NONE
+                    }
+                }
             }
         }
     }
diff --git a/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt b/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt
index ce09b8c..5df9ad9 100644
--- a/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt
+++ b/app/src/main/java/org/lineageos/twelve/utils/PermissionsUtils.kt
@@ -15,11 +15,13 @@
     /**
      * Permissions required to run the app
      */
-    val mainPermissions = mutableListOf<String>().apply {
+    val mainPermissions = buildList {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
             add(Manifest.permission.READ_MEDIA_AUDIO)
         } else {
             add(Manifest.permission.READ_EXTERNAL_STORAGE)
         }
+
+        add(Manifest.permission.RECORD_AUDIO)
     }.toTypedArray()
 }
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt
index b91b54a..ebdc154 100644
--- a/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/NowPlayingViewModel.kt
@@ -15,6 +15,7 @@
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.filterNotNull
@@ -23,6 +24,14 @@
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.mapLatest
 import kotlinx.coroutines.flow.stateIn
+import me.bogerchan.niervisualizer.renderer.IRenderer
+import me.bogerchan.niervisualizer.renderer.circle.CircleBarRenderer
+import me.bogerchan.niervisualizer.renderer.circle.CircleRenderer
+import me.bogerchan.niervisualizer.renderer.columnar.ColumnarType1Renderer
+import me.bogerchan.niervisualizer.renderer.columnar.ColumnarType2Renderer
+import me.bogerchan.niervisualizer.renderer.columnar.ColumnarType3Renderer
+import me.bogerchan.niervisualizer.renderer.columnar.ColumnarType4Renderer
+import me.bogerchan.niervisualizer.renderer.line.LineRenderer
 import org.lineageos.twelve.ext.availableCommandsFlow
 import org.lineageos.twelve.ext.isPlayingFlow
 import org.lineageos.twelve.ext.mediaItemFlow
@@ -53,6 +62,17 @@
         }
     }
 
+    enum class VisualizerType(val factory: () -> Array<IRenderer>?) {
+        NONE({ null }),
+        TYPE_1({ arrayOf(ColumnarType1Renderer()) }),
+        TYPE_2({ arrayOf(ColumnarType2Renderer()) }),
+        TYPE_3({ arrayOf(ColumnarType3Renderer()) }),
+        TYPE_4({ arrayOf(ColumnarType4Renderer()) }),
+        LINE({ arrayOf(LineRenderer(true)) }),
+        CIRCLE_BAR({ arrayOf(CircleBarRenderer()) }),
+        CIRCLE({ arrayOf(CircleRenderer(true)) }),
+    }
+
     @OptIn(ExperimentalCoroutinesApi::class)
     val mediaMetadata = mediaController
         .filterNotNull()
@@ -249,6 +269,20 @@
             initialValue = Triple(null, null, 1f)
         )
 
+    private val _currentVisualizerType = MutableStateFlow(VisualizerType.entries.first())
+    val currentVisualizerType = combine(
+        _currentVisualizerType,
+        isPlaying,
+    ) { currentVisualizerType, isPlaying ->
+        currentVisualizerType.takeIf { isPlaying } ?: VisualizerType.NONE
+    }
+        .flowOn(Dispatchers.IO)
+        .stateIn(
+            viewModelScope,
+            started = SharingStarted.WhileSubscribed(),
+            initialValue = VisualizerType.NONE
+        )
+
     fun togglePlayPause() {
         mediaController.value?.let {
             if (it.isPlaying) {
@@ -301,4 +335,12 @@
             it.setPlaybackSpeed(playbackSpeed.next().value)
         }
     }
+
+    fun nextVisualizerType() {
+        _currentVisualizerType.value = _currentVisualizerType.value.next()
+    }
+
+    fun disableVisualizer() {
+        _currentVisualizerType.value = VisualizerType.NONE
+    }
 }
diff --git a/app/src/main/res/drawable/ic_audiofx.xml b/app/src/main/res/drawable/ic_audiofx.xml
new file mode 100644
index 0000000..ad6c2d2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_audiofx.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#000000"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M5.872,18.795C5.351,19.316 4.499,19.321 4.052,18.735C3.129,17.527 2.491,16.12 2.192,14.618C1.806,12.678 2.004,10.667 2.761,8.84C3.518,7.013 4.8,5.451 6.444,4.352C8.089,3.253 10.022,2.667 12,2.667C13.978,2.667 15.911,3.253 17.556,4.352C19.2,5.451 20.482,7.013 21.239,8.84C21.996,10.667 22.194,12.678 21.808,14.618C21.509,16.12 20.871,17.527 19.948,18.735C19.501,19.321 18.649,19.316 18.128,18.795L15.771,16.438C16.517,15.692 17.025,14.742 17.231,13.708C17.437,12.673 17.331,11.601 16.927,10.626L16.913,10.593C17.597,8.56 17.702,8.27 17.892,7.749L17.892,7.749L17.955,7.575C17.984,7.5 18,7.419 18,7.334C18,6.965 17.702,6.667 17.333,6.667C17.247,6.667 17.164,6.683 17.089,6.713L16.919,6.775C16.397,6.965 16.109,7.07 14.075,7.754C13.421,7.478 12.716,7.334 12,7.334C10.945,7.334 9.914,7.646 9.037,8.232C8.16,8.819 7.476,9.651 7.073,10.626C6.669,11.601 6.563,12.673 6.769,13.708C6.975,14.742 7.483,15.692 8.229,16.438L5.872,18.795ZM12,16.334C14.025,16.334 15.667,14.692 15.667,12.667C15.667,10.642 14.025,9 12,9C9.975,9 8.333,10.642 8.333,12.667C8.333,14.692 9.975,16.334 12,16.334Z" />
+
+</vector>
diff --git a/app/src/main/res/layout/fragment_now_playing.xml b/app/src/main/res/layout/fragment_now_playing.xml
index 09488ab..3e51829 100644
--- a/app/src/main/res/layout/fragment_now_playing.xml
+++ b/app/src/main/res/layout/fragment_now_playing.xml
@@ -104,6 +104,17 @@
                         app:layout_constraintStart_toStartOf="parent"
                         app:layout_constraintTop_toTopOf="parent" />
 
+                    <SurfaceView
+                        android:id="@+id/visualizerSurfaceView"
+                        android:layout_width="0dp"
+                        android:layout_height="0dp"
+                        android:background="#55000000"
+                        android:visibility="visible"
+                        app:layout_constraintBottom_toBottomOf="@+id/albumArtImageView"
+                        app:layout_constraintEnd_toEndOf="parent"
+                        app:layout_constraintStart_toStartOf="parent"
+                        app:layout_constraintTop_toTopOf="parent" />
+
                 </androidx.constraintlayout.widget.ConstraintLayout>
 
             </com.google.android.material.card.MaterialCardView>
@@ -319,12 +330,12 @@
                     <com.google.android.material.button.MaterialButton
                         android:id="@+id/equalizerMaterialButton"
                         style="@style/Theme.Twelve.NowPlayingFragment.BottomBarButton"
-                        app:icon="@drawable/ic_graphic_eq" />
+                        app:icon="@drawable/ic_audiofx" />
 
                     <com.google.android.material.button.MaterialButton
-                        android:id="@+id/castMaterialButton"
+                        android:id="@+id/visualizerMaterialButton"
                         style="@style/Theme.Twelve.NowPlayingFragment.BottomBarButton"
-                        app:icon="@drawable/ic_cast" />
+                        app:icon="@drawable/ic_graphic_eq" />
 
                     <com.google.android.material.button.MaterialButton
                         android:id="@+id/moreMaterialButton"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c001c3b..fcd4667 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -16,6 +16,7 @@
 material = "1.12.0"
 media3 = "1.5.0-alpha01"
 navigation = "2.8.0"
+nier-visualizer = "v0.1.3"
 recyclerview = "1.3.2"
 room = "2.6.1"
 viewpager2 = "1.1.0"
@@ -41,6 +42,7 @@
 androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
 kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinx-coroutines" }
 material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+nier-visualizer = { group = "com.github.bogerchan", name = "Nier-Visualizer", version.ref = "nier-visualizer" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }