Skip to content

Commit

Permalink
Add Playback speed controls (#1302)
Browse files Browse the repository at this point in the history
* Add basic mocks to unit test PlaybackController

Adds the various basic mocks required to unit test Playback Controller.

At a high level the steps we perform are:
- Prep mocks for deps injected by constructor
- Prep mocks for Koin, then pass these as singletons
- Patch up various calls out to Android SharedPrefs ...etc.

This allows us to construct a PlaybackController in a JVM context now.
These mechanisms will be useful for later in the patch series too....

* Add setPlayback impl and tests to controller/view

Adds setPlaybackSpeed to the view and controller.
The controller has associated tests which built on the earlier ones.

Unfortunately the view also has logic in it and I've tried to (ab)use
mocks to factor out the ExoPlayer factory. However because it's a static
final the mock will always defer to the real impl.

I could introduce some sort of interface and abstract out the calls to
the underlying players using an adapter pattern so we can test this. But
alas, the player rewrite will obselete this and I'm 8+ hours into
getting tests to work...

* Add VideoSpeedController and tests

Adds a new controller for VideoSpeed, to help encapsulate items
following SRP.
This also adds a mechanism to track the users most recent speed
selection, so switching from videos / to other media and back will
restore the user's selection on the next video.

* Switch to using Enums to representing speed

This makes it much easier to compare for equality and has built in
indexing making the menu much easier to work with.

In comparison using doubles directly would have required us to add a
comparison lambda in the view to correctly detect what was selected /
determine what is selected.

* Wire up view for Playback speed controls

Wires up the view component for playback speed controls, based on the
pattern used in various other action items.

* Preserve playback speed between videos

- Update the PlaybackController to internally track the last playback
speed, but still treat it as a black box from all external callers
- Add a test we don't throw a NPE trying to set it before init
- There's no test for checking this value gets forwarded (see below)
- Change the activity to create the speed controller whilst the
ControlGlue is being created.

This is unfortunately a bit of a hack. There isn't a signal for
onPlaybackStarted or similar we can attach to from the playback
controller so we have to mess with the internals. Something I've tried
to avoid greatly.

I've tried adding a unit test with mocks, which was surprisingly going
well. Until we create LibVLC objects mid-way. These needs some sort of
factory pattern to inject mocks else it will query Android for playback
capabilities failing the test.

I'm going to leave this particular test as a manual case as I extracting
factories out with the pending rewrite is a futile effort.

* Fix various nits / warnings from CI

This includes:
- Using magic values in our enum
- Impliclt Locale issues
- Nullability checks

* Port drawable from Exoplayer into drawables

Ports the playback speed drawable into the projects list of drawable
items, following PR feedback.

The license is preserved (Apache 2.0) and compatible with the GPL
license of this project.

* Apply various code improvements to PlaybackSpeedAction

Includes the following from the PR review:
- Whitespace tidy up
- Implicitly return from lambda instead of explicitly naming it
- Use apply to remove a significant amount of boilerplate

* Add check that setPlaybackSpeed is sane

This will ignore any values that are clearly incorrect i.e. <0.25.

This means the playback controller can still have a sentinel value of
-1.0 to ensure the model controlling speed is in-place correctly. Whilst
not breaking user playback by default if something goes wrong.

As this file directly interfaces with the video manager (which holds
surfaces) we can't easily make a test for it, so leave this block
untested

* DI interface instead of concrete type

Inject the interface for shared preferences rather than the concrete
type.
This avoids Mockk trying to grab the abstract class underneath, which
would run the real impl instead. It also brings our DI in this file more
in-line with the commonly used pattern

* Address PR comments

- Revert some noisy automatic formatting
- Move speed steps out of companion object
- Drop supuerfluous interface

* Tidy up VideoSpeedController from PR review

- Use Kotlin a kotlin setter directly
- Make the variable public
- (Small amount of rewiring to make everything work)

* Drop PlaybackController tests and revert DI

Drop PlaybackController test. We can't simply inject by interface as
this isn't type safe with how it's currently implemented.

Since it's likely PlaybackController is going to undergo a major rework
with pending future work, let's simply drop the tests and revert the
associated changes.
  • Loading branch information
DavidFair authored Jan 12, 2022
1 parent 483ad96 commit 17ba482
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import android.view.Display;
import android.view.WindowManager;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.jellyfin.androidtv.R;
Expand All @@ -19,6 +20,7 @@
import org.jellyfin.androidtv.data.compat.SubtitleStreamInfo;
import org.jellyfin.androidtv.data.compat.VideoOptions;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.preference.PreferenceStore;
import org.jellyfin.androidtv.preference.SystemPreferences;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.UserSettingPreferences;
Expand Down Expand Up @@ -86,6 +88,7 @@ public class PlaybackController {
private VideoOptions mCurrentOptions;
private int mDefaultSubIndex = -1;
private int mDefaultAudioIndex = -1;
private double mRequestedPlaybackSpeed = -1.0;

private PlayMethod mPlaybackMethod = PlayMethod.Transcode;

Expand Down Expand Up @@ -150,10 +153,17 @@ public PlayMethod getPlaybackMethod() {
return mPlaybackMethod;
}

public void setPlaybackMethod(PlayMethod value) {
public void setPlaybackMethod(@NonNull PlayMethod value) {
mPlaybackMethod = value;
}

public void setPlaybackSpeed(@NonNull Double speed) {
mRequestedPlaybackSpeed = speed;
if (hasInitializedVideoManager()) {
mVideoManager.setPlaybackSpeed(speed);
}
}

public BaseItemDto getCurrentlyPlayingItem() {
return mItems.size() > mCurrentIndex ? mItems.get(mCurrentIndex) : null;
}
Expand Down Expand Up @@ -719,6 +729,7 @@ private void startItem(BaseItemDto item, long position, StreamInfo response) {

// get subtitle info
mSubtitleStreams = response.GetSubtitleProfiles(false, apiClient.getValue().getApiUrl(), apiClient.getValue().getAccessToken());
mVideoManager.setPlaybackSpeed(mRequestedPlaybackSpeed);

if (mFragment != null) mFragment.updateDisplay();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import android.view.ViewGroup;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.google.android.exoplayer2.DefaultRenderersFactory;
Expand Down Expand Up @@ -160,8 +161,13 @@ public void setNativeMode(boolean value) {
}
}

public boolean isNativeMode() { return nativeMode; }
public int getZoomMode() { return mZoomMode; }
public boolean isNativeMode() {
return nativeMode;
}

public int getZoomMode() {
return mZoomMode;
}

public void setZoom(int mode) {
mZoomMode = mode;
Expand Down Expand Up @@ -192,7 +198,7 @@ public void setMetaDuration(long duration) {
}

public long getDuration() {
if (nativeMode){
if (nativeMode) {
return mExoPlayer.getDuration() > 0 ? mExoPlayer.getDuration() : mMetaDuration;
} else {
return mVlcPlayer.getLength() > 0 ? mVlcPlayer.getLength() : mMetaDuration;
Expand Down Expand Up @@ -302,13 +308,14 @@ public long seekTo(long pos) {
mLastTime = mVlcPlayer.getTime();
Timber.i("VLC length in seek is: %d", mVlcPlayer.getLength());
try {
if (getDuration() > 0) mVlcPlayer.setPosition((float)pos / getDuration()); else mVlcPlayer.setTime(pos);
if (getDuration() > 0) mVlcPlayer.setPosition((float) pos / getDuration());
else mVlcPlayer.setTime(pos);

return pos;

} catch (Exception e) {
Timber.e(e, "Error seeking in VLC");
Utils.showToast(mActivity, mActivity.getString(R.string.seek_error));
Utils.showToast(mActivity, mActivity.getString(R.string.seek_error));
return -1;
}
}
Expand Down Expand Up @@ -366,7 +373,7 @@ public boolean setSubtitleTrack(int index, @Nullable List<MediaStream> allStream
} catch (IndexOutOfBoundsException e) {
Timber.e("Could not locate subtitle with index %s in vlc track info", index);
return false;
} catch (NullPointerException e){
} catch (NullPointerException e) {
Timber.e("No subtitle tracks found in player trying to set subtitle with index %s in vlc track info", index);
return false;
}
Expand Down Expand Up @@ -404,7 +411,7 @@ public void setAudioTrack(int ndx, List<MediaStream> allStreams) {
Timber.e("Could not locate audio with index %s in vlc track info", ndx);
mVlcPlayer.setAudioTrack(ndx);
return;
} catch (NullPointerException e){
} catch (NullPointerException e) {
Timber.e("No subtitle tracks found in player trying to set subtitle with index %s in vlc track info", ndx);
mVlcPlayer.setAudioTrack(vlcIndex);
return;
Expand All @@ -426,6 +433,19 @@ public void setAudioTrack(int ndx, List<MediaStream> allStreams) {
}
}

public void setPlaybackSpeed(@NonNull Double speed) {
if (speed < 0.25) {
Timber.w("Invalid playback speed requested: %d", speed);
return;
}

if (nativeMode) {
mExoPlayer.setPlaybackSpeed(speed.floatValue());
} else {
mVlcPlayer.setRate(speed.floatValue());
}
}

public void setAudioDelay(long value) {
if (!nativeMode && mVlcPlayer != null) {
if (!mVlcPlayer.setAudioDelay(value * 1000)) {
Expand Down Expand Up @@ -454,7 +474,7 @@ public void setAudioMode() {
}

private void setVlcAudioOptions() {
if(!Utils.downMixAudio()) {
if (!Utils.downMixAudio()) {
mVlcPlayer.setAudioDigitalOutputEnabled(true);
} else {
setCompatibleAudio();
Expand Down Expand Up @@ -561,7 +581,7 @@ public void contractVideo(int height) {
Activity activity = TvApp.getApplication().getCurrentActivity();
int sw = activity.getWindow().getDecorView().getWidth();
int sh = activity.getWindow().getDecorView().getHeight();
float ar = (float)sw / sh;
float ar = (float) sw / sh;
lp.height = height;
lp.width = (int) Math.ceil(height * ar);
lp.rightMargin = ((lp.width - normalWidth) / 2) - 110;
Expand Down Expand Up @@ -627,10 +647,10 @@ private void changeSurfaceLayout(int videoWidth, int videoHeight, int videoVisib
double ar;
if (sarDen == sarNum) {
/* No indication about the density, assuming 1:1 */
ar = (double)videoVisibleWidth / (double)videoVisibleHeight;
ar = (double) videoVisibleWidth / (double) videoVisibleHeight;
} else {
/* Use the specified aspect ratio */
double vw = videoVisibleWidth * (double)sarNum / sarDen;
double vw = videoVisibleWidth * (double) sarNum / sarDen;
ar = vw / videoVisibleHeight;
}

Expand All @@ -644,7 +664,7 @@ private void changeSurfaceLayout(int videoWidth, int videoHeight, int videoVisib

// set display size
ViewGroup.LayoutParams lp = mSurfaceView.getLayoutParams();
lp.width = (int) Math.ceil(dw * videoWidth / videoVisibleWidth);
lp.width = (int) Math.ceil(dw * videoWidth / videoVisibleWidth);
lp.height = (int) Math.ceil(dh * videoHeight / videoVisibleHeight);
normalWidth = lp.width;
normalHeight = lp.height;
Expand Down Expand Up @@ -688,6 +708,7 @@ public void setOnProgressListener(PlaybackListener listener) {

private PlaybackListener progressListener;
private Runnable progressLoop;

private void startProgressLoop() {
progressLoop = new Runnable() {
@Override
Expand Down Expand Up @@ -733,7 +754,7 @@ public void onNewVideoLayout(IVLCVout vout, int width, int height, int visibleWi
mVideoHeight = height;
mVideoWidth = width;
mVideoVisibleHeight = visibleHeight;
mVideoVisibleWidth = visibleWidth;
mVideoVisibleWidth = visibleWidth;
mSarNum = sarNum;
mSarDen = sarDen;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.jellyfin.androidtv.ui.playback

class VideoSpeedController(
private val parentController: PlaybackController
) {
enum class SpeedSteps(val speed: Double) {
// Use named parameter so detekt knows these aren't magic values
SPEED_0_25(speed = 0.25),
SPEED_0_50(speed = 0.5),
SPEED_0_75(speed = 0.75),
SPEED_1_00(speed = 1.0),
SPEED_1_25(speed = 1.25),
SPEED_1_50(speed = 1.50),
SPEED_1_75(speed = 1.75),
SPEED_2_00(speed = 2.0),
}

companion object {
// Preserve the currently selected speed during the app lifetime, even if
// video playback closes
private var previousSpeedSelection = SpeedSteps.SPEED_1_00
}

var currentSpeed = previousSpeedSelection
set(value) {
parentController.setPlaybackSpeed(value.speed)
previousSpeedSelection = value
field = value
}

init {
// We need to do this again in init, as Kotlin will not call the custom
// setter on initialization, so the PlaybackController is not informed
currentSpeed = previousSpeedSelection
}

fun resetSpeedToDefault() {
currentSpeed = SpeedSteps.SPEED_1_00
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.jellyfin.androidtv.ui.playback.overlay.action.ChapterAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.ClosedCaptionsAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.GuideAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.PlaybackSpeedAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.PreviousLiveTvChannelAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.RecordAction;
import org.jellyfin.androidtv.ui.playback.overlay.action.SelectAudioAction;
Expand All @@ -48,6 +49,7 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
private PlaybackControlsRow.SkipNextAction skipNextAction;
private SelectAudioAction selectAudioAction;
private ClosedCaptionsAction closedCaptionsAction;
private PlaybackSpeedAction playbackSpeedAction;
private AdjustAudioDelayAction adjustAudioDelayAction;
private ZoomAction zoomAction;
private ChapterAction chapterAction;
Expand Down Expand Up @@ -173,6 +175,8 @@ private void initActions(Context context) {
selectAudioAction.setLabels(new String[]{context.getString(R.string.lbl_audio_track)});
closedCaptionsAction = new ClosedCaptionsAction(context, this);
closedCaptionsAction.setLabels(new String[]{context.getString(R.string.lbl_subtitle_track)});
playbackSpeedAction = new PlaybackSpeedAction(context, this, playbackController);
playbackSpeedAction.setLabels(new String[]{context.getString(R.string.lbl_playback_speed)});
adjustAudioDelayAction = new AdjustAudioDelayAction(context, this);
adjustAudioDelayAction.setLabels(new String[]{context.getString(R.string.lbl_audio_delay)});
zoomAction = new ZoomAction(context, this);
Expand Down Expand Up @@ -219,6 +223,8 @@ void addMediaActions() {
primaryActionsAdapter.add(closedCaptionsAction);
}

primaryActionsAdapter.add(playbackSpeedAction);

if (hasMultiAudio()) {
primaryActionsAdapter.add(selectAudioAction);
}
Expand Down Expand Up @@ -282,6 +288,9 @@ public void onCustomActionClicked(Action action, View view) {
} else if (action == closedCaptionsAction) {
leanbackOverlayFragment.setFading(false);
closedCaptionsAction.handleClickAction(playbackController, leanbackOverlayFragment, getContext(), view);
} else if (action == playbackSpeedAction) {
leanbackOverlayFragment.setFading(false);
playbackSpeedAction.handleClickAction(playbackController, leanbackOverlayFragment, getContext(), view);
} else if (action == adjustAudioDelayAction) {
leanbackOverlayFragment.setFading(false);
adjustAudioDelayAction.handleClickAction(playbackController, leanbackOverlayFragment, getContext(), view);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.jellyfin.androidtv.ui.playback.overlay.action

import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.VideoSpeedController
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import java.util.*

class PlaybackSpeedAction(
context: Context,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
playbackController: PlaybackController
) : CustomAction(context, customPlaybackTransportControlGlue) {
private val speedController = VideoSpeedController(playbackController)
private val speeds = VideoSpeedController.SpeedSteps.values()

init {
initializeWithIcon(R.drawable.ic_playback_speed)
}

override fun handleClickAction(
playbackController: PlaybackController,
leanbackOverlayFragment: LeanbackOverlayFragment,
context: Context, view: View
) {
val speedMenu = populateMenu(context, view, speedController)

speedMenu.setOnDismissListener { leanbackOverlayFragment.setFading(true) }

speedMenu.setOnMenuItemClickListener { menuItem ->
speedController.currentSpeed = speeds[menuItem.itemId]
speedMenu.dismiss()
true
}

speedMenu.show()
}

private fun populateMenu(
context: Context,
view: View,
speedController: VideoSpeedController
) = PopupMenu(context, view, Gravity.END).apply {
speeds.forEachIndexed { i, selected ->
// Since this is purely numeric data, coerce to en_us to keep the linter happy
menu.add(0, i, i, String.format(Locale.US, "%.2fx", selected.speed))
}

menu.setGroupCheckable(0, true, true)
menu.getItem(speeds.indexOf(speedController.currentSpeed)).isChecked = true
}

}
25 changes: 25 additions & 0 deletions app/src/main/res/drawable/ic_playback_speed.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M13.05,9.79L10,7.5v9l3.05,-2.29L16,12zM13.05,9.79L10,7.5v9l3.05,-2.29L16,12zM13.05,9.79L10,7.5v9l3.05,-2.29L16,12zM11,4.07L11,2.05c-2.01,0.2 -3.84,1 -5.32,2.21L7.1,5.69c1.11,-0.86 2.44,-1.44 3.9,-1.62zM5.69,7.1L4.26,5.68C3.05,7.16 2.25,8.99 2.05,11h2.02c0.18,-1.46 0.76,-2.79 1.62,-3.9zM4.07,13L2.05,13c0.2,2.01 1,3.84 2.21,5.32l1.43,-1.43c-0.86,-1.1 -1.44,-2.43 -1.62,-3.89zM5.68,19.74C7.16,20.95 9,21.75 11,21.95v-2.02c-1.46,-0.18 -2.79,-0.76 -3.9,-1.62l-1.42,1.43zM22,12c0,5.16 -3.92,9.42 -8.95,9.95v-2.02C16.97,19.41 20,16.05 20,12s-3.03,-7.41 -6.95,-7.93L13.05,2.05C18.08,2.58 22,6.84 22,12z"
android:fillColor="#FFFFFF"/>
</vector>
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
<string name="lbl_stretch">Stretch</string>
<string name="lbl_fit">Normal</string>
<string name="lbl_audio_track">Select audio track</string>
<string name="lbl_playback_speed">Playback Speed</string>
<string name="lbl_subtitle_track">Select subtitle track</string>
<string name="msg_external_path">This feature will only work if you have properly set up your library on the server with network paths or path substitution and the client you are using can directly access these locations over the network.</string>
<string name="btn_got_it">Got it</string>
Expand Down
Loading

0 comments on commit 17ba482

Please sign in to comment.