Skip to content

Commit

Permalink
Add encoder configuration UI
Browse files Browse the repository at this point in the history
The configuration is implemented as a bottom sheet that appears when
clicking the new output format preferences. Changes are applied
immediately and per-codec configuration values are preserved when
switching codecs.

Issue: #21
Signed-off-by: Andrew Gunnerson <chillermillerlong@hotmail.com>
  • Loading branch information
chenxiaolong committed May 27, 2022
1 parent cc3bc70 commit 4243381
Show file tree
Hide file tree
Showing 14 changed files with 237 additions and 29 deletions.
135 changes: 135 additions & 0 deletions app/src/main/java/com/chiller3/bcr/CodecBottomSheetFragment.kt
@@ -0,0 +1,135 @@
package com.chiller3.bcr

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.ViewCompat
import com.chiller3.bcr.codec.Codec
import com.chiller3.bcr.codec.CodecParamType
import com.chiller3.bcr.codec.Codecs
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider

class CodecBottomSheetFragment : BottomSheetDialogFragment(),
MaterialButtonToggleGroup.OnButtonCheckedListener, LabelFormatter, Slider.OnChangeListener,
View.OnClickListener {
private lateinit var codecParamTitle: TextView
private lateinit var codecParam: Slider
private lateinit var codecReset: MaterialButton
private lateinit var codecNameGroup: MaterialButtonToggleGroup
private val buttonIdToCodec = HashMap<Int, Codec>()
private val codecToButtonId = HashMap<Codec, Int>()
private lateinit var codecParamType: CodecParamType

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val bottomSheet = inflater.inflate(R.layout.codec_bottom_sheet, container, false)

codecParamTitle = bottomSheet.findViewById(R.id.codec_param_title)

codecParam = bottomSheet.findViewById(R.id.codec_param)
codecParam.setLabelFormatter(this)
codecParam.addOnChangeListener(this)

codecReset = bottomSheet.findViewById(R.id.codec_reset)
codecReset.setOnClickListener(this)

codecNameGroup = bottomSheet.findViewById(R.id.codec_name_group)!!

for (codec in Codecs.all) {
if (!codec.supported) {
continue
}

val button = layoutInflater.inflate(
R.layout.codec_bottom_sheet_button, codecNameGroup, false) as MaterialButton
val id = ViewCompat.generateViewId()
button.id = id
button.text = codec.name
codecNameGroup.addView(button)
buttonIdToCodec[id] = codec
codecToButtonId[codec] = id
}

codecNameGroup.addOnButtonCheckedListener(this)

refreshCodec()

return bottomSheet
}

/**
* Update UI based on currently selected codec in the preferences.
*
* Calls [refreshParam] via [onButtonChecked].
*/
private fun refreshCodec() {
val (codec, _) = Codecs.fromPreferences(requireContext())
codecNameGroup.check(codecToButtonId[codec]!!)
}

/**
* Update parameter title and slider to match codec parameter specifications.
*/
private fun refreshParam() {
val (codec, param) = Codecs.fromPreferences(requireContext())
codecParamType = codec.paramType

val titleResId = when (codec.paramType) {
CodecParamType.CompressionLevel -> R.string.bottom_sheet_compression_level
CodecParamType.Bitrate -> R.string.bottom_sheet_bitrate
}

codecParamTitle.setText(titleResId)

codecParam.valueFrom = codec.paramRange.first.toFloat()
codecParam.valueTo = codec.paramRange.last.toFloat()
codecParam.stepSize = codec.paramStepSize.toFloat()

codecParam.value = (param ?: codec.paramDefault).toFloat()
}

override fun onButtonChecked(
group: MaterialButtonToggleGroup?,
checkedId: Int,
isChecked: Boolean
) {
if (isChecked) {
Preferences.setCodecName(requireContext(), buttonIdToCodec[checkedId]!!.name)
refreshParam()
}
}

override fun getFormattedValue(value: Float): String =
codecParamType.format(value.toUInt())

override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
when (slider) {
codecParam -> {
val codec = buttonIdToCodec[codecNameGroup.checkedButtonId]!!
Preferences.setCodecParam(requireContext(), codec.name, value.toUInt())
}
}
}

override fun onClick(v: View?) {
when (v) {
codecReset -> {
Preferences.resetAllCodecs(requireContext())
refreshCodec()
}
}
}

companion object {
val TAG = CodecBottomSheetFragment::class.java.simpleName
}
}
7 changes: 4 additions & 3 deletions app/src/main/java/com/chiller3/bcr/Preferences.kt
Expand Up @@ -97,6 +97,9 @@ object Preferences {
editor.apply()
}

fun isCodecKey(key: String): Boolean =
key == PREF_CODEC_NAME || key.startsWith(PREF_CODEC_PARAM_PREFIX)

/**
* Get the saved output codec.
*
Expand Down Expand Up @@ -172,9 +175,7 @@ object Preferences {
*/
fun resetAllCodecs(context: Context) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val keys = prefs.all.keys.filter {
it == PREF_CODEC_NAME || it.startsWith(PREF_CODEC_PARAM_PREFIX)
}
val keys = prefs.all.keys.filter(::isCodecKey)
val editor = prefs.edit()

for (key in keys) {
Expand Down
29 changes: 12 additions & 17 deletions app/src/main/java/com/chiller3/bcr/SettingsActivity.kt
Expand Up @@ -9,7 +9,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import com.chiller3.bcr.codec.CodecParamType
import com.chiller3.bcr.codec.Codecs

class SettingsActivity : AppCompatActivity() {
Expand All @@ -33,7 +32,7 @@ class SettingsActivity : AppCompatActivity() {
SharedPreferences.OnSharedPreferenceChangeListener {
private lateinit var prefCallRecording: SwitchPreferenceCompat
private lateinit var prefOutputDir: LongClickablePreference
private lateinit var prefOutputFormat: LongClickablePreference
private lateinit var prefOutputFormat: Preference
private lateinit var prefInhibitBatteryOpt: SwitchPreferenceCompat
private lateinit var prefVersion: Preference

Expand Down Expand Up @@ -76,7 +75,6 @@ class SettingsActivity : AppCompatActivity() {

prefOutputFormat = findPreference(Preferences.PREF_OUTPUT_FORMAT)!!
prefOutputFormat.onPreferenceClickListener = this
prefOutputFormat.onPreferenceLongClickListener = this
refreshOutputFormat()

prefInhibitBatteryOpt = findPreference(Preferences.PREF_INHIBIT_BATT_OPT)!!
Expand Down Expand Up @@ -112,10 +110,7 @@ class SettingsActivity : AppCompatActivity() {
val (codec, codecParamSaved) = Codecs.fromPreferences(requireContext())
val codecParam = codecParamSaved ?: codec.paramDefault
val summary = getString(R.string.pref_output_format_desc)
val paramText = when (codec.paramType) {
CodecParamType.CompressionLevel -> codecParam.toString()
CodecParamType.Bitrate -> "${codecParam / 1_000u} kbps"
}
val paramText = codec.paramType.format(codecParam)

prefOutputFormat.summary = "${summary}\n\n${codec.name} (${paramText})"
}
Expand Down Expand Up @@ -155,7 +150,8 @@ class SettingsActivity : AppCompatActivity() {
return true
}
prefOutputFormat -> {
// TODO: Open codec configuration dialog
CodecBottomSheetFragment().show(
childFragmentManager, CodecBottomSheetFragment.TAG)
return true
}
prefVersion -> {
Expand All @@ -175,27 +171,26 @@ class SettingsActivity : AppCompatActivity() {
refreshOutputDir()
return true
}
prefOutputFormat -> {
Preferences.resetAllCodecs(requireContext())
refreshOutputFormat()
return true
}
}

return false
}

override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
// Update the switch state if it was toggled outside of the preference (eg. from the
// quick settings toggle)
when (key) {
prefCallRecording.key -> {
when {
// Update the switch state if it was toggled outside of the preference (eg. from the
// quick settings toggle)
key == prefCallRecording.key -> {
val current = prefCallRecording.isChecked
val expected = sharedPreferences.getBoolean(key, current)
if (current != expected) {
prefCallRecording.isChecked = expected
}
}
// Update the output format state when it's changed by the bottom sheet
Preferences.isCodecKey(key) -> {
refreshOutputFormat()
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/chiller3/bcr/codec/AacCodec.kt
Expand Up @@ -13,6 +13,7 @@ object AacCodec : Codec() {
// AAC-LC: 2 * 64kbps/channel.
// https://trac.ffmpeg.org/wiki/Encode/AAC
override val paramRange: UIntRange = 24_000u..128_000u
override val paramStepSize: UInt = 4_000u
override val paramDefault: UInt = 64_000u
// https://datatracker.ietf.org/doc/html/rfc6381#section-3.1
override val mimeTypeContainer: String = "audio/mp4"
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/chiller3/bcr/codec/Codec.kt
Expand Up @@ -17,6 +17,9 @@ sealed class Codec {
/** Valid range for the codec-specific parameter value. */
abstract val paramRange: UIntRange

/** Reasonable step size for selecting a value via the UI. */
abstract val paramStepSize: UInt

/** Default codec parameter value. */
abstract val paramDefault: UInt

Expand Down
11 changes: 9 additions & 2 deletions app/src/main/java/com/chiller3/bcr/codec/CodecParamType.kt
Expand Up @@ -2,7 +2,14 @@ package com.chiller3.bcr.codec

enum class CodecParamType {
/** For lossless codecs. Represents a codec-specific arbitrary integer. */
CompressionLevel,
CompressionLevel {
override fun format(param: UInt): String = param.toString()
},

/** For lossy codecs. Represents a bitrate *per channel* in bits per second. */
Bitrate,
Bitrate {
override fun format(param: UInt): String = "${param / 1_000u} kbps"
};

abstract fun format(param: UInt): String
}
12 changes: 7 additions & 5 deletions app/src/main/java/com/chiller3/bcr/codec/Codecs.kt
Expand Up @@ -17,11 +17,13 @@ object Codecs {
*/
fun fromPreferences(context: Context): Pair<Codec, UInt?> {
val savedCodecName = Preferences.getCodecName(context)
val codec = if (savedCodecName != null) {
getByName(savedCodecName) ?: default
} else {
default
}

// Use the saved codec if it is valid and supported on the current device. Otherwise, fall
// back to the default.
val codec = savedCodecName
?.let { getByName(it) }
?.let { if (it.supported) { it } else { null } }
?: default

// Clamp to the codec's allowed parameter range in case the range is shrunk
val param = Preferences.getCodecParam(context, codec.name)?.coerceIn(codec.paramRange)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/chiller3/bcr/codec/FlacCodec.kt
Expand Up @@ -7,6 +7,7 @@ object FlacCodec: Codec() {
override val name: String = "FLAC"
override val paramType: CodecParamType = CodecParamType.CompressionLevel
override val paramRange: UIntRange = 0u..8u
override val paramStepSize: UInt = 1u
// Devices are fast enough nowadays to use the highest compression for realtime recording
override val paramDefault: UInt = 8u
override val mimeTypeContainer: String = MediaFormat.MIMETYPE_AUDIO_FLAC
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/chiller3/bcr/codec/OpusCodec.kt
Expand Up @@ -10,6 +10,7 @@ object OpusCodec : Codec() {
override val name: String = "OGG/Opus"
override val paramType: CodecParamType = CodecParamType.Bitrate
override val paramRange: UIntRange = 6_000u..510_000u
override val paramStepSize: UInt = 2_000u
// "Essentially transparent mono or stereo speech, reasonable music"
// https://wiki.hydrogenaud.io/index.php?title=Opus
override val paramDefault: UInt = 48_000u
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/res/layout/codec_bottom_sheet.xml
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="@dimen/bottom_sheet_overall_padding">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/bottom_sheet_title_margin_bottom"
android:text="@string/bottom_sheet_output_format"
android:textAppearance="?attr/textAppearanceHeadline6" />

<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/codec_name_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:selectionRequired="true"
app:singleSelection="true" />

<TextView
android:id="@+id/codec_param_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/bottom_sheet_section_separation"
android:layout_marginBottom="@dimen/bottom_sheet_title_margin_bottom"
android:textAppearance="?attr/textAppearanceHeadline6" />

<com.google.android.material.slider.Slider
android:id="@+id/codec_param"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:labelBehavior="visible" />

<com.google.android.material.button.MaterialButton
android:id="@+id/codec_reset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/bottom_sheet_section_separation"
android:text="@string/bottom_sheet_reset"
style="?attr/materialButtonOutlinedStyle" />
</LinearLayout>
5 changes: 5 additions & 0 deletions app/src/main/res/layout/codec_bottom_sheet_button.xml
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="?attr/materialButtonOutlinedStyle" />
6 changes: 6 additions & 0 deletions app/src/main/res/values/dimens.xml
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="bottom_sheet_overall_padding">30dp</dimen>
<dimen name="bottom_sheet_title_margin_bottom">16dp</dimen>
<dimen name="bottom_sheet_section_separation">30dp</dimen>
</resources>
8 changes: 7 additions & 1 deletion app/src/main/res/values/strings.xml
Expand Up @@ -14,14 +14,20 @@
<string name="pref_output_dir_desc">Pick a directory to store recordings. Long press to reset to the default directory.</string>

<string name="pref_output_format_name">Output format</string>
<string name="pref_output_format_desc">Select an encoding format for the recordings. Long press to reset all encoder settings to the default.</string>
<string name="pref_output_format_desc">Select an encoding format for the recordings.</string>

<string name="pref_inhibit_batt_opt_name">Disable battery optimization</string>
<string name="pref_inhibit_batt_opt_desc">Reduces the chance of the app being killed by the system.</string>

<!-- About "preference" -->
<string name="pref_version_name">Version</string>

<!-- Output format bottom sheet -->
<string name="bottom_sheet_output_format">Output format</string>
<string name="bottom_sheet_compression_level">Compression level</string>
<string name="bottom_sheet_bitrate">Bitrate</string>
<string name="bottom_sheet_reset">Reset to defaults</string>

<!-- Notifications -->
<string name="notification_channel_persistent_name">Background services</string>
<string name="notification_channel_persistent_desc">Persistent notification for background call recording</string>
Expand Down

0 comments on commit 4243381

Please sign in to comment.