From 1007d2f2d59a01ae94f838666a1eb4756a703db3 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Apr 2023 01:34:07 -0400 Subject: [PATCH 1/5] Replace format parameter slider with chips Instead of using a slider with a min, max, and step size, the format parameter is configured using chips. For each format, there are three presets and there's an additional option for the user to set a specific compression level or bitrate. The user's existing settings will be preserved. Fixes: #295 Signed-off-by: Andrew Gunnerson --- .../bcr/FilenameTemplateDialogFragment.kt | 9 +- .../chiller3/bcr/FormatParamDialogFragment.kt | 104 ++++++++++++++ .../bcr/OutputFormatBottomSheetFragment.kt | 134 ++++++++++++------ .../java/com/chiller3/bcr/format/AacFormat.kt | 9 +- .../com/chiller3/bcr/format/FlacContainer.kt | 1 - .../com/chiller3/bcr/format/FlacFormat.kt | 4 +- .../java/com/chiller3/bcr/format/Format.kt | 8 +- .../chiller3/bcr/format/FormatParamInfo.kt | 37 ++--- .../com/chiller3/bcr/format/OpusFormat.kt | 14 +- .../com/chiller3/bcr/format/WaveContainer.kt | 1 - ...ame_template.xml => dialog_text_input.xml} | 3 +- .../res/layout/output_format_bottom_sheet.xml | 9 +- app/src/main/res/values/strings.xml | 5 + 13 files changed, 246 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt rename app/src/main/res/layout/{dialog_filename_template.xml => dialog_text_input.xml} (92%) diff --git a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt index 9381897f9..242ea5fc3 100644 --- a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt @@ -7,6 +7,7 @@ import android.graphics.Typeface import android.net.Uri import android.os.Bundle import android.text.Annotation +import android.text.InputType import android.text.Spannable import android.text.SpannableStringBuilder import android.text.SpannedString @@ -20,7 +21,7 @@ import androidx.core.os.bundleOf import androidx.core.widget.addTextChangedListener import androidx.fragment.app.DialogFragment import androidx.fragment.app.setFragmentResult -import com.chiller3.bcr.databinding.DialogFilenameTemplateBinding +import com.chiller3.bcr.databinding.DialogTextInputBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder class FilenameTemplateDialogFragment : DialogFragment() { @@ -32,7 +33,7 @@ class FilenameTemplateDialogFragment : DialogFragment() { private lateinit var prefs: Preferences private lateinit var highlighter: TemplateSyntaxHighlighter - private lateinit var binding: DialogFilenameTemplateBinding + private lateinit var binding: DialogTextInputBinding private var template: Template? = null private var success: Boolean = false @@ -42,11 +43,13 @@ class FilenameTemplateDialogFragment : DialogFragment() { highlighter = TemplateSyntaxHighlighter(context) template = prefs.filenameTemplate ?: Preferences.DEFAULT_FILENAME_TEMPLATE - binding = DialogFilenameTemplateBinding.inflate(layoutInflater) + binding = DialogTextInputBinding.inflate(layoutInflater) binding.message.movementMethod = LinkMovementMethod.getInstance() binding.message.text = buildMessage() + binding.text.inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS // Make this non-multiline text box look like one binding.text.setHorizontallyScrolling(false) binding.text.maxLines = Int.MAX_VALUE diff --git a/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt new file mode 100644 index 000000000..6e6c43647 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt @@ -0,0 +1,104 @@ +package com.chiller3.bcr + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputType +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import com.chiller3.bcr.databinding.DialogTextInputBinding +import com.chiller3.bcr.format.Format +import com.chiller3.bcr.format.RangedParamInfo +import com.chiller3.bcr.format.RangedParamType +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class FormatParamDialogFragment : DialogFragment() { + companion object { + val TAG: String = FormatParamDialogFragment::class.java.simpleName + + const val RESULT_SUCCESS = "success" + } + + private lateinit var prefs: Preferences + private lateinit var format: Format + private lateinit var binding: DialogTextInputBinding + private var value: UInt? = null + private var success: Boolean = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + prefs = Preferences(context) + format = Format.fromPreferences(prefs).first + + val paramInfo = format.paramInfo + if (paramInfo !is RangedParamInfo) { + throw IllegalStateException("Selected format is not configurable") + } + + val multiplier = when (paramInfo.type) { + RangedParamType.CompressionLevel -> 1U + RangedParamType.Bitrate -> 1000U + } + + binding = DialogTextInputBinding.inflate(layoutInflater) + + binding.message.text = getString( + R.string.format_param_dialog_message, + paramInfo.format(paramInfo.range.first), + paramInfo.format(paramInfo.range.last), + ) + + if (paramInfo.type == RangedParamType.Bitrate) { + binding.textLayout.suffixText = "kbps" + binding.text.textAlignment = View.TEXT_ALIGNMENT_TEXT_END + } + + binding.text.inputType = InputType.TYPE_CLASS_NUMBER + binding.text.addTextChangedListener { + value = if (it!!.isNotEmpty()) { + val newValue = it.toString().toUInt().times(multiplier.toULong()) + if (newValue in paramInfo.range) { + newValue.toUInt() + } else { + null + } + } else { + null + } + + refreshOkButtonEnabledState() + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.format_param_dialog_title) + .setView(binding.root) + .setPositiveButton(R.string.dialog_action_ok) { _, _ -> + prefs.setFormatParam(format, value!!) + success = true + } + .setNegativeButton(R.string.dialog_action_cancel, null) + .create() + .apply { + setCanceledOnTouchOutside(false) + } + } + + override fun onStart() { + super.onStart() + refreshOkButtonEnabledState() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + + setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) + } + + private fun refreshOkButtonEnabledState() { + (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = value != null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt index 2f3b21633..9d5b26f83 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr import android.os.Bundle @@ -5,15 +7,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible +import androidx.fragment.app.setFragmentResultListener import com.chiller3.bcr.databinding.BottomSheetChipBinding import com.chiller3.bcr.databinding.OutputFormatBottomSheetBinding import com.chiller3.bcr.format.* import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.chip.ChipGroup -import com.google.android.material.slider.Slider class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), - ChipGroup.OnCheckedStateChangeListener, Slider.OnChangeListener, View.OnClickListener { + ChipGroup.OnCheckedStateChangeListener, View.OnClickListener { private var _binding: OutputFormatBottomSheetBinding? = null private val binding get() = _binding!! @@ -22,7 +24,9 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), private val chipIdToFormat = HashMap() private val formatToChipId = HashMap() - private lateinit var formatParamInfo: FormatParamInfo + + private val chipIdToParam = HashMap() + private val paramToChipId = HashMap() private val chipIdToSampleRate = HashMap() private val sampleRateToChipId = HashMap() @@ -36,11 +40,6 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), prefs = Preferences(requireContext()) - binding.paramSlider.setLabelFormatter { - formatParamInfo.format(it.toUInt()) - } - binding.paramSlider.addOnChangeListener(this) - binding.reset.setOnClickListener(this) for (format in Format.all) { @@ -53,13 +52,20 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), binding.nameGroup.setOnCheckedStateChangeListener(this) + binding.paramGroup.setOnCheckedStateChangeListener(this) + for (sampleRate in SampleRate.all) { addSampleRateChip(inflater, sampleRate) } binding.sampleRateGroup.setOnCheckedStateChangeListener(this) + setFragmentResultListener(FormatParamDialogFragment.TAG) { _, _ -> + refreshParam() + } + refreshFormat() + refreshParam() refreshSampleRate() return binding.root @@ -70,28 +76,43 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), _binding = null } - private fun addFormatChip(inflater: LayoutInflater, format: Format) { - val chipBinding = BottomSheetChipBinding.inflate( - inflater, binding.nameGroup, false) + private fun addChip(inflater: LayoutInflater, parent: ViewGroup): BottomSheetChipBinding { + val chipBinding = BottomSheetChipBinding.inflate(inflater, parent, false) val id = View.generateViewId() chipBinding.root.id = id - chipBinding.root.text = format.name chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE - binding.nameGroup.addView(chipBinding.root) - chipIdToFormat[id] = format - formatToChipId[format] = id + parent.addView(chipBinding.root) + return chipBinding + } + + private fun addFormatChip(inflater: LayoutInflater, format: Format) { + val chipBinding = addChip(inflater, binding.nameGroup) + chipBinding.root.text = format.name + chipIdToFormat[chipBinding.root.id] = format + formatToChipId[format] = chipBinding.root.id + } + + private fun addParamChip(inflater: LayoutInflater, paramInfo: FormatParamInfo, value: UInt?, + canClose: Boolean) { + val chipBinding = addChip(inflater, binding.paramGroup) + if (canClose) { + chipBinding.root.isCloseIconVisible = true + chipBinding.root.setOnCloseIconClickListener(::onChipClosed) + } + if (value != null) { + chipBinding.root.text = paramInfo.format(value) + } else { + chipBinding.root.setText(R.string.output_format_bottom_sheet_custom_param) + } + chipIdToParam[chipBinding.root.id] = value + paramToChipId[value] = chipBinding.root.id } private fun addSampleRateChip(inflater: LayoutInflater, sampleRate: SampleRate) { - val chipBinding = BottomSheetChipBinding.inflate( - inflater, binding.sampleRateGroup, false) - val id = View.generateViewId() - chipBinding.root.id = id + val chipBinding = addChip(inflater, binding.sampleRateGroup) chipBinding.root.text = sampleRate.toString() - chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE - binding.sampleRateGroup.addView(chipBinding.root) - chipIdToSampleRate[id] = sampleRate - sampleRateToChipId[sampleRate] = id + chipIdToSampleRate[chipBinding.root.id] = sampleRate + sampleRateToChipId[sampleRate] = chipBinding.root.id } /** @@ -104,59 +125,78 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), binding.nameGroup.check(formatToChipId[format]!!) } - private fun refreshSampleRate() { - val sampleRate = SampleRate.fromPreferences(prefs) - binding.sampleRateGroup.check(sampleRateToChipId[sampleRate]!!) - } - - /** - * Update parameter title and slider to match format parameter specifications. - */ private fun refreshParam() { val (format, param) = Format.fromPreferences(prefs) - formatParamInfo = format.paramInfo + val selectedParam = param ?: format.paramInfo.default + + chipIdToParam.clear() + paramToChipId.clear() + binding.paramGroup.removeAllViews() when (val info = format.paramInfo) { is RangedParamInfo -> { - binding.paramGroup.isVisible = true + binding.paramLayout.isVisible = true binding.paramTitle.setText(when (info.type) { RangedParamType.CompressionLevel -> R.string.output_format_bottom_sheet_compression_level RangedParamType.Bitrate -> R.string.output_format_bottom_sheet_bitrate }) - binding.paramSlider.valueFrom = info.range.first.toFloat() - binding.paramSlider.valueTo = info.range.last.toFloat() - binding.paramSlider.stepSize = info.stepSize.toFloat() - binding.paramSlider.value = (param ?: info.default).toFloat() + for (preset in format.paramInfo.presets) { + addParamChip(layoutInflater, format.paramInfo, preset, false) + } + + if (selectedParam !in format.paramInfo.presets) { + // TODO: Cancellable + addParamChip(layoutInflater, format.paramInfo, selectedParam, true) + } else { + // TODO: New custom + addParamChip(layoutInflater, format.paramInfo, null, false) + } + + binding.paramGroup.check(paramToChipId[selectedParam]!!) } NoParamInfo -> { - binding.paramGroup.isVisible = false + binding.paramLayout.isVisible = false } } } + private fun refreshSampleRate() { + val sampleRate = SampleRate.fromPreferences(prefs) + binding.sampleRateGroup.check(sampleRateToChipId[sampleRate]!!) + } + + private fun onChipClosed(chip: View) { + if (chip.id in chipIdToParam) { + val format = chipIdToFormat[binding.nameGroup.checkedChipId]!! + prefs.setFormatParam(format, null) + refreshParam() + } + } + override fun onCheckedChanged(group: ChipGroup, checkedIds: MutableList) { when (group) { binding.nameGroup -> { prefs.format = chipIdToFormat[checkedIds.first()]!! refreshParam() } + binding.paramGroup -> { + val format = chipIdToFormat[binding.nameGroup.checkedChipId]!! + val param = chipIdToParam[checkedIds.first()] + if (param != null) { + prefs.setFormatParam(format, param) + } else { + FormatParamDialogFragment().show( + parentFragmentManager.beginTransaction(), FormatParamDialogFragment.TAG) + } + } binding.sampleRateGroup -> { prefs.sampleRate = chipIdToSampleRate[checkedIds.first()] } } } - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - when (slider) { - binding.paramSlider -> { - val format = chipIdToFormat[binding.nameGroup.checkedChipId]!! - prefs.setFormatParam(format, value.toUInt()) - } - } - } - override fun onClick(v: View?) { when (v) { binding.reset -> { diff --git a/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt b/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt index 187e80cb6..1336d854a 100644 --- a/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt +++ b/app/src/main/java/com/chiller3/bcr/format/AacFormat.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr.format import android.media.MediaCodecInfo @@ -14,8 +16,13 @@ object AacFormat : Format() { // with AAC-LC: 2 * 64kbps/channel. // https://trac.ffmpeg.org/wiki/Encode/AAC 24_000u..128_000u, - 4_000u, 64_000u, + uintArrayOf( + 24_000u, + // "As a rule of thumb, for audible transparency, use 64 kBit/s for each channel" + 64_000u, + 128_000u, + ), ) // https://datatracker.ietf.org/doc/html/rfc6381#section-3.1 override val mimeTypeContainer: String = "audio/mp4" diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt index 03d43e273..64c8f540f 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacContainer.kt @@ -1,4 +1,3 @@ -@file:Suppress("OPT_IN_IS_NOT_ENABLED") @file:OptIn(ExperimentalUnsignedTypes::class) package com.chiller3.bcr.format diff --git a/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt b/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt index 52ce86cb5..1bf3a1a5d 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FlacFormat.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr.format import android.media.MediaFormat @@ -8,9 +10,9 @@ object FlacFormat : Format() { override val paramInfo: FormatParamInfo = RangedParamInfo( RangedParamType.CompressionLevel, 0u..8u, - 1u, // Devices are fast enough nowadays to use the highest compression for realtime recording 8u, + uintArrayOf(0u, 5u, 8u), ) override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC override val mimeTypeAudio: String = MediaFormat.MIMETYPE_AUDIO_FLAC diff --git a/app/src/main/java/com/chiller3/bcr/format/Format.kt b/app/src/main/java/com/chiller3/bcr/format/Format.kt index b4b38c6c7..b0f350da3 100644 --- a/app/src/main/java/com/chiller3/bcr/format/Format.kt +++ b/app/src/main/java/com/chiller3/bcr/format/Format.kt @@ -101,14 +101,14 @@ sealed class Format { * The parameter, if set, is clamped to the format's allowed parameter range. */ fun fromPreferences(prefs: Preferences): Pair { - // Use the saved format if it is valid and supported on the current device. Otherwise, fall - // back to the default. + // Use the saved format if it is valid and supported on the current device. Otherwise, + // fall back to the default. val format = prefs.format ?.let { if (it.supported) { it } else { null } } ?: default - // Convert the saved value to the nearest valid value (eg. in case bitrate range or step - // size in changed in a future version) + // Convert the saved value to the nearest valid value (eg. in case the bitrate range is + // changed in a future version) val param = prefs.getFormatParam(format)?.let { format.paramInfo.toNearest(it) } diff --git a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt b/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt index f2f24476d..d4c373aaa 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt @@ -1,6 +1,12 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr.format -sealed class FormatParamInfo(val default: UInt) { +sealed class FormatParamInfo( + val default: UInt, + /** Handful of handpicked parameter choices to show in the UI as presets. */ + val presets: UIntArray, +) { /** * Ensure that [param] is valid. * @@ -27,9 +33,9 @@ enum class RangedParamType { class RangedParamInfo( val type: RangedParamType, val range: UIntRange, - val stepSize: UInt, default: UInt, -) : FormatParamInfo(default) { + presets: UIntArray, +) : FormatParamInfo(default, presets) { override fun validate(param: UInt) { if (param !in range) { throw IllegalArgumentException("Parameter ${format(param)} is not in the range: " + @@ -37,27 +43,8 @@ class RangedParamInfo( } } - /** Clamp [param] to [range] and snap to nearest [stepSize]. */ - override fun toNearest(param: UInt): UInt { - val offset = param.coerceIn(range) - range.first - val roundedDown = (offset / stepSize) * stepSize - - return range.first + if (roundedDown == offset) { - // Already on step size boundary - offset - } else if (roundedDown >= UInt.MAX_VALUE - stepSize) { - // Rounded up would overflow - roundedDown - } else { - // Round to closer boundary, preferring the upper boundary if it's in the middle - val roundedUp = roundedDown + stepSize - if (roundedUp - offset <= offset - roundedDown) { - roundedUp - } else { - roundedDown - } - } - } + /** Clamp [param] to [range]. */ + override fun toNearest(param: UInt): UInt = param.coerceIn(range) override fun format(param: UInt): String = when (type) { @@ -66,7 +53,7 @@ class RangedParamInfo( } } -object NoParamInfo : FormatParamInfo(0u) { +object NoParamInfo : FormatParamInfo(0u, uintArrayOf()) { override fun validate(param: UInt) { // Always valid } diff --git a/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt b/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt index 306fc3563..a8c3c6b0e 100644 --- a/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt +++ b/app/src/main/java/com/chiller3/bcr/format/OpusFormat.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr.format import android.media.MediaFormat @@ -11,10 +13,16 @@ object OpusFormat : Format() { override val paramInfo: FormatParamInfo = RangedParamInfo( RangedParamType.Bitrate, 6_000u..510_000u, - 21_000u, - // "Essentially transparent mono or stereo speech, reasonable music" - // https://wiki.hydrogenaud.io/index.php?title=Opus 48_000u, + // https://wiki.hydrogenaud.io/index.php?title=Opus + uintArrayOf( + // "Medium bandwidth, better than telephone quality" + 12_000u, + // "Near transparent speech" + 24_000u, + // "Essentially transparent mono or stereo speech, reasonable music" + 48_000u, + ), ) // https://datatracker.ietf.org/doc/html/rfc7845#section-9 override val mimeTypeContainer: String = "audio/ogg" diff --git a/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt b/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt index 77ffd52f4..26e1435ac 100644 --- a/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt +++ b/app/src/main/java/com/chiller3/bcr/format/WaveContainer.kt @@ -1,4 +1,3 @@ -@file:Suppress("OPT_IN_IS_NOT_ENABLED") @file:OptIn(ExperimentalUnsignedTypes::class) package com.chiller3.bcr.format diff --git a/app/src/main/res/layout/dialog_filename_template.xml b/app/src/main/res/layout/dialog_text_input.xml similarity index 92% rename from app/src/main/res/layout/dialog_filename_template.xml rename to app/src/main/res/layout/dialog_text_input.xml index ce83db428..546b479c3 100644 --- a/app/src/main/res/layout/dialog_filename_template.xml +++ b/app/src/main/res/layout/dialog_text_input.xml @@ -26,8 +26,7 @@ + android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/output_format_bottom_sheet.xml b/app/src/main/res/layout/output_format_bottom_sheet.xml index 1545af510..a0b629a90 100644 --- a/app/src/main/res/layout/output_format_bottom_sheet.xml +++ b/app/src/main/res/layout/output_format_bottom_sheet.xml @@ -26,7 +26,7 @@ app:singleSelection="true" /> - + app:selectionRequired="true" + app:singleSelection="true" /> Compression level Bitrate Sample rate + Custom Reset to defaults @@ -56,6 +57,10 @@ Template syntax is invalid Reset to default + + Custom parameter + Enter a value in the range [%s, %s]. + OK Cancel From 6474412e3f2d7394e47c882294be91f21af57398 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Apr 2023 02:17:02 -0400 Subject: [PATCH 2/5] Make format parameter string representations translatable Signed-off-by: Andrew Gunnerson --- .../chiller3/bcr/FormatParamDialogFragment.kt | 45 ++++++++++++++----- .../bcr/OutputFormatBottomSheetFragment.kt | 2 +- .../java/com/chiller3/bcr/SettingsActivity.kt | 2 +- .../chiller3/bcr/format/FormatParamInfo.kt | 19 +++++--- app/src/main/res/values/strings.xml | 4 ++ 5 files changed, 51 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt index 6e6c43647..fea3bdfb2 100644 --- a/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt @@ -15,6 +15,7 @@ import com.chiller3.bcr.format.Format import com.chiller3.bcr.format.RangedParamInfo import com.chiller3.bcr.format.RangedParamType import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.lang.NumberFormatException class FormatParamDialogFragment : DialogFragment() { companion object { @@ -48,26 +49,46 @@ class FormatParamDialogFragment : DialogFragment() { binding.message.text = getString( R.string.format_param_dialog_message, - paramInfo.format(paramInfo.range.first), - paramInfo.format(paramInfo.range.last), + paramInfo.format(context, paramInfo.range.first), + paramInfo.format(context, paramInfo.range.last), ) - if (paramInfo.type == RangedParamType.Bitrate) { - binding.textLayout.suffixText = "kbps" + // Try to detect if the displayed format is a prefix or suffix since it's not the same in + // every language (eg. "Level 8" vs "8级") + val translated = when (paramInfo.type) { + RangedParamType.CompressionLevel -> + getString(R.string.format_param_compression_level, "\u0000") + RangedParamType.Bitrate -> + getString(R.string.format_param_bitrate, "\u0000") + } + val placeholder = translated.indexOf('\u0000') + val hasPrefix = placeholder > 0 + val hasSuffix = placeholder < translated.length - 1 + if (hasPrefix) { + binding.textLayout.prefixText = translated.substring(0, placeholder).trimEnd() + } + if (hasSuffix) { + binding.textLayout.suffixText = translated.substring(placeholder + 1).trimStart() + } + if (hasPrefix && hasSuffix) { + binding.text.textAlignment = View.TEXT_ALIGNMENT_CENTER + } else if (hasSuffix) { binding.text.textAlignment = View.TEXT_ALIGNMENT_TEXT_END } binding.text.inputType = InputType.TYPE_CLASS_NUMBER binding.text.addTextChangedListener { - value = if (it!!.isNotEmpty()) { - val newValue = it.toString().toUInt().times(multiplier.toULong()) - if (newValue in paramInfo.range) { - newValue.toUInt() - } else { - null + value = null + + if (it!!.isNotEmpty()) { + try { + val newValue = it.toString().toUInt().times(multiplier.toULong()) + if (newValue in paramInfo.range) { + value = newValue.toUInt() + } + } catch (e: NumberFormatException) { + // Ignore } - } else { - null } refreshOkButtonEnabledState() diff --git a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt index 9d5b26f83..a21c86264 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt @@ -100,7 +100,7 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), chipBinding.root.setOnCloseIconClickListener(::onChipClosed) } if (value != null) { - chipBinding.root.text = paramInfo.format(value) + chipBinding.root.text = paramInfo.format(requireContext(), value) } else { chipBinding.root.setText(R.string.output_format_bottom_sheet_custom_param) } diff --git a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt index 4edb73b21..79678b00c 100644 --- a/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt +++ b/app/src/main/java/com/chiller3/bcr/SettingsActivity.kt @@ -116,7 +116,7 @@ class SettingsActivity : AppCompatActivity() { val formatParam = formatParamSaved ?: format.paramInfo.default val summary = getString(R.string.pref_output_format_desc) val prefix = when (val info = format.paramInfo) { - is RangedParamInfo -> "${info.format(formatParam)}, " + is RangedParamInfo -> "${info.format(requireContext(), formatParam)}, " NoParamInfo -> "" } val sampleRate = SampleRate.fromPreferences(prefs) diff --git a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt b/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt index d4c373aaa..d080ab5fd 100644 --- a/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt +++ b/app/src/main/java/com/chiller3/bcr/format/FormatParamInfo.kt @@ -2,6 +2,9 @@ package com.chiller3.bcr.format +import android.content.Context +import com.chiller3.bcr.R + sealed class FormatParamInfo( val default: UInt, /** Handful of handpicked parameter choices to show in the UI as presets. */ @@ -22,7 +25,7 @@ sealed class FormatParamInfo( /** * Format [param] to present as a user-facing string. */ - abstract fun format(param: UInt): String + abstract fun format(context: Context, param: UInt): String } enum class RangedParamType { @@ -38,18 +41,20 @@ class RangedParamInfo( ) : FormatParamInfo(default, presets) { override fun validate(param: UInt) { if (param !in range) { - throw IllegalArgumentException("Parameter ${format(param)} is not in the range: " + - "[${format(range.first)}, ${format(range.last)}]") + throw IllegalArgumentException("Parameter $param is not in the range: " + + "[${range.first}, ${range.last}]") } } /** Clamp [param] to [range]. */ override fun toNearest(param: UInt): UInt = param.coerceIn(range) - override fun format(param: UInt): String = + override fun format(context: Context, param: UInt): String = when (type) { - RangedParamType.CompressionLevel -> param.toString() - RangedParamType.Bitrate -> "${param / 1_000u} kbps" + RangedParamType.CompressionLevel -> + context.getString(R.string.format_param_compression_level, param.toString()) + RangedParamType.Bitrate -> + context.getString(R.string.format_param_bitrate, (param / 1_000U).toString()) } } @@ -60,5 +65,5 @@ object NoParamInfo : FormatParamInfo(0u, uintArrayOf()) { override fun toNearest(param: UInt): UInt = param - override fun format(param: UInt): String = "" + override fun format(context: Context, param: UInt): String = "" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ed3d62cb..962b81eab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,6 +61,10 @@ Custom parameter Enter a value in the range [%s, %s]. + + %s kbps + Level %s + OK Cancel From 976202c396be6759cbba63e76cebc12ef21f8e99 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Apr 2023 02:20:53 -0400 Subject: [PATCH 3/5] FilenameTemplateDialogFragment: Only populate text box on initial load The dialog happens to restore the last user input state after the setText() call, but it's not documented to do that, so avoid relying on that behavior. Signed-off-by: Andrew Gunnerson --- .../java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt index 242ea5fc3..480da5c7a 100644 --- a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt @@ -98,7 +98,9 @@ class FilenameTemplateDialogFragment : DialogFragment() { refreshOkButtonEnabledState() } - binding.text.setText(template.toString()) + if (savedInstanceState == null) { + binding.text.setText(template.toString()) + } return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.filename_template_dialog_title) From 7903a85aaf085dbeef150b5d32e40e589a0d837e Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Apr 2023 03:22:09 -0400 Subject: [PATCH 4/5] Replace file retention slider with custom values only This commit replaces the file retention slider with a simple dialog for entering a custom value. Unlike the output format parameters, this setting is purely a personal preference and there's no reason to suggest hardcoded durations. This was the last component that still used the Material 3 slider. No more janky animations and abusing floating point values. Signed-off-by: Andrew Gunnerson --- .../bcr/FileRetentionDialogFragment.kt | 99 +++++++++++++++++++ .../bcr/FilenameTemplateDialogFragment.kt | 3 +- .../bcr/OutputDirectoryBottomSheetFragment.kt | 41 ++++---- .../bcr/OutputFormatBottomSheetFragment.kt | 14 +-- .../main/java/com/chiller3/bcr/Retention.kt | 29 +----- .../layout/output_directory_bottom_sheet.xml | 19 +++- app/src/main/res/values/strings.xml | 7 ++ 7 files changed, 146 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/chiller3/bcr/FileRetentionDialogFragment.kt diff --git a/app/src/main/java/com/chiller3/bcr/FileRetentionDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FileRetentionDialogFragment.kt new file mode 100644 index 000000000..71da16811 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/FileRetentionDialogFragment.kt @@ -0,0 +1,99 @@ +package com.chiller3.bcr + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputType +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import com.chiller3.bcr.databinding.DialogTextInputBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class FileRetentionDialogFragment : DialogFragment() { + companion object { + val TAG: String = FileRetentionDialogFragment::class.java.simpleName + + const val RESULT_SUCCESS = "success" + } + + private lateinit var prefs: Preferences + private lateinit var binding: DialogTextInputBinding + private var retention: Retention? = null + private var success: Boolean = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + prefs = Preferences(context) + retention = Retention.fromPreferences(prefs) + + binding = DialogTextInputBinding.inflate(layoutInflater) + + binding.message.setText(R.string.file_retention_dialog_message) + + binding.text.inputType = InputType.TYPE_CLASS_NUMBER + binding.text.addTextChangedListener { + retention = if (it!!.isEmpty()) { + NoRetention + } else { + try { + val days = it.toString().toUInt() + if (days == 0U) { + NoRetention + } else { + DaysRetention(days) + } + } catch (e: NumberFormatException) { + binding.textLayout.error = getString(R.string.file_retention_error_too_large) + null + } + } + + refreshHelperText() + refreshOkButtonEnabledState() + } + if (savedInstanceState == null) { + when (val r = retention!!) { + is DaysRetention -> binding.text.setText(r.days.toString()) + NoRetention -> binding.text.setText("") + } + } + + refreshHelperText() + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.file_retention_dialog_title) + .setView(binding.root) + .setPositiveButton(R.string.dialog_action_ok) { _, _ -> + prefs.outputRetention = retention!! + success = true + } + .setNegativeButton(R.string.dialog_action_cancel, null) + .create() + .apply { + setCanceledOnTouchOutside(false) + } + } + + override fun onStart() { + super.onStart() + refreshOkButtonEnabledState() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + + setFragmentResult(tag!!, bundleOf(RESULT_SUCCESS to success)) + } + + private fun refreshHelperText() { + binding.textLayout.helperText = retention?.toFormattedString(requireContext()) + } + + private fun refreshOkButtonEnabledState() { + (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = + retention != null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt index 480da5c7a..05165e9ea 100644 --- a/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/FilenameTemplateDialogFragment.kt @@ -99,7 +99,7 @@ class FilenameTemplateDialogFragment : DialogFragment() { refreshOkButtonEnabledState() } if (savedInstanceState == null) { - binding.text.setText(template.toString()) + binding.text.setText(template!!.toString()) } return MaterialAlertDialogBuilder(requireContext()) @@ -112,6 +112,7 @@ class FilenameTemplateDialogFragment : DialogFragment() { .setNegativeButton(R.string.dialog_action_cancel, null) .setNeutralButton(R.string.filename_template_dialog_action_reset_to_default) { _, _ -> prefs.filenameTemplate = null + success = true } .create() .apply { diff --git a/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt index 6276f83e6..e83bae5ef 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/OutputDirectoryBottomSheetFragment.kt @@ -8,13 +8,9 @@ import android.view.ViewGroup import androidx.fragment.app.setFragmentResultListener import com.chiller3.bcr.databinding.OutputDirectoryBottomSheetBinding import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.slider.Slider - -class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.OnChangeListener { - private var _binding: OutputDirectoryBottomSheetBinding? = null - private val binding - get() = _binding!! +class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment() { + private lateinit var binding: OutputDirectoryBottomSheetBinding private lateinit var prefs: Preferences private lateinit var highlighter: TemplateSyntaxHighlighter @@ -29,7 +25,7 @@ class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.O container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = OutputDirectoryBottomSheetBinding.inflate(inflater, container, false) + binding = OutputDirectoryBottomSheetBinding.inflate(inflater, container, false) val context = requireContext() @@ -45,13 +41,10 @@ class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.O parentFragmentManager.beginTransaction(), FilenameTemplateDialogFragment.TAG) } - binding.retentionSlider.valueFrom = 0f - binding.retentionSlider.valueTo = (Retention.all.size - 1).toFloat() - binding.retentionSlider.stepSize = 1f - binding.retentionSlider.setLabelFormatter { - Retention.all[it.toInt()].toFormattedString(context) + binding.editRetention.setOnClickListener { + FileRetentionDialogFragment().show( + parentFragmentManager.beginTransaction(), FileRetentionDialogFragment.TAG) } - binding.retentionSlider.addOnChangeListener(this) binding.reset.setOnClickListener { prefs.outputDir = null @@ -66,6 +59,9 @@ class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.O refreshFilenameTemplate() refreshOutputRetention() } + setFragmentResultListener(FileRetentionDialogFragment.TAG) { _, _ -> + refreshOutputRetention() + } refreshFilenameTemplate() refreshOutputDir() @@ -88,21 +84,20 @@ class OutputDirectoryBottomSheetFragment : BottomSheetDialogFragment(), Slider.O } private fun refreshOutputRetention() { - val days = Retention.fromPreferences(prefs) - binding.retentionSlider.value = Retention.all.indexOf(days).toFloat() - // Disable retention options if the template makes it impossible for the feature to work val template = prefs.filenameTemplate ?: Preferences.DEFAULT_FILENAME_TEMPLATE val locations = template.findVariableRef(OutputFilenameGenerator.DATE_VAR) - binding.retentionSlider.isEnabled = locations != null && + val retentionUsable = locations != null && locations.second != setOf(Template.VariableRefLocation.Arbitrary) - } - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - when (slider) { - binding.retentionSlider -> { - prefs.outputRetention = Retention.all[value.toInt()] - } + binding.retention.isEnabled = retentionUsable + binding.editRetention.isEnabled = retentionUsable + + if (retentionUsable) { + val retention = Retention.fromPreferences(prefs) + binding.retention.text = retention.toFormattedString(requireContext()) + } else { + binding.retention.setText(R.string.retention_unusable) } } diff --git a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt index a21c86264..e52696540 100644 --- a/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt +++ b/app/src/main/java/com/chiller3/bcr/OutputFormatBottomSheetFragment.kt @@ -16,10 +16,7 @@ import com.google.android.material.chip.ChipGroup class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), ChipGroup.OnCheckedStateChangeListener, View.OnClickListener { - private var _binding: OutputFormatBottomSheetBinding? = null - private val binding - get() = _binding!! - + private lateinit var binding: OutputFormatBottomSheetBinding private lateinit var prefs: Preferences private val chipIdToFormat = HashMap() @@ -36,7 +33,7 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = OutputFormatBottomSheetBinding.inflate(inflater, container, false) + binding = OutputFormatBottomSheetBinding.inflate(inflater, container, false) prefs = Preferences(requireContext()) @@ -71,11 +68,6 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), return binding.root } - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - private fun addChip(inflater: LayoutInflater, parent: ViewGroup): BottomSheetChipBinding { val chipBinding = BottomSheetChipBinding.inflate(inflater, parent, false) val id = View.generateViewId() @@ -192,7 +184,7 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), } } binding.sampleRateGroup -> { - prefs.sampleRate = chipIdToSampleRate[checkedIds.first()] + prefs.sampleRate = chipIdToSampleRate[checkedIds.first()]!! } } } diff --git a/app/src/main/java/com/chiller3/bcr/Retention.kt b/app/src/main/java/com/chiller3/bcr/Retention.kt index c2a3afde3..6a843be90 100644 --- a/app/src/main/java/com/chiller3/bcr/Retention.kt +++ b/app/src/main/java/com/chiller3/bcr/Retention.kt @@ -9,16 +9,7 @@ sealed interface Retention { fun toRawPreferenceValue(): UInt companion object { - val all = arrayOf( - DaysRetention(1u), - DaysRetention(7u), - DaysRetention(30u), - DaysRetention(90u), - DaysRetention(182u), - DaysRetention(365u), - NoRetention, - ) - val default = all.last() + val default = NoRetention fun fromRawPreferenceValue(value: UInt): Retention = if (value == 0u) { NoRetention @@ -26,21 +17,7 @@ sealed interface Retention { DaysRetention(value) } - /** - * Get the saved retention in days from the preferences. - * - * If the saved sample rate is no longer valid or no sample rate is selected, then [default] - * is returned. - */ - fun fromPreferences(prefs: Preferences): Retention { - val savedRetention = prefs.outputRetention - - if (savedRetention != null && all.contains(savedRetention)) { - return savedRetention - } - - return default - } + fun fromPreferences(prefs: Preferences): Retention = prefs.outputRetention ?: default } } @@ -52,7 +29,7 @@ object NoRetention : Retention { } @JvmInline -value class DaysRetention(private val days: UInt) : Retention { +value class DaysRetention(val days: UInt) : Retention { override fun toFormattedString(context: Context): String = context.resources.getQuantityString(R.plurals.retention_days, days.toInt(), days.toInt()) diff --git a/app/src/main/res/layout/output_directory_bottom_sheet.xml b/app/src/main/res/layout/output_directory_bottom_sheet.xml index 5d5023b83..f7556c149 100644 --- a/app/src/main/res/layout/output_directory_bottom_sheet.xml +++ b/app/src/main/res/layout/output_directory_bottom_sheet.xml @@ -22,7 +22,8 @@ android:id="@+id/output_dir" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/bottom_sheet_title_margin_bottom" /> + android:layout_marginBottom="@dimen/bottom_sheet_title_margin_bottom" + android:textAlignment="center" /> - + android:layout_marginBottom="@dimen/bottom_sheet_title_margin_bottom" + android:textAlignment="center" /> + + Filename template Edit template File retention + Edit retention Keep all Keep %d day Keep %d days + File retention is disabled because the current filename template is incompatible with the feature. Output format @@ -57,6 +59,11 @@ Template syntax is invalid Reset to default + + @string/output_dir_bottom_sheet_file_retention + Enter the number of days to keep recordings. + Number is too large + Custom parameter Enter a value in the range [%s, %s]. From 7075a6793dbe9f16b8c2a808d348f1563bff23a0 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Wed, 19 Apr 2023 03:32:38 -0400 Subject: [PATCH 5/5] CHANGELOG.md: Add entry for PR #301 Signed-off-by: Andrew Gunnerson --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f8a40e4..30276ccdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Add support for querying the name from the call log (Issue: #291, PR: #298, @chenxiaolong) * This requires the optional `READ_CALL_LOGS` permission to be granted. * Add support for formatting the phone number as digits only or with the country-specific style (Issue: #290, PR: #299, @chenxiaolong) +* Replace slider UI components with chips and the ability to set custom values (Issue: #295, PR: #301, @chenxiaolong) Non-user-facing changes: