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