diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt new file mode 100644 index 00000000..9d8489e3 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt @@ -0,0 +1,47 @@ +package com.cappielloantonio.tempo.service + +import android.media.audiofx.Equalizer + +class EqualizerManager { + + private var equalizer: Equalizer? = null + + fun attachToSession(audioSessionId: Int): Boolean { + release() + if (audioSessionId != 0 && audioSessionId != -1) { + try { + equalizer = Equalizer(0, audioSessionId).apply { + enabled = true + } + return true + } catch (e: Exception) { + // Some devices may not support Equalizer or audio session may be invalid + equalizer = null + } + } + return false + } + + fun setBandLevel(band: Short, level: Short) { + equalizer?.setBandLevel(band, level) + } + + fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0 + + fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange + + fun getCenterFreq(band: Short): Int? = + equalizer?.getCenterFreq(band)?.div(1000) + + fun getBandLevel(band: Short): Short? = + equalizer?.getBandLevel(band) + + fun setEnabled(enabled: Boolean) { + equalizer?.enabled = enabled + } + + fun release() { + equalizer?.release() + equalizer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt new file mode 100644 index 00000000..16c58457 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt @@ -0,0 +1,225 @@ +package com.cappielloantonio.tempo.ui.fragment + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.service.EqualizerManager +import com.cappielloantonio.tempo.service.MediaService +import com.cappielloantonio.tempo.util.Preferences + +class EqualizerFragment : Fragment() { + + private var equalizerManager: EqualizerManager? = null + private lateinit var eqBandsContainer: LinearLayout + private lateinit var eqSwitch: Switch + private lateinit var resetButton: Button + private lateinit var safeSpace: Space + private val bandSeekBars = mutableListOf() + + private val connection = object : ServiceConnection { + @OptIn(UnstableApi::class) + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as MediaService.LocalBinder + equalizerManager = binder.getEqualizerManager() + initUI() + restoreEqualizerPreferences() + } + + override fun onServiceDisconnected(arg0: ComponentName) { + equalizerManager = null + } + } + + @OptIn(UnstableApi::class) + override fun onStart() { + super.onStart() + Intent(requireContext(), MediaService::class.java).also { intent -> + intent.action = MediaService.ACTION_BIND_EQUALIZER + requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + requireActivity().unbindService(connection) + equalizerManager = null + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_equalizer, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + eqBandsContainer = view.findViewById(R.id.eq_bands_container) + eqSwitch = view.findViewById(R.id.equalizer_switch) + resetButton = view.findViewById(R.id.equalizer_reset_button) + safeSpace = view.findViewById(R.id.equalizer_bottom_space) + } + + private fun initUI() { + val manager = equalizerManager + val notSupportedView = view?.findViewById(R.id.equalizer_not_supported_container) + val switchRow = view?.findViewById(R.id.equalizer_switch_row) + + if (manager == null || manager.getNumberOfBands().toInt() == 0) { + switchRow?.visibility = View.GONE + resetButton.visibility = View.GONE + eqBandsContainer.visibility = View.GONE + safeSpace.visibility = View.GONE + notSupportedView?.visibility = View.VISIBLE + return + } + + notSupportedView?.visibility = View.GONE + switchRow?.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + eqBandsContainer.visibility = View.VISIBLE + safeSpace.visibility = View.VISIBLE + + eqSwitch.setOnCheckedChangeListener(null) + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + updateUiEnabledState(eqSwitch.isChecked) + eqSwitch.setOnCheckedChangeListener { _, isChecked -> + manager.setEnabled(isChecked) + Preferences.setEqualizerEnabled(isChecked) + updateUiEnabledState(isChecked) + } + + createBandSliders() + + resetButton.setOnClickListener { + resetEqualizer() + saveBandLevelsToPreferences() + } + } + + private fun updateUiEnabledState(isEnabled: Boolean) { + resetButton.isEnabled = isEnabled + bandSeekBars.forEach { it.isEnabled = isEnabled } + } + + private fun createBandSliders() { + val manager = equalizerManager ?: return + eqBandsContainer.removeAllViews() + bandSeekBars.clear() + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val maxLevel = bandLevelRange[1].toInt() + + val savedLevels = Preferences.getEqualizerBandLevels(bands) + for (i in 0 until bands) { + val band = i.toShort() + val freq = manager.getCenterFreq(band) ?: 0 + + val row = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = 24 + bottomMargin = 24 + } + setPadding(0, 8, 0, 8) + } + + val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = if (freq >= 1000) { + if (freq % 1000 == 0) { + "${freq / 1000} kHz" + } else { + String.format("%.1f kHz", freq / 1000f) + } + } else { + "$freq Hz" + } + width = 120 + } + row.addView(freqLabel) + + val initialLevel = savedLevels.getOrNull(i) ?: (manager.getBandLevel(band)?.toInt() ?: 0) + val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = "${(initialLevel.toInt() / 100)} dB" + setPadding(12, 0, 0, 0) + width = 120 + gravity = Gravity.END + } + + val seekBar = SeekBar(requireContext()).apply { + max = maxLevel - minLevel + progress = initialLevel.toInt() - minLevel + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val thisLevel = (progress + minLevel).toShort() + if (fromUser) { + manager.setBandLevel(band, thisLevel) + saveBandLevelsToPreferences() + } + dbLabel.text = "${((progress + minLevel) / 100)} dB" + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + bandSeekBars.add(seekBar) + row.addView(seekBar) + row.addView(dbLabel) + eqBandsContainer.addView(row) + } + } + + private fun resetEqualizer() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val midLevel = 0 + for (i in 0 until bands) { + manager.setBandLevel(i.toShort(), midLevel.toShort()) + bandSeekBars.getOrNull(i)?.progress = midLevel - minLevel + } + Preferences.setEqualizerBandLevels(ShortArray(bands.toInt())) + } + + private fun saveBandLevelsToPreferences() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 } + Preferences.setEqualizerBandLevels(levels) + } + + private fun restoreEqualizerPreferences() { + val manager = equalizerManager ?: return + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + updateUiEnabledState(eqSwitch.isChecked) + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val savedLevels = Preferences.getEqualizerBandLevels(bands) + if (savedLevels != null) { + for (i in 0 until bands) { + manager.setBandLevel(i.toShort(), savedLevels[i]) + bandSeekBars.getOrNull(i)?.progress = savedLevels[i] - minLevel + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index 99f3c4ca..84871c78 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -24,6 +24,8 @@ import androidx.media3.common.util.RepeatModeUtil; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; import androidx.viewpager2.widget.ViewPager2; @@ -68,6 +70,7 @@ public class PlayerControllerFragment extends Fragment { private ImageButton playerOpenQueueButton; private ImageButton playerTrackInfo; private LinearLayout ratingContainer; + private ImageButton equalizerButton; private MainActivity activity; private PlayerBottomSheetViewModel playerBottomSheetViewModel; @@ -89,6 +92,7 @@ public class PlayerControllerFragment extends Fragment { initMediaListenable(); initMediaLabelButton(); initArtistLabelButton(); + initEqualizerButton(); return view; } @@ -126,6 +130,7 @@ public class PlayerControllerFragment extends Fragment { playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track); songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); + equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); checkAndSetRatingContainerVisibility(); } @@ -426,6 +431,18 @@ public class PlayerControllerFragment extends Fragment { }); } + private void initEqualizerButton() { + equalizerButton.setOnClickListener(v -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + navController.navigate(R.id.equalizerFragment, null, navOptions); + if (activity != null) activity.collapseBottomSheetDelayed(); + }); + } + public void goToControllerPage() { playerMediaCoverViewPager.setCurrentItem(0, false); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index e8ddf2b2..09a50c86 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -18,6 +18,9 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.fragment.NavHostFragment; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; @@ -86,7 +89,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { public void onResume() { super.onResume(); - checkEqualizer(); + checkSystemEqualizer(); checkCacheStorage(); checkStorage(); @@ -102,6 +105,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionChangeDownloadStorage(); actionDeleteDownloadStorage(); actionKeepScreenOn(); + actionAppEqualizer(); } @Override @@ -124,8 +128,8 @@ public class SettingsFragment extends PreferenceFragmentCompat { } } - private void checkEqualizer() { - Preference equalizer = findPreference("equalizer"); + private void checkSystemEqualizer() { + Preference equalizer = findPreference("system_equalizer"); if (equalizer == null) return; @@ -353,4 +357,21 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); } + + private void actionAppEqualizer() { + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + activity.setBottomNavigationBarVisibility(true); + activity.setBottomSheetVisibility(true); + navController.navigate(R.id.equalizerFragment, null, navOptions); + return true; + }); + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 8c77ab13..92cb30cd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -69,7 +69,8 @@ object Preferences { private const val NEXT_UPDATE_CHECK = "next_update_check" private const val CONTINUOUS_PLAY = "continuous_play" private const val LAST_INSTANT_MIX = "last_instant_mix" - + private const val EQUALIZER_ENABLED = "equalizer_enabled" + private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" @JvmStatic fun getServer(): String? { @@ -538,4 +539,31 @@ object Preferences { LAST_INSTANT_MIX, 0 ) + 5000 < System.currentTimeMillis() } + + @JvmStatic + fun setEqualizerEnabled(enabled: Boolean) { + App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply() + } + + @JvmStatic + fun isEqualizerEnabled(): Boolean { + return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false) + } + + @JvmStatic + fun setEqualizerBandLevels(bandLevels: ShortArray) { + val asString = bandLevels.joinToString(",") + App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply() + } + + @JvmStatic + fun getEqualizerBandLevels(bandCount: Short): ShortArray { + val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null) + if (str.isNullOrBlank()) { + return ShortArray(bandCount.toInt()) + } + val parts = str.split(",") + if (parts.size < bandCount) return ShortArray(bandCount.toInt()) + return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eq.xml b/app/src/main/res/drawable/ic_eq.xml new file mode 100644 index 00000000..5f3a8b46 --- /dev/null +++ b/app/src/main/res/drawable/ic_eq.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_eq_not_supported.xml b/app/src/main/res/drawable/ui_eq_not_supported.xml new file mode 100644 index 00000000..fc8a364b --- /dev/null +++ b/app/src/main/res/drawable/ui_eq_not_supported.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml index 7ad0250e..cb3ae9c6 100644 --- a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml @@ -382,11 +382,23 @@ android:layout_height="wrap_content" android:padding="16dp" android:background="?attr/selectableItemBackgroundBorderless" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:srcCompat="@drawable/ic_queue" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_equalizer.xml b/app/src/main/res/layout/fragment_equalizer.xml new file mode 100644 index 00000000..dcf2191f --- /dev/null +++ b/app/src/main/res/layout/fragment_equalizer.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + +