diff --git a/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionCustomSpeedDialogFragment.kt b/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionCustomSpeedDialogFragment.kt new file mode 100644 index 0000000000..9288549f57 --- /dev/null +++ b/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionCustomSpeedDialogFragment.kt @@ -0,0 +1,97 @@ +package dev.jdtech.jellyfin.dialogs + +import android.app.Dialog +import android.os.Bundle +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.jdtech.jellyfin.player.video.R +import kotlin.math.exp +import kotlin.math.ln + +class SpeedSelectionCustomSpeedDialogFragment( + private val speedSelectionDialog: SpeedSelectionDialogFragment, + private val currentSpeed: Float, +) : DialogFragment() { + + /** + * Define the key values for the speed selection slide bar. Chosen for the logarithmic scaling. + */ + private object SeekBarConstants { + private const val MAX_SPEED = 4.01f + private const val MIN_SPEED = 1 / 4f + const val NORMALIZATION = 2000 + val MAX = (NORMALIZATION * ln(MAX_SPEED)).toInt() + val MIN = (NORMALIZATION * ln(MIN_SPEED)).toInt() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return activity?.let { activity -> + + val speedText = TextView(activity.baseContext) + speedText.text = createLabel(currentSpeed) + speedText.gravity = Gravity.CENTER + + val seekBar = SeekBar(activity.baseContext) + seekBar.min = SeekBarConstants.MIN + seekBar.max = SeekBarConstants.MAX + seekBar.progress = speedToSeekBarValue(currentSpeed) + + val listener = object : SeekBar.OnSeekBarChangeListener { + + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + speedText.text = createLabel(seekBarValueToSpeed(seekBar?.progress ?: 0)) + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) {} // NO-OP + + override fun onStopTrackingTouch(seekBar: SeekBar?) {} // NO-OP + } + seekBar.setOnSeekBarChangeListener(listener) + + val container = LinearLayout(activity.baseContext) + container.orientation = LinearLayout.VERTICAL + container.addView(speedText) + container.addView(seekBar) + + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.custom_playback_speed) + .setView(container) + .setPositiveButton(R.string.custom_playback_speed_confirm_button_label) { dialog, _ -> + speedSelectionDialog.setCustomSpeed(seekBarValueToSpeed(seekBar.progress)) + dialog.dismiss() + } + .create() + } ?: throw IllegalStateException("Activity cannot be null") + } + + /** + * Scale the integer value from the SeekBar to the associated playback speed multiplier. + * Uses a logarithmic scale so that X speed and 1/X speed are equidistant from 1x speed. + * Discards precision beyond 2 decimal places. + * Inverted by [speedToSeekBarValue]. + */ + private fun seekBarValueToSpeed(int: Int): Float { + val preciseSpeed = exp((int.toFloat() / SeekBarConstants.NORMALIZATION)) + return ((100 * preciseSpeed).toInt() / 100f) + } + + /** + * Scale a float playback speed multiplier to the associated progress value for the SeekBar. + * Uses a logarithmic scale so that X speed and 1/X speed are equidistant from 1x speed. + * Inverted by [seekBarValueToSpeed]. + */ + private fun speedToSeekBarValue(float: Float): Int { + return (SeekBarConstants.NORMALIZATION * ln(float)).toInt() + } + + /** + * Create a formatted string for a label describing the selected playback speed multiplier. + */ + private fun createLabel(float: Float): String { + return "%.2fx".format(float) + } +} diff --git a/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt b/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt index 47240638e6..8817b41463 100644 --- a/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt +++ b/player/video/src/main/java/dev/jdtech/jellyfin/dialogs/SpeedSelectionDialogFragment.kt @@ -1,6 +1,7 @@ package dev.jdtech.jellyfin.dialogs import android.app.Dialog +import android.icu.text.DecimalFormat import android.os.Bundle import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -9,14 +10,28 @@ import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.jdtech.jellyfin.player.video.R import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel -import java.lang.IllegalStateException class SpeedSelectionDialogFragment( private val viewModel: PlayerActivityViewModel, ) : DialogFragment() { + + private companion object { + val PLAYBACK_SPEED_FORMAT = DecimalFormat("0.##x") + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val speedTexts = listOf("0.5x", "0.75x", "1x", "1.25x", "1.5x", "1.75x", "2x") - val speedNumbers = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) + val customLabel = getString(R.string.custom_playback_speed_label) + val currentSpeed = viewModel.playbackSpeed + + val speedNumbers = mutableListOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f) + val speedTexts = speedNumbers.map(PLAYBACK_SPEED_FORMAT::format).toMutableList() + + if (currentSpeed !in speedNumbers) { + speedTexts.add("$customLabel: ${PLAYBACK_SPEED_FORMAT.format(currentSpeed)}") + speedNumbers.add(currentSpeed) + } else { + speedTexts.add(customLabel) + } return activity?.let { activity -> val builder = MaterialAlertDialogBuilder(activity) @@ -25,9 +40,13 @@ class SpeedSelectionDialogFragment( speedTexts.toTypedArray(), speedNumbers.indexOf(viewModel.playbackSpeed), ) { dialog, which -> - viewModel.selectSpeed( - speedNumbers[which], - ) + if (speedTexts[which].startsWith(customLabel)) { + // Use a secondary dialog to determine the speed to set. + SpeedSelectionCustomSpeedDialogFragment(this, currentSpeed) + .show(activity.supportFragmentManager, "customSpeedSelection") + } else { + setCustomSpeed(speedNumbers[which]) + } dialog.dismiss() } builder.create() @@ -44,4 +63,8 @@ class SpeedSelectionDialogFragment( } } } + + fun setCustomSpeed(speed: Float) { + viewModel.selectSpeed(speed) + } } diff --git a/player/video/src/main/res/values/strings.xml b/player/video/src/main/res/values/strings.xml index 124455aa86..20a645dcd9 100644 --- a/player/video/src/main/res/values/strings.xml +++ b/player/video/src/main/res/values/strings.xml @@ -2,6 +2,10 @@ Select audio track Select subtitle track Select playback speed + + Playback speed + Custom + Set "Select a version" External None