From 21fccf1f3131ad56bdce4030e00aa5a4bab6962e Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 22 May 2022 09:32:23 -0600 Subject: [PATCH] playback: add pre-amp customization Implement a UI frontend for customizing the ReplayGain pre-amp value. This finally completes Auxio's ReplayGain implementation. Not only that, it also shows how Auxio can use positive ReplayGain values, unlike other apps. As a side-note, this also fiddles with the dialog style somewhat. I got carried away. Resolves #114. --- .../{ReplayGainModels.kt => Models.kt} | 4 +- .../replaygain/ReplayGainAudioProcessor.kt | 24 ++-- .../playback/replaygain/ReplayGainDialog.kt | 90 +++++++++++++++ .../playback/system/NotificationComponent.kt | 1 - .../auxio/settings/SettingsListFragment.kt | 20 +++- .../oxycblt/auxio/settings/SettingsManager.kt | 17 ++- .../auxio/ui/accent/AccentCustomizeDialog.kt | 22 ++-- app/src/main/res/layout/dialog_excluded.xml | 7 +- app/src/main/res/layout/dialog_pre_amp.xml | 103 ++++++++++++++++++ app/src/main/res/layout/dialog_tabs.xml | 2 +- app/src/main/res/layout/item_excluded_dir.xml | 14 ++- app/src/main/res/layout/item_tab.xml | 3 +- app/src/main/res/values-ar-rIQ/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/dimens.xml | 2 - app/src/main/res/values/strings.xml | 12 +- app/src/main/res/values/styles_android.xml | 2 +- app/src/main/res/xml/prefs_main.xml | 8 ++ 22 files changed, 294 insertions(+), 49 deletions(-) rename app/src/main/java/org/oxycblt/auxio/playback/replaygain/{ReplayGainModels.kt => Models.kt} (90%) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainDialog.kt create mode 100644 app/src/main/res/layout/dialog_pre_amp.xml diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainModels.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/Models.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainModels.kt rename to app/src/main/java/org/oxycblt/auxio/playback/replaygain/Models.kt index 387d7cea4..87ffc95dd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainModels.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/Models.kt @@ -43,8 +43,10 @@ enum class ReplayGainMode { } } -/** Represents the ReplayGain pre-amp */ +/** Represents the ReplayGain pre-amp values. */ data class ReplayGainPreAmp( + /** The value to use when ReplayGain tags are present. */ val with: Float, + /** The value to use when ReplayGain tags are not present. */ val without: Float, ) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 8339530ab..c4780d81d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -59,8 +59,9 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() { // --- REPLAYGAIN PARSING --- /** - * Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is based off - * Vanilla Music's implementation. + * Updates the rough volume adjustment for [Metadata] with ReplayGain tags. This is + * tangentially based off Vanilla Music's implementation, but has diverged to a significant + * extent. */ fun applyReplayGain(metadata: Metadata?) { if (settingsManager.replayGainMode == ReplayGainMode.OFF) { @@ -102,10 +103,11 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() { gain.track } - // Apply the "With tags" adjustment + // Apply the adjustment specified when there is ReplayGain tags. resolvedGain + preAmp.with } else { - // No gain tags were present, just apply the adjustment without tags. + // No ReplayGain tags existed, or no tags were parsable, or there was no metadata + // in the first place. Return the gain to use when there is no ReplayGain value. logD("No ReplayGain tags present ") preAmp.without } @@ -157,7 +159,8 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() { found = true } - // Case 2: R128 ReplayGain, most commonly found on FLAC files. + // Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless + // encodings to increase precision in volume adjustments. // While technically there is the R128 base gain in Opus files, that is automatically // applied by the media framework [which ExoPlayer relies on]. The only reason we would // want to read it is to zero previous ReplayGain values for being invalid, however there @@ -215,8 +218,8 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() { } else { for (i in position until limit step 2) { // Ensure we clamp the values to the minimum and maximum values possible - // for the encoding. This prevents issues where samples amplified beyond - // 1 << 16 will end up becoming truncated during the conversion to a short, + // for the encoding. This prevents issues where samples amplified beyond + // 1 << 16 will end up becoming truncated during the conversion to a short, // resulting in popping. var sample = inputBuffer.getLeShort(i) sample = @@ -232,10 +235,17 @@ class ReplayGainAudioProcessor : BaseAudioProcessor() { buffer.flip() } + // Normally, ByteBuffer endianness is determined by object state, which is possibly + // the most java thing I have ever heard. Instead of mutating that state and accidentally + // breaking downstream parsers of audio data, we have our own methods to always parse a + // little-endian value. + + /** Always get a little-endian short value from a [ByteBuffer] */ private fun ByteBuffer.getLeShort(at: Int): Short { return get(at + 1).toInt().shl(8).or(get(at).toInt().and(0xFF)).toShort() } + /** Always place a little-endian short value into a [ByteBuffer]. */ private fun ByteBuffer.putLeShort(short: Short) { put(short.toByte()) put(short.toInt().shr(8).toByte()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainDialog.kt new file mode 100644 index 000000000..d7439e0bc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainDialog.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.playback.replaygain + +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import kotlin.math.abs +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogPreAmpBinding +import org.oxycblt.auxio.settings.SettingsManager +import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.textSafe + +/** + * The dialog for customizing the ReplayGain pre-amp values. + * @author OxygenCobalt + */ +class ReplayGainDialog : ViewBindingDialogFragment() { + private val settingsManager = SettingsManager.getInstance() + + override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.set_pre_amp) + .setPositiveButton(android.R.string.ok) { _, _ -> + val binding = requireBinding() + settingsManager.replayGainPreAmp = + ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) + } + .setNegativeButton(android.R.string.cancel, null) + } + + override fun onBindingCreated(binding: DialogPreAmpBinding, savedInstanceState: Bundle?) { + if (savedInstanceState == null) { + // First initialization, we need to supply the sliders with the values from + // settings. After this, the sliders save their own state, so we do not need to + // do any restore behavior. + val preAmp = settingsManager.replayGainPreAmp + binding.withTagsSlider.value = preAmp.with + binding.withoutTagsSlider.value = preAmp.without + } + + // The listener always fires when the Slider restores it's own state, *except* when + // it's at it's default value. Then it doesn't. Just initialize the ticker ourselves. + updateTicker(binding.withTagsTicker, binding.withTagsSlider.value) + binding.withTagsSlider.addOnChangeListener { _, value, _ -> + updateTicker(binding.withTagsTicker, value) + } + + updateTicker(binding.withoutTagsTicker, binding.withoutTagsSlider.value) + binding.withoutTagsSlider.addOnChangeListener { _, value, _ -> + updateTicker(binding.withoutTagsTicker, value) + } + } + + private fun updateTicker(ticker: TextView, valueDb: Float) { + // It is more clear to prepend a +/- before the pre-amp value to make it easier to + // gauge how much it may be increasing the volume, however android does not add + + // to positive float values when formatting them in a string. Instead, add it ourselves. + ticker.textSafe = + if (valueDb >= 0) { + getString(R.string.fmt_db_pos, valueDb) + } else { + getString(R.string.fmt_db_neg, abs(valueDb)) + } + } + + companion object { + const val TAG = BuildConfig.APPLICATION_ID + ".tag.PRE_AMP_CUSTOMIZE" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt index 568f0e472..4a5e19f46 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/NotificationComponent.kt @@ -35,7 +35,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.getSystemServiceSafe -import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastIntent import org.oxycblt.auxio.util.newMainIntent diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt index fb552911f..782af507a 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt @@ -33,6 +33,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.home.tabs.TabCustomizeDialog import org.oxycblt.auxio.music.excluded.ExcludedDialog import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.playback.replaygain.ReplayGainDialog +import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.settings.pref.IntListPreference import org.oxycblt.auxio.settings.pref.IntListPreferenceDialog import org.oxycblt.auxio.ui.accent.AccentCustomizeDialog @@ -88,6 +90,7 @@ class SettingsListFragment : PreferenceFragmentCompat() { // to adequately supply a "target fragment" (otherwise we will crash since the dialog // requires one), and then we need to actually show the dialog, making sure we use // the parent FragmentManager as again, it will crash if we don't. + // // Fragments were a mistake. val dialog = IntListPreferenceDialog.from(preference) dialog.setTargetFragment(this, 0) @@ -150,7 +153,22 @@ class SettingsListFragment : PreferenceFragmentCompat() { onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> Coil.imageLoader(requireContext()).apply { this.memoryCache?.clear() } - + true + } + } + SettingsManager.KEY_REPLAY_GAIN -> { + notifyDependencyChange(settingsManager.replayGainMode == ReplayGainMode.OFF) + onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, value -> + notifyDependencyChange( + ReplayGainMode.fromIntCode(value as Int) == ReplayGainMode.OFF) + true + } + } + SettingsManager.KEY_PRE_AMP -> { + onPreferenceClickListener = + Preference.OnPreferenceClickListener { + ReplayGainDialog().show(childFragmentManager, ReplayGainDialog.TAG) true } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt index b785017cf..11ed1ae4e 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt @@ -23,9 +23,9 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import androidx.preference.PreferenceManager import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp +import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.accent.Accent @@ -109,12 +109,11 @@ class SettingsManager private constructor(context: Context) : var replayGainPreAmp: ReplayGainPreAmp get() = ReplayGainPreAmp( - prefs.getFloat(KEY_REPLAY_GAIN_PRE_AMP_WITH, 0f), - prefs.getFloat(KEY_REPLAY_GAIN_PRE_AMP_WITHOUT, 0f)) + prefs.getFloat(KEY_PRE_AMP_WITH, 0f), prefs.getFloat(KEY_PRE_AMP_WITHOUT, 0f)) set(value) { prefs.edit { - putFloat(KEY_REPLAY_GAIN_PRE_AMP_WITH, value.with) - putFloat(KEY_REPLAY_GAIN_PRE_AMP_WITHOUT, value.without) + putFloat(KEY_PRE_AMP_WITH, value.with) + putFloat(KEY_PRE_AMP_WITHOUT, value.without) apply() } } @@ -258,7 +257,7 @@ class SettingsManager private constructor(context: Context) : KEY_USE_ALT_NOTIFICATION_ACTION -> callbacks.forEach { it.onNotifSettingsChanged() } KEY_SHOW_COVERS, KEY_QUALITY_COVERS -> callbacks.forEach { it.onCoverSettingsChanged() } KEY_LIB_TABS -> callbacks.forEach { it.onLibraryChanged() } - KEY_REPLAY_GAIN, KEY_REPLAY_GAIN_PRE_AMP_WITH, KEY_REPLAY_GAIN_PRE_AMP_WITHOUT -> + KEY_REPLAY_GAIN, KEY_PRE_AMP_WITH, KEY_PRE_AMP_WITHOUT -> callbacks.forEach { it.onReplayGainSettingsChanged() } } } @@ -290,9 +289,9 @@ class SettingsManager private constructor(context: Context) : const val KEY_HEADSET_AUTOPLAY = "auxio_headset_autoplay" const val KEY_REPLAY_GAIN = "auxio_replay_gain" - const val KEY_REPLAY_GAIN_PRE_AMP = "auxio_pre_amp" - const val KEY_REPLAY_GAIN_PRE_AMP_WITH = "auxio_pre_amp_with" - const val KEY_REPLAY_GAIN_PRE_AMP_WITHOUT = "auxio_pre_amp_without" + const val KEY_PRE_AMP = "auxio_pre_amp" + const val KEY_PRE_AMP_WITH = "auxio_pre_amp_with" + const val KEY_PRE_AMP_WITHOUT = "auxio_pre_amp_without" const val KEY_SONG_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" const val KEY_KEEP_SHUFFLE = "KEY_KEEP_SHUFFLE" diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index db945a60b..fe93acc81 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -40,20 +40,20 @@ class AccentCustomizeDialog : override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { - builder.setTitle(R.string.set_accent) + builder + .setTitle(R.string.set_accent) + .setPositiveButton(android.R.string.ok) { _, _ -> + if (accentAdapter.selectedAccent != settingsManager.accent) { + logD("Applying new accent") + settingsManager.accent = unlikelyToBeNull(accentAdapter.selectedAccent) + requireActivity().recreate() + } - builder.setPositiveButton(android.R.string.ok) { _, _ -> - if (accentAdapter.selectedAccent != settingsManager.accent) { - logD("Applying new accent") - settingsManager.accent = unlikelyToBeNull(accentAdapter.selectedAccent) - requireActivity().recreate() + dismiss() } - dismiss() - } - - // Negative button just dismisses, no need for a listener. - builder.setNegativeButton(android.R.string.cancel, null) + // Negative button just dismisses, no need for a listener. + .setNegativeButton(android.R.string.cancel, null) } override fun onBindingCreated(binding: DialogAccentBinding, savedInstanceState: Bundle?) { diff --git a/app/src/main/res/layout/dialog_excluded.xml b/app/src/main/res/layout/dialog_excluded.xml index 9067fda64..f4bc48212 100644 --- a/app/src/main/res/layout/dialog_excluded.xml +++ b/app/src/main/res/layout/dialog_excluded.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:paddingTop="@dimen/spacing_small"> + android:paddingTop="@dimen/spacing_medium"> + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_tabs.xml b/app/src/main/res/layout/dialog_tabs.xml index fc292412b..4377b262d 100644 --- a/app/src/main/res/layout/dialog_tabs.xml +++ b/app/src/main/res/layout/dialog_tabs.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" - android:paddingTop="@dimen/spacing_small" + android:paddingTop="@dimen/spacing_medium" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintBottom_toTopOf="@+id/accent_cancel" app:layout_constraintTop_toBottomOf="@+id/accent_header" diff --git a/app/src/main/res/layout/item_excluded_dir.xml b/app/src/main/res/layout/item_excluded_dir.xml index f0fe034bf..14bca0d07 100644 --- a/app/src/main/res/layout/item_excluded_dir.xml +++ b/app/src/main/res/layout/item_excluded_dir.xml @@ -3,11 +3,8 @@ تفضيل نشاط الخلط صوتيات - صخب الصوت (تجريبي) + صخب الصوت اطفاء تفضيل المقطع تفضيل الالبوم diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 36464d1bc..0c375acfe 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -73,7 +73,7 @@ Audio Kopfhörer automatische Wiedergabe Beginne die Wiedergabe immer, wenn Kopfhörer verbunden sind (funktioniert nicht auf allen Geräten) - ReplayGain (Experimentell) + ReplayGain Aus Titel bevorzugen Album bevorzugen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bd821b884..b9e43f475 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -81,7 +81,7 @@ Preferir acción de mezcla Sonido - ReplayGain (Experimental) + ReplayGain Desactivado Por pista Por álbum diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a92f09b70..2ace338c8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -83,7 +83,7 @@ Audio Autoplay cuffie Comincia la riproduzione ogni volta che le cuffie sono inserite (potrebbe non funzionare su tutti i dispositivi) - Replay Gain (Sperimentale) + Replay Gain Preferisci traccia Preferisci disco Dinamico diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 11c8b2362..9cef75174 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -83,7 +83,7 @@ Звук Воспроизводить при подключении Всегда начинать воспроизведение при подключении наушников (может работать не на всех устройствах) - ReplayGain (экспериментально) + ReplayGain Выкл. По треку По альбому diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a3f4c9a69..690398bff 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -82,7 +82,7 @@ 音频 自动播放 连接至耳机时总是自动播放(并非在所有设备上都有用) - 回放增益(实验性) + 回放增益 偏好曲目 偏好专辑 动态 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index baa70df53..0db474ae6 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -42,8 +42,6 @@ 6dp 16dp - 6dp - 10dp 88dp 128dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1040d8501..8849ccffb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,10 +87,15 @@ Audio Headset autoplay Always start playing when a headset is connected (may not work on all devices) - ReplayGain (Experimental) + ReplayGain Prefer track Prefer album Dynamic + ReplayGain pre-amp + The pre-amp is applied to the existing adjustment during playback + Adjustment with tags + Adjustment without tags + Warning: Changing the pre-amp to a high positive value may result in peaking on some audio tracks. Behavior When a song is selected @@ -109,6 +114,7 @@ Excluded folders The content of excluded folders is hidden from your library + Off @@ -173,6 +179,10 @@ Disc %d + + +%.1f dB + -%.1f dB + Songs loaded: %d Albums loaded: %d Artists loaded: %d diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml index d592f2350..023ca1d36 100644 --- a/app/src/main/res/values/styles_android.xml +++ b/app/src/main/res/values/styles_android.xml @@ -6,7 +6,7 @@ A dialog theme that doesn't suck. This is the only non-Material3 usage in the entire project since the Material3 dialogs [and especially the button panel] are unusable. --> -