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: 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 9381897f9..05165e9ea 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 @@ -95,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) @@ -107,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/FormatParamDialogFragment.kt b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt new file mode 100644 index 000000000..fea3bdfb2 --- /dev/null +++ b/app/src/main/java/com/chiller3/bcr/FormatParamDialogFragment.kt @@ -0,0 +1,125 @@ +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 +import java.lang.NumberFormatException + +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(context, paramInfo.range.first), + paramInfo.format(context, paramInfo.range.last), + ) + + // 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 = 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 + } + } + + 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/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 2f3b21633..e52696540 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,24 +7,23 @@ 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 { - private var _binding: OutputFormatBottomSheetBinding? = null - private val binding - get() = _binding!! - + ChipGroup.OnCheckedStateChangeListener, View.OnClickListener { + private lateinit var binding: OutputFormatBottomSheetBinding private lateinit var prefs: Preferences 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() @@ -32,15 +33,10 @@ class OutputFormatBottomSheetFragment : BottomSheetDialogFragment(), container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = OutputFormatBottomSheetBinding.inflate(inflater, container, false) + binding = OutputFormatBottomSheetBinding.inflate(inflater, container, false) prefs = Preferences(requireContext()) - binding.paramSlider.setLabelFormatter { - formatParamInfo.format(it.toUInt()) - } - binding.paramSlider.addOnChangeListener(this) - binding.reset.setOnClickListener(this) for (format in Format.all) { @@ -53,45 +49,62 @@ 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 } - 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() + chipBinding.root.id = id + chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE + parent.addView(chipBinding.root) + return chipBinding } private fun addFormatChip(inflater: LayoutInflater, format: Format) { - val chipBinding = BottomSheetChipBinding.inflate( - inflater, binding.nameGroup, false) - val id = View.generateViewId() - chipBinding.root.id = id + val chipBinding = addChip(inflater, binding.nameGroup) chipBinding.root.text = format.name - chipBinding.root.layoutDirection = View.LAYOUT_DIRECTION_LOCALE - binding.nameGroup.addView(chipBinding.root) - chipIdToFormat[id] = format - formatToChipId[format] = id + 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(requireContext(), 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,55 +117,74 @@ 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.sampleRateGroup -> { - prefs.sampleRate = chipIdToSampleRate[checkedIds.first()] - } - } - } - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - when (slider) { - binding.paramSlider -> { + binding.paramGroup -> { val format = chipIdToFormat[binding.nameGroup.checkedChipId]!! - prefs.setFormatParam(format, value.toUInt()) + 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()]!! } } } 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/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/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..d080ab5fd 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,15 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + package com.chiller3.bcr.format -sealed class FormatParamInfo(val default: UInt) { +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. */ + val presets: UIntArray, +) { /** * Ensure that [param] is valid. * @@ -16,7 +25,7 @@ sealed class FormatParamInfo(val default: UInt) { /** * 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 { @@ -27,51 +36,34 @@ 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: " + - "[${format(range.first)}, ${format(range.last)}]") + throw IllegalArgumentException("Parameter $param is not in the range: " + + "[${range.first}, ${range.last}]") } } - /** 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 = + 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()) } } -object NoParamInfo : FormatParamInfo(0u) { +object NoParamInfo : FormatParamInfo(0u, uintArrayOf()) { override fun validate(param: UInt) { // Always valid } 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/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_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" /> + + - + app:selectionRequired="true" + app:singleSelection="true" /> 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 Compression level Bitrate Sample rate + Custom Reset to defaults @@ -56,6 +59,19 @@ 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]. + + + %s kbps + Level %s + OK Cancel