Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Support custom playback speed #895

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -44,4 +63,8 @@ class SpeedSelectionDialogFragment(
}
}
}

fun setCustomSpeed(speed: Float) {
viewModel.selectSpeed(speed)
}
}
4 changes: 4 additions & 0 deletions player/video/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<string name="select_audio_track">Select audio track</string>
<string name="select_subtile_track">Select subtitle track</string>
<string name="select_playback_speed">Select playback speed</string>
<!--TODO: Add translations for custom playback speed strings.-->
<string name="custom_playback_speed">Playback speed</string>
<string name="custom_playback_speed_label">Custom</string>
<string name="custom_playback_speed_confirm_button_label">Set</string>
<string name="select_a_version">"Select a version"</string>
<string name="external">External</string>
<string name="none">None</string>
Expand Down