From 18d019c62ae18f9c42a19d98e9cfe7d0cb43fd4a Mon Sep 17 00:00:00 2001 From: John Zhen Mo Date: Wed, 21 Mar 2018 20:08:33 -0700 Subject: [PATCH] -Added quadratic slider strategy implementation and tests. -Modified playback speed control to use quadratic sliders instead of linear. -Modified number formatters in player helper to use double instead of float. -Simplified slider behavior in playback parameter dialog. -Fixed potential NPE in base local fragment. --- .../local/bookmark/BaseLocalListFragment.java | 5 +- .../helper/PlaybackParameterDialog.java | 345 ++++++++++-------- .../newpipe/player/helper/PlayerHelper.java | 4 +- .../schabi/newpipe/util/SliderStrategy.java | 73 ++++ .../util/QuadraticSliderStrategyTest.java | 86 +++++ 5 files changed, 353 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java create mode 100644 app/src/test/java/org/schabi/newpipe/util/QuadraticSliderStrategyTest.java diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java index d2c4e1b14..eb366d97f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java @@ -151,7 +151,10 @@ public abstract class BaseLocalListFragment extends BaseStateFragment @Override public void showListFooter(final boolean show) { - itemsList.post(() -> itemListAdapter.showFooter(show)); + if (itemsList == null) return; + itemsList.post(() -> { + if (itemListAdapter != null) itemListAdapter.showFooter(show); + }); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java index 8a0a8a86c..7c7d87791 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -14,59 +14,64 @@ import android.widget.SeekBar; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.SliderStrategy; import static org.schabi.newpipe.player.BasePlayer.DEBUG; public class PlaybackParameterDialog extends DialogFragment { - private static final String TAG = "PlaybackParameterDialog"; + @NonNull private static final String TAG = "PlaybackParameterDialog"; - public static final float MINIMUM_PLAYBACK_VALUE = 0.25f; - public static final float MAXIMUM_PLAYBACK_VALUE = 3.00f; + public static final double MINIMUM_PLAYBACK_VALUE = 0.25f; + public static final double MAXIMUM_PLAYBACK_VALUE = 3.00f; - public static final String STEP_UP_SIGN = "+"; - public static final String STEP_DOWN_SIGN = "-"; - public static final float PLAYBACK_STEP_VALUE = 0.05f; + public static final char STEP_UP_SIGN = '+'; + public static final char STEP_DOWN_SIGN = '-'; + public static final double PLAYBACK_STEP_VALUE = 0.05f; - public static final float NIGHTCORE_TEMPO = 1.20f; - public static final float NIGHTCORE_PITCH_LOWER = 1.15f; - public static final float NIGHTCORE_PITCH_UPPER = 1.25f; + public static final double NIGHTCORE_TEMPO = 1.20f; + public static final double NIGHTCORE_PITCH_LOWER = 1.15f; + public static final double NIGHTCORE_PITCH_UPPER = 1.25f; - public static final float DEFAULT_TEMPO = 1.00f; - public static final float DEFAULT_PITCH = 1.00f; + public static final double DEFAULT_TEMPO = 1.00f; + public static final double DEFAULT_PITCH = 1.00f; - private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; - private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; + @NonNull private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; + @NonNull private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; public interface Callback { void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch); } - private Callback callback; + @Nullable private Callback callback; - private float initialTempo = DEFAULT_TEMPO; - private float initialPitch = DEFAULT_PITCH; + @NonNull private final SliderStrategy strategy = new SliderStrategy.Quadratic( + MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE, + /*centerAt=*/1.00f, /*sliderGranularity=*/10000); - private SeekBar tempoSlider; - private TextView tempoMinimumText; - private TextView tempoMaximumText; - private TextView tempoCurrentText; - private TextView tempoStepDownText; - private TextView tempoStepUpText; + private double initialTempo = DEFAULT_TEMPO; + private double initialPitch = DEFAULT_PITCH; - private SeekBar pitchSlider; - private TextView pitchMinimumText; - private TextView pitchMaximumText; - private TextView pitchCurrentText; - private TextView pitchStepDownText; - private TextView pitchStepUpText; + @Nullable private SeekBar tempoSlider; + @Nullable private TextView tempoMinimumText; + @Nullable private TextView tempoMaximumText; + @Nullable private TextView tempoCurrentText; + @Nullable private TextView tempoStepDownText; + @Nullable private TextView tempoStepUpText; - private CheckBox unhookingCheckbox; + @Nullable private SeekBar pitchSlider; + @Nullable private TextView pitchMinimumText; + @Nullable private TextView pitchMaximumText; + @Nullable private TextView pitchCurrentText; + @Nullable private TextView pitchStepDownText; + @Nullable private TextView pitchStepUpText; - private TextView nightCorePresetText; - private TextView resetPresetText; + @Nullable private CheckBox unhookingCheckbox; - public static PlaybackParameterDialog newInstance(final float playbackTempo, - final float playbackPitch) { + @Nullable private TextView nightCorePresetText; + @Nullable private TextView resetPresetText; + + public static PlaybackParameterDialog newInstance(final double playbackTempo, + final double playbackPitch) { PlaybackParameterDialog dialog = new PlaybackParameterDialog(); dialog.initialTempo = playbackTempo; dialog.initialPitch = playbackPitch; @@ -91,16 +96,16 @@ public class PlaybackParameterDialog extends DialogFragment { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (savedInstanceState != null) { - initialTempo = savedInstanceState.getFloat(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); - initialPitch = savedInstanceState.getFloat(INITIAL_PITCH_KEY, DEFAULT_PITCH); + initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); + initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); } } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - outState.putFloat(INITIAL_TEMPO_KEY, initialTempo); - outState.putFloat(INITIAL_PITCH_KEY, initialPitch); + outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); + outState.putDouble(INITIAL_PITCH_KEY, initialPitch); } /*////////////////////////////////////////////////////////////////////////// @@ -111,7 +116,7 @@ public class PlaybackParameterDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { final View view = View.inflate(getContext(), R.layout.dialog_playback_parameter, null); - setupView(view); + setupControlViews(view); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) .setTitle(R.string.playback_speed_control) @@ -120,16 +125,16 @@ public class PlaybackParameterDialog extends DialogFragment { .setNegativeButton(R.string.cancel, (dialogInterface, i) -> setPlaybackParameters(initialTempo, initialPitch)) .setPositiveButton(R.string.finish, (dialogInterface, i) -> - setPlaybackParameters(getCurrentTempo(), getCurrentPitch())); + setCurrentPlaybackParameters()); return dialogBuilder.create(); } /*////////////////////////////////////////////////////////////////////////// - // Dialog Builder + // Control Views //////////////////////////////////////////////////////////////////////////*/ - private void setupView(@NonNull View rootView) { + private void setupControlViews(@NonNull View rootView) { setupHookingControl(rootView); setupTempoControl(rootView); setupPitchControl(rootView); @@ -144,45 +149,34 @@ public class PlaybackParameterDialog extends DialogFragment { tempoStepUpText = rootView.findViewById(R.id.tempoStepUp); tempoStepDownText = rootView.findViewById(R.id.tempoStepDown); - tempoCurrentText.setText(PlayerHelper.formatSpeed(initialTempo)); - tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); - tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); + if (tempoCurrentText != null) + tempoCurrentText.setText(PlayerHelper.formatSpeed(initialTempo)); + if (tempoMaximumText != null) + tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); + if (tempoMinimumText != null) + tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); - tempoStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); - tempoStepUpText.setOnClickListener(view -> - setTempo(getCurrentTempo() + PLAYBACK_STEP_VALUE)); + if (tempoStepUpText != null) { + tempoStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); + tempoStepUpText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() + PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } - tempoStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); - tempoStepDownText.setOnClickListener(view -> - setTempo(getCurrentTempo() - PLAYBACK_STEP_VALUE)); + if (tempoStepDownText != null) { + tempoStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); + tempoStepDownText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() - PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } - tempoSlider.setMax(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE)); - tempoSlider.setProgress(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, initialTempo)); - tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); - } - - private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { - return new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - final float currentTempo = getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, progress); - if (fromUser) { // this change is first in chain - setTempo(currentTempo); - } else { - setPlaybackParameters(currentTempo, getCurrentPitch()); - } - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - // Do Nothing. - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - // Do Nothing. - } - }; + if (tempoSlider != null) { + tempoSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + tempoSlider.setProgress(strategy.progressOf(initialTempo)); + tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); + } } private void setupPitchControl(@NonNull View rootView) { @@ -193,32 +187,85 @@ public class PlaybackParameterDialog extends DialogFragment { pitchStepDownText = rootView.findViewById(R.id.pitchStepDown); pitchStepUpText = rootView.findViewById(R.id.pitchStepUp); - pitchCurrentText.setText(PlayerHelper.formatPitch(initialPitch)); - pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); - pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); + if (pitchCurrentText != null) + pitchCurrentText.setText(PlayerHelper.formatPitch(initialPitch)); + if (pitchMaximumText != null) + pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); + if (pitchMinimumText != null) + pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); - pitchStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); - pitchStepUpText.setOnClickListener(view -> - setPitch(getCurrentPitch() + PLAYBACK_STEP_VALUE)); + if (pitchStepUpText != null) { + pitchStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); + pitchStepUpText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() + PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } - pitchStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); - pitchStepDownText.setOnClickListener(view -> - setPitch(getCurrentPitch() - PLAYBACK_STEP_VALUE)); + if (pitchStepDownText != null) { + pitchStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); + pitchStepDownText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() - PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } - pitchSlider.setMax(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE)); - pitchSlider.setProgress(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, initialPitch)); - pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); + if (pitchSlider != null) { + pitchSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + pitchSlider.setProgress(strategy.progressOf(initialPitch)); + pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); + } } - private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { + private void setupHookingControl(@NonNull View rootView) { + unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); + if (unhookingCheckbox != null) { + unhookingCheckbox.setChecked(initialPitch != initialTempo); + unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + if (isChecked) return; + // When unchecked, slide back to the minimum of current tempo or pitch + final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); + setSliders(minimum); + setCurrentPlaybackParameters(); + }); + } + } + + private void setupPresetControl(@NonNull View rootView) { + nightCorePresetText = rootView.findViewById(R.id.presetNightcore); + if (nightCorePresetText != null) { + nightCorePresetText.setOnClickListener(view -> { + final double randomPitch = NIGHTCORE_PITCH_LOWER + + Math.random() * (NIGHTCORE_PITCH_UPPER - NIGHTCORE_PITCH_LOWER); + + setTempoSlider(NIGHTCORE_TEMPO); + setPitchSlider(randomPitch); + setCurrentPlaybackParameters(); + }); + } + + resetPresetText = rootView.findViewById(R.id.presetReset); + if (resetPresetText != null) { + resetPresetText.setOnClickListener(view -> { + setTempoSlider(DEFAULT_TEMPO); + setPitchSlider(DEFAULT_PITCH); + setCurrentPlaybackParameters(); + }); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Sliders + //////////////////////////////////////////////////////////////////////////*/ + + private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { return new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - final float currentPitch = getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, progress); - if (fromUser) { // this change is first in chain - setPitch(currentPitch); - } else { - setPlaybackParameters(getCurrentTempo(), currentPitch); + final double currentTempo = strategy.valueOf(progress); + if (fromUser) { + onTempoSliderUpdated(currentTempo); + setCurrentPlaybackParameters(); } } @@ -234,38 +281,30 @@ public class PlaybackParameterDialog extends DialogFragment { }; } - private void setupHookingControl(@NonNull View rootView) { - unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); - unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - if (isChecked) return; - // When unchecked, slide back to the minimum of current tempo or pitch - final float minimum = Math.min(getCurrentPitch(), getCurrentTempo()); - setSliders(minimum); - }); + private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { + return new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + final double currentPitch = strategy.valueOf(progress); + if (fromUser) { // this change is first in chain + onPitchSliderUpdated(currentPitch); + setCurrentPlaybackParameters(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + }; } - private void setupPresetControl(@NonNull View rootView) { - nightCorePresetText = rootView.findViewById(R.id.presetNightcore); - nightCorePresetText.setOnClickListener(view -> { - final float randomPitch = NIGHTCORE_PITCH_LOWER + - (float) Math.random() * (NIGHTCORE_PITCH_UPPER - NIGHTCORE_PITCH_LOWER); - - setTempoSlider(NIGHTCORE_TEMPO); - setPitchSlider(randomPitch); - }); - - resetPresetText = rootView.findViewById(R.id.presetReset); - resetPresetText.setOnClickListener(view -> { - setTempoSlider(DEFAULT_TEMPO); - setPitchSlider(DEFAULT_PITCH); - }); - } - - /*////////////////////////////////////////////////////////////////////////// - // Helper - //////////////////////////////////////////////////////////////////////////*/ - - private void setTempo(final float newTempo) { + private void onTempoSliderUpdated(final double newTempo) { if (unhookingCheckbox == null) return; if (!unhookingCheckbox.isChecked()) { setSliders(newTempo); @@ -274,7 +313,7 @@ public class PlaybackParameterDialog extends DialogFragment { } } - private void setPitch(final float newPitch) { + private void onPitchSliderUpdated(final double newPitch) { if (unhookingCheckbox == null) return; if (!unhookingCheckbox.isChecked()) { setSliders(newPitch); @@ -283,25 +322,30 @@ public class PlaybackParameterDialog extends DialogFragment { } } - private void setSliders(final float newValue) { + private void setSliders(final double newValue) { setTempoSlider(newValue); setPitchSlider(newValue); } - private void setTempoSlider(final float newTempo) { + private void setTempoSlider(final double newTempo) { if (tempoSlider == null) return; - // seekbar doesn't register progress if it is the same as the existing progress - tempoSlider.setProgress(Integer.MAX_VALUE); - tempoSlider.setProgress(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, newTempo)); + tempoSlider.setProgress(strategy.progressOf(newTempo)); } - private void setPitchSlider(final float newPitch) { + private void setPitchSlider(final double newPitch) { if (pitchSlider == null) return; - pitchSlider.setProgress(Integer.MAX_VALUE); - pitchSlider.setProgress(getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, newPitch)); + pitchSlider.setProgress(strategy.progressOf(newPitch)); } - private void setPlaybackParameters(final float tempo, final float pitch) { + /*////////////////////////////////////////////////////////////////////////// + // Helper + //////////////////////////////////////////////////////////////////////////*/ + + private void setCurrentPlaybackParameters() { + setPlaybackParameters(getCurrentTempo(), getCurrentPitch()); + } + + private void setPlaybackParameters(final double tempo, final double pitch) { if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { if (DEBUG) Log.d(TAG, "Setting playback parameters to " + "tempo=[" + tempo + "], " + @@ -309,40 +353,27 @@ public class PlaybackParameterDialog extends DialogFragment { tempoCurrentText.setText(PlayerHelper.formatSpeed(tempo)); pitchCurrentText.setText(PlayerHelper.formatPitch(pitch)); - callback.onPlaybackParameterChanged(tempo, pitch); + callback.onPlaybackParameterChanged((float) tempo, (float) pitch); } } - private float getCurrentTempo() { - return tempoSlider == null ? initialTempo : getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, + private double getCurrentTempo() { + return tempoSlider == null ? initialTempo : strategy.valueOf( tempoSlider.getProgress()); } - private float getCurrentPitch() { - return pitchSlider == null ? initialPitch : getSliderEquivalent(MINIMUM_PLAYBACK_VALUE, + private double getCurrentPitch() { + return pitchSlider == null ? initialPitch : strategy.valueOf( pitchSlider.getProgress()); } - /** - * Converts from zeroed float with a minimum offset to the nearest rounded slider - * equivalent integer - * */ - private static int getSliderEquivalent(final float minimumValue, final float floatValue) { - return Math.round((floatValue - minimumValue) * 100f); - } - - /** - * Converts from slider integer value to an equivalent float value with a given minimum offset - * */ - private static float getSliderEquivalent(final float minimumValue, final int intValue) { - return ((float) intValue) / 100f + minimumValue; - } - - private static String getStepUpPercentString(final float percent) { + @NonNull + private static String getStepUpPercentString(final double percent) { return STEP_UP_SIGN + PlayerHelper.formatPitch(percent); } - private static String getStepDownPercentString(final float percent) { + @NonNull + private static String getStepDownPercentString(final double percent) { return STEP_DOWN_SIGN + PlayerHelper.formatPitch(percent); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index b34cec724..63ac7e8a1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -60,11 +60,11 @@ public class PlayerHelper { : stringFormatter.format("%02d:%02d", minutes, seconds).toString(); } - public static String formatSpeed(float speed) { + public static String formatSpeed(double speed) { return speedFormatter.format(speed); } - public static String formatPitch(float pitch) { + public static String formatPitch(double pitch) { return pitchFormatter.format(pitch); } diff --git a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java new file mode 100644 index 000000000..efec1abb0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java @@ -0,0 +1,73 @@ +package org.schabi.newpipe.util; + +public interface SliderStrategy { + /** + * Converts from zeroed double with a minimum offset to the nearest rounded slider + * equivalent integer + * */ + int progressOf(final double value); + + /** + * Converts from slider integer value to an equivalent double value with a given + * minimum offset + * */ + double valueOf(final int progress); + + // TODO: also implement linear strategy when needed + + final class Quadratic implements SliderStrategy { + private final double leftGap; + private final double rightGap; + private final double center; + + private final int centerProgress; + + /** + * Quadratic slider strategy that scales the value of a slider given how far the slider + * progress is from the center of the slider. The further away from the center, + * the faster the interpreted value changes, and vice versa. + * + * @param minimum the minimum value of the interpreted value of the slider. + * @param maximum the maximum value of the interpreted value of the slider. + * @param center center of the interpreted value between the minimum and maximum, which + * will be used as the center value on the slider progress. Doesn't need + * to be the average of the minimum and maximum values, but must be in + * between the two. + * @param maxProgress the maximum possible progress of the slider, this is the + * value that is shown for the UI and controls the granularity of + * the slider. Should be as large as possible to avoid floating + * point round-off error. Using odd number is recommended. + * */ + public Quadratic(double minimum, double maximum, double center, int maxProgress) { + if (center < minimum || center > maximum) { + throw new IllegalArgumentException("Center must be in between minimum and maximum"); + } + + this.leftGap = minimum - center; + this.rightGap = maximum - center; + this.center = center; + + this.centerProgress = maxProgress / 2; + } + + @Override + public int progressOf(double value) { + final double difference = value - center; + final double root = difference >= 0 ? + Math.sqrt(difference / rightGap) : + -Math.sqrt(Math.abs(difference / leftGap)); + final double offset = Math.round(root * centerProgress); + + return (int) (centerProgress + offset); + } + + @Override + public double valueOf(int progress) { + final int offset = progress - centerProgress; + final double square = Math.pow(((double) offset) / ((double) centerProgress), 2); + final double difference = square * (offset >= 0 ? rightGap : leftGap); + + return difference + center; + } + } +} diff --git a/app/src/test/java/org/schabi/newpipe/util/QuadraticSliderStrategyTest.java b/app/src/test/java/org/schabi/newpipe/util/QuadraticSliderStrategyTest.java new file mode 100644 index 000000000..8c8d52043 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/util/QuadraticSliderStrategyTest.java @@ -0,0 +1,86 @@ +package org.schabi.newpipe.util; + +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class QuadraticSliderStrategyTest { + private final static int STEP = 100; + private final static float DELTA = 1f / (float) STEP; + + private final SliderStrategy.Quadratic standard = + new SliderStrategy.Quadratic(0f, 100f, 50f, STEP); + @Test + public void testLeftBound() throws Exception { + assertEquals(standard.progressOf(0), 0); + assertEquals(standard.valueOf(0), 0f, DELTA); + } + + @Test + public void testCenter() throws Exception { + assertEquals(standard.progressOf(50), 50); + assertEquals(standard.valueOf(50), 50f, DELTA); + } + + @Test + public void testRightBound() throws Exception { + assertEquals(standard.progressOf(100), 100); + assertEquals(standard.valueOf(100), 100f, DELTA); + } + + @Test + public void testLeftRegion() throws Exception { + final int leftProgress = standard.progressOf(25); + final double leftValue = standard.valueOf(25); + assertTrue(leftProgress > 0 && leftProgress < 50); + assertTrue(leftValue > 0f && leftValue < 50); + } + + @Test + public void testRightRegion() throws Exception { + final int leftProgress = standard.progressOf(75); + final double leftValue = standard.valueOf(75); + assertTrue(leftProgress > 50 && leftProgress < 100); + assertTrue(leftValue > 50f && leftValue < 100); + } + + @Test + public void testConversion() throws Exception { + assertEquals(standard.progressOf(standard.valueOf(0)), 0); + assertEquals(standard.progressOf(standard.valueOf(25)), 25); + assertEquals(standard.progressOf(standard.valueOf(50)), 50); + assertEquals(standard.progressOf(standard.valueOf(75)), 75); + assertEquals(standard.progressOf(standard.valueOf(100)), 100); + } + + @Test + public void testReverseConversion() throws Exception { + // Need a larger delta since step size / granularity is too small and causes + // floating point round-off errors during conversion + final float largeDelta = 1f; + + assertEquals(standard.valueOf(standard.progressOf(0)), 0f, largeDelta); + assertEquals(standard.valueOf(standard.progressOf(25)), 25f, largeDelta); + assertEquals(standard.valueOf(standard.progressOf(50)), 50f, largeDelta); + assertEquals(standard.valueOf(standard.progressOf(75)), 75f, largeDelta); + assertEquals(standard.valueOf(standard.progressOf(100)), 100f, largeDelta); + } + + @Test + public void testQuadraticPropertyLeftRegion() throws Exception { + final double differenceCloserToCenter = + Math.abs(standard.valueOf(40) - standard.valueOf(45)); + final double differenceFurtherFromCenter = + Math.abs(standard.valueOf(10) - standard.valueOf(15)); + assertTrue(differenceCloserToCenter < differenceFurtherFromCenter); + } + + @Test + public void testQuadraticPropertyRightRegion() throws Exception { + final double differenceCloserToCenter = + Math.abs(standard.valueOf(75) - standard.valueOf(70)); + final double differenceFurtherFromCenter = + Math.abs(standard.valueOf(95) - standard.valueOf(90)); + assertTrue(differenceCloserToCenter < differenceFurtherFromCenter); + } +}