Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050110922c | ||
|
|
f97db4643e | ||
|
|
1715dc02b7 | ||
|
|
23c8b42e79 | ||
|
|
3ddcf4d284 | ||
|
|
6a97e9b05b | ||
|
|
6af1df5122 | ||
|
|
68f0edca71 | ||
|
|
f5fda8c7de | ||
|
|
9ea2b81b28 | ||
|
|
c568862ee1 | ||
|
|
42b767f619 | ||
|
|
ebf7fc4244 | ||
|
|
102642d58f | ||
|
|
5115e1181e | ||
|
|
f70ac357b6 | ||
|
|
7136341c3c | ||
|
|
9b49af4c42 | ||
|
|
fa59595dd5 | ||
|
|
7c3f495036 | ||
|
|
d05ef3d810 | ||
|
|
f5c875959a | ||
|
|
b45700739a | ||
|
|
a0430a13d1 | ||
|
|
9dfe855940 | ||
|
|
d09c45f74f | ||
|
|
16d6b6b2ed | ||
|
|
75e8938db3 | ||
|
|
dff36669bf | ||
|
|
1953508209 |
5
.github/workflows/app-build.yaml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -13,7 +16,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
|
||||
10
.github/workflows/app-lint.yaml
vendored
@@ -7,14 +7,20 @@ on:
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
@@ -22,7 +28,7 @@ jobs:
|
||||
- name: Run detekt and lint task
|
||||
run: ./gradlew --build-cache --no-daemon --info detekt lint
|
||||
- name: Upload SARIF files
|
||||
uses: github/codeql-action/upload-sarif@v1
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: .
|
||||
|
||||
4
.github/workflows/app-publish.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Upload release archive to GitHub release
|
||||
uses: alexellis/upload-assets@0.3.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
with:
|
||||
asset_paths: '["build/jellyfin-publish/*"]'
|
||||
- name: Upload release archive to repo.jellyfin.org
|
||||
|
||||
5
.github/workflows/app-test.yaml
vendored
@@ -7,6 +7,9 @@ on:
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -14,7 +17,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: adopt
|
||||
java-version: 11
|
||||
|
||||
3
.github/workflows/gradlew-validate.yaml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
paths:
|
||||
- '**/gradlе-wrapper.jar'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
<!-- Generic permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_WIFI_STATE"
|
||||
tools:ignore="LeanbackUsesWifi" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<!-- Device feature requirements -->
|
||||
@@ -38,15 +40,16 @@
|
||||
<application
|
||||
android:name=".JellyfinApplication"
|
||||
android:allowBackup="true"
|
||||
android:appCategory="video"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:icon="@drawable/app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:roundIcon="@drawable/app_icon_round"
|
||||
android:theme="@style/Theme.Jellyfin"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name=".auth.AuthenticatorService"
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
|
||||
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.defaultAudioBehavior
|
||||
import org.jellyfin.androidtv.util.DeviceUtils
|
||||
@@ -90,9 +91,9 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
var videoPlayer = Preference.enum("video_player", PreferredVideoPlayer.EXOPLAYER)
|
||||
|
||||
/**
|
||||
* Enable refresh rate switching when device supports it
|
||||
* Change refresh rate to match media when device supports it
|
||||
*/
|
||||
var refreshRateSwitchingEnabled = Preference.boolean("pref_refresh_switching", false)
|
||||
var refreshRateSwitchingBehavior = Preference.enum("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
|
||||
|
||||
/**
|
||||
* Send a path instead to the external player
|
||||
@@ -230,6 +231,15 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||
// Disable AC3 (Dolby Digital) on Fire Stick Gen 1 devices
|
||||
if (DeviceUtils.isFireTvStickGen1()) putBoolean("pref_bitstream_ac3", false)
|
||||
}
|
||||
|
||||
// v0.13.3 to v0.13.4
|
||||
migration(toVersion = 6) {
|
||||
putEnum("refresh_rate_switching_behavior",
|
||||
when {
|
||||
it.getBoolean("pref_refresh_switching", false) -> RefreshRateSwitchingBehavior.SCALE_ON_TV
|
||||
else -> RefreshRateSwitchingBehavior.DISABLED
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.jellyfin.androidtv.preference.constant
|
||||
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.EnumDisplayOptions
|
||||
|
||||
enum class RefreshRateSwitchingBehavior {
|
||||
@EnumDisplayOptions(R.string.state_disabled)
|
||||
DISABLED,
|
||||
|
||||
/**
|
||||
* When comparing modes, use difference in resolution to rank modes.
|
||||
*/
|
||||
@EnumDisplayOptions(R.string.pref_refresh_rate_scale_on_tv)
|
||||
SCALE_ON_TV,
|
||||
|
||||
/**
|
||||
* When comparing modes, rank native resolution modes highest.
|
||||
* Otherwise use difference in resolution to rank modes.
|
||||
*/
|
||||
@EnumDisplayOptions(R.string.pref_refresh_rate_scale_on_device)
|
||||
SCALE_ON_DEVICE,
|
||||
}
|
||||
@@ -44,10 +44,12 @@ class ExpandableTextView(context: Context, attrs: AttributeSet?) : AppCompatText
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
|
||||
isFocusable = !text.isNullOrBlank()
|
||||
isClickable = !text.isNullOrBlank()
|
||||
// Only make focusable when text is ellipsized.
|
||||
val isEllipsized = !text.isNullOrBlank() && lineCount > 0 && layout.getEllipsisCount(lineCount - 1) > 0
|
||||
isFocusable = isEllipsized
|
||||
isClickable = isEllipsized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.view.KeyEvent;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.PopupMenu;
|
||||
@@ -622,7 +623,7 @@ public class ItemListActivity extends FragmentActivity {
|
||||
@Override
|
||||
public void onResponse(UserItemDataDto response) {
|
||||
mBaseItem.setUserData(response);
|
||||
((TextUnderButton)v).setImageResource(response.getIsFavorite() ? R.drawable.ic_heart_red : R.drawable.ic_heart);
|
||||
((ImageButton)v).setImageResource(response.getIsFavorite() ? R.drawable.ic_heart_red : R.drawable.ic_heart);
|
||||
dataRefreshService.getValue().setLastFavoriteUpdate(System.currentTimeMillis());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1258,7 +1258,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void setCurrentTime(long time) {
|
||||
leanbackOverlayFragment.updateCurrentPosition();
|
||||
if (leanbackOverlayFragment != null)
|
||||
leanbackOverlayFragment.updateCurrentPosition();
|
||||
}
|
||||
|
||||
public void setSecondaryTime(long time) {
|
||||
@@ -1482,6 +1483,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// Encode whitespace as html entities
|
||||
final String htmlText = text
|
||||
.replaceAll("\\r\\n", "<br>")
|
||||
.replaceAll("\\n", "<br>")
|
||||
.replaceAll("\\\\h", " ");
|
||||
|
||||
final SpannableString span = new SpannableString(TextUtilsKt.toHtmlSpanned(htmlText));
|
||||
|
||||
@@ -25,11 +25,11 @@ import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.preference.UserSettingPreferences;
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior;
|
||||
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer;
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
|
||||
import org.jellyfin.androidtv.ui.livetv.TvManager;
|
||||
import org.jellyfin.androidtv.util.DeviceUtils;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
import org.jellyfin.androidtv.util.Utils;
|
||||
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper;
|
||||
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
|
||||
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
|
||||
import org.jellyfin.androidtv.util.profile.BaseProfile;
|
||||
@@ -88,7 +88,7 @@ public class PlaybackController {
|
||||
private int mDefaultSubIndex = -1;
|
||||
private int mDefaultAudioIndex = -1;
|
||||
private boolean burningSubs = false;
|
||||
private double mRequestedPlaybackSpeed = -1.0;
|
||||
private float mRequestedPlaybackSpeed = -1.0f;
|
||||
|
||||
private PlayMethod mPlaybackMethod = PlayMethod.Transcode;
|
||||
|
||||
@@ -116,15 +116,18 @@ public class PlaybackController {
|
||||
private long lastPlaybackError = 0;
|
||||
|
||||
private Display.Mode[] mDisplayModes;
|
||||
private boolean refreshRateSwitchingEnabled;
|
||||
private RefreshRateSwitchingBehavior refreshRateSwitchingBehavior = RefreshRateSwitchingBehavior.DISABLED;
|
||||
|
||||
public PlaybackController(List<BaseItemDto> items, CustomPlaybackOverlayFragment fragment) {
|
||||
mItems = items;
|
||||
mFragment = fragment;
|
||||
mHandler = new Handler();
|
||||
|
||||
refreshRateSwitchingEnabled = DeviceUtils.is60() && userPreferences.getValue().get(UserPreferences.Companion.getRefreshRateSwitchingEnabled());
|
||||
if (refreshRateSwitchingEnabled) getDisplayModes();
|
||||
if (DeviceUtils.is60()) {
|
||||
refreshRateSwitchingBehavior = userPreferences.getValue().get(UserPreferences.Companion.getRefreshRateSwitchingBehavior());
|
||||
if (refreshRateSwitchingBehavior != RefreshRateSwitchingBehavior.DISABLED)
|
||||
getDisplayModes();
|
||||
}
|
||||
|
||||
// Set default value for useVlc field
|
||||
// when set to auto the default will be exoplayer
|
||||
@@ -163,7 +166,17 @@ public class PlaybackController {
|
||||
mPlaybackMethod = value;
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Double speed) {
|
||||
public float getPlaybackSpeed() {
|
||||
if (hasInitializedVideoManager()) {
|
||||
// Actually poll the video manager, since exoplayer can revert back
|
||||
// to 1x if it can't go faster, so we should check directly
|
||||
return mVideoManager.getPlaybackSpeed();
|
||||
} else {
|
||||
return mRequestedPlaybackSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull float speed) {
|
||||
mRequestedPlaybackSpeed = speed;
|
||||
if (hasInitializedVideoManager()) {
|
||||
mVideoManager.setPlaybackSpeed(speed);
|
||||
@@ -205,7 +218,7 @@ public class PlaybackController {
|
||||
}
|
||||
|
||||
public int getSubtitleStreamIndex() {
|
||||
return (mCurrentOptions != null && mCurrentOptions.getSubtitleStreamIndex() != null) ? mCurrentOptions.getSubtitleStreamIndex() : mDefaultSubIndex;
|
||||
return (mCurrentOptions != null && mCurrentOptions.getSubtitleStreamIndex() != null) ? mCurrentOptions.getSubtitleStreamIndex() : -1;
|
||||
}
|
||||
|
||||
public List<SubtitleStreamInfo> getSubtitleStreams() {
|
||||
@@ -281,23 +294,31 @@ public class PlaybackController {
|
||||
|
||||
@TargetApi(23)
|
||||
private void getDisplayModes() {
|
||||
if (mFragment == null)
|
||||
return;
|
||||
Display display = mFragment.requireActivity().getWindowManager().getDefaultDisplay();
|
||||
mDisplayModes = display.getSupportedModes();
|
||||
Timber.i("** Available display refresh rates:");
|
||||
for (Display.Mode mDisplayMode : mDisplayModes) {
|
||||
Timber.i("%f", mDisplayMode.getRefreshRate());
|
||||
Timber.d("display mode %s - %dx%d@%f", mDisplayMode.getModeId(), mDisplayMode.getPhysicalWidth(), mDisplayMode.getPhysicalHeight(), mDisplayMode.getRefreshRate());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@TargetApi(23)
|
||||
private Display.Mode findBestDisplayMode(MediaStream videoStream) {
|
||||
if (mDisplayModes == null || videoStream.getRealFrameRate() == null) return null;
|
||||
if (mFragment == null || mDisplayModes == null || videoStream.getRealFrameRate() == null) return null;
|
||||
|
||||
|
||||
int curWeight = 0;
|
||||
Display.Mode bestMode = null;
|
||||
int sourceRate = Math.round(videoStream.getRealFrameRate() * 100);
|
||||
|
||||
Display.Mode defaultMode = mFragment.requireActivity().getWindowManager().getDefaultDisplay().getMode();
|
||||
|
||||
Timber.d("trying to find display mode for video: %dx%d@%f", videoStream.getWidth(), videoStream.getHeight(), videoStream.getRealFrameRate());
|
||||
for (Display.Mode mode : mDisplayModes) {
|
||||
Timber.d("considering display mode: %s - %dx%d@%f", mode.getModeId(), mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate());
|
||||
|
||||
// Skip unwanted display modes
|
||||
if (mode.getPhysicalWidth() < 1280 || mode.getPhysicalHeight() < 720) // Skip non-HD
|
||||
continue;
|
||||
@@ -309,8 +330,28 @@ public class PlaybackController {
|
||||
if (rate != sourceRate && rate != sourceRate * 2 && rate != Math.round(sourceRate * 2.5)) // Skip inappropriate rates
|
||||
continue;
|
||||
|
||||
int weight = Math.round(rate * 10000) + (9999 - (mode.getPhysicalWidth() - videoStream.getWidth()));
|
||||
Timber.d("qualifying display mode: %s - %dx%d@%f", mode.getModeId(), mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate());
|
||||
|
||||
// if scaling on-device, keep native resolution modes at diff 0 (best score)
|
||||
// for other resolutions when scaling on device, or if scaling on tv, score based on distance from media resolution
|
||||
|
||||
// use -1 as the default so, with SCALE_ON_DEVICE, a mode at native resolution will rank higher than
|
||||
// a mode with equal refresh rate and the same resolution as the media
|
||||
int resolutionDifference = -1;
|
||||
if ((refreshRateSwitchingBehavior == RefreshRateSwitchingBehavior.SCALE_ON_DEVICE &&
|
||||
!(mode.getPhysicalWidth() == defaultMode.getPhysicalWidth() && mode.getPhysicalHeight() == defaultMode.getPhysicalHeight())) ||
|
||||
|
||||
refreshRateSwitchingBehavior == RefreshRateSwitchingBehavior.SCALE_ON_TV) {
|
||||
|
||||
resolutionDifference = Math.abs(mode.getPhysicalWidth() - videoStream.getWidth());
|
||||
}
|
||||
int refreshRateDifference = rate - sourceRate;
|
||||
|
||||
// use 100,000 to account for refresh rates 120Hz+ (at 120Hz rate == 12,000)
|
||||
int weight = 100000 - refreshRateDifference + 100000 - resolutionDifference;
|
||||
|
||||
if (weight > curWeight) {
|
||||
Timber.d("preferring mode: %s - %dx%d@%f", mode.getModeId(), mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate());
|
||||
curWeight = weight;
|
||||
bestMode = mode;
|
||||
}
|
||||
@@ -332,7 +373,8 @@ public class PlaybackController {
|
||||
Timber.i("*** Best refresh mode is: %s - %dx%d/%f",
|
||||
best.getModeId(), best.getPhysicalWidth(), best.getPhysicalHeight(), best.getRefreshRate());
|
||||
if (current.getModeId() != best.getModeId()) {
|
||||
Timber.i("*** Attempting to change refresh rate from %s/%s", current.getModeId(), current.getRefreshRate());
|
||||
Timber.i("*** Attempting to change refresh rate from: %s - %dx%d@%f", current.getModeId(), current.getPhysicalWidth(),
|
||||
current.getPhysicalHeight(),current.getRefreshRate());
|
||||
WindowManager.LayoutParams params = mFragment.requireActivity().getWindow().getAttributes();
|
||||
params.preferredDisplayModeId = best.getModeId();
|
||||
mFragment.requireActivity().getWindow().setAttributes(params);
|
||||
@@ -340,10 +382,8 @@ public class PlaybackController {
|
||||
Timber.i("Display is already in best mode");
|
||||
}
|
||||
} else {
|
||||
Timber.i("*** Unable to find display mode for refresh rate: %s", videoStream.getRealFrameRate());
|
||||
Timber.i("*** Unable to find display mode for refresh rate: %f", videoStream.getRealFrameRate());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// central place to update mCurrentPosition
|
||||
@@ -381,8 +421,8 @@ public class PlaybackController {
|
||||
play(position, null);
|
||||
}
|
||||
|
||||
private void play(long position, @Nullable Integer transcodedSubtitle) {
|
||||
Timber.d("Play called from state: %s with pos: %d and sub index: %d", mPlaybackState, position, transcodedSubtitle);
|
||||
private void play(long position, @Nullable Integer forcedSubtitleIndex) {
|
||||
Timber.d("Play called from state: %s with pos: %d and sub index: %d", mPlaybackState, position, forcedSubtitleIndex);
|
||||
|
||||
if (position < 0) {
|
||||
Timber.i("Negative start requested - adjusting to zero");
|
||||
@@ -491,8 +531,8 @@ public class PlaybackController {
|
||||
vlcOptions.setEnableDirectStream(false);
|
||||
vlcOptions.setEnableDirectPlay(false);
|
||||
}
|
||||
vlcOptions.setSubtitleStreamIndex(transcodedSubtitle);
|
||||
vlcOptions.setMediaSourceId(transcodedSubtitle != null ? getCurrentMediaSource().getId() : null);
|
||||
vlcOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
|
||||
vlcOptions.setMediaSourceId(forcedSubtitleIndex != null ? getCurrentMediaSource().getId() : null);
|
||||
DeviceProfile vlcProfile = new LibVlcProfile(isLiveTv);
|
||||
vlcOptions.setProfile(vlcProfile);
|
||||
|
||||
@@ -503,8 +543,8 @@ public class PlaybackController {
|
||||
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
|
||||
internalOptions.setEnableDirectStream(false);
|
||||
internalOptions.setMaxAudioChannels(Utils.downMixAudio() ? 2 : null); //have to downmix at server
|
||||
internalOptions.setSubtitleStreamIndex(transcodedSubtitle);
|
||||
internalOptions.setMediaSourceId(transcodedSubtitle != null ? getCurrentMediaSource().getId() : null);
|
||||
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
|
||||
internalOptions.setMediaSourceId(forcedSubtitleIndex != null ? getCurrentMediaSource().getId() : null);
|
||||
DeviceProfile internalProfile = new BaseProfile();
|
||||
if (DeviceUtils.is60() || userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled())) {
|
||||
boolean hlsSupported = false;
|
||||
@@ -649,15 +689,22 @@ public class PlaybackController {
|
||||
if (mVideoManager == null)
|
||||
return;
|
||||
mVideoManager.init(getBufferAmount(), useDeinterlacing);
|
||||
|
||||
Timber.d("server default: %s inferred first track: %s", internalResponse.getMediaSource().getDefaultAudioStreamIndex(), bestGuessAudioTrack(internalResponse.getMediaSource()));
|
||||
mCurrentOptions = useVlc ? vlcOptions : internalOptions;
|
||||
startItem(item, position, useVlc ? vlcResponse : internalResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception exception) {
|
||||
Timber.e(exception, "Unable to get internal stream info");
|
||||
Timber.e(exception, "Unable to get stream info for internal player - falling back to libVLC");
|
||||
if (mVideoManager == null)
|
||||
return;
|
||||
|
||||
boolean useDeinterlacing = vlcResponse.getMediaSource().getVideoStream() != null &&
|
||||
vlcResponse.getMediaSource().getVideoStream().getIsInterlaced() &&
|
||||
(vlcResponse.getMediaSource().getVideoStream().getWidth() == null ||
|
||||
vlcResponse.getMediaSource().getVideoStream().getWidth() > 1200);
|
||||
|
||||
mVideoManager.init(getBufferAmount(), useDeinterlacing);
|
||||
mCurrentOptions = vlcOptions;
|
||||
startItem(item, position, vlcResponse);
|
||||
}
|
||||
@@ -729,6 +776,8 @@ public class PlaybackController {
|
||||
mCurrentOptions.setSubtitleStreamIndex(mDefaultSubIndex);
|
||||
Timber.d("stream started with burnt in subs");
|
||||
burningSubs = true;
|
||||
} else {
|
||||
mCurrentOptions.setSubtitleStreamIndex(null);
|
||||
}
|
||||
|
||||
Long mbPos = position * 10000;
|
||||
@@ -747,13 +796,13 @@ public class PlaybackController {
|
||||
}
|
||||
|
||||
// set refresh rate
|
||||
if (refreshRateSwitchingEnabled) {
|
||||
if (refreshRateSwitchingBehavior != RefreshRateSwitchingBehavior.DISABLED) {
|
||||
setRefreshRate(response.getMediaSource().getVideoStream());
|
||||
}
|
||||
|
||||
// set playback speed to user selection, or 1 if we're watching live-tv
|
||||
if (mVideoManager != null)
|
||||
mVideoManager.setPlaybackSpeed(isLiveTv() ? 1.0 : mRequestedPlaybackSpeed);
|
||||
mVideoManager.setPlaybackSpeed(isLiveTv() ? 1.0f : mRequestedPlaybackSpeed);
|
||||
|
||||
if (mFragment != null) mFragment.updateDisplay();
|
||||
|
||||
@@ -882,20 +931,23 @@ public class PlaybackController {
|
||||
public void switchSubtitleStream(int index) {
|
||||
if (!hasInitializedVideoManager())
|
||||
return;
|
||||
|
||||
// get current timestamp first
|
||||
refreshCurrentPosition();
|
||||
Timber.d("Setting subtitle index to: %d", index);
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
|
||||
// clear subtitles first
|
||||
if (mFragment != null) mFragment.addManualSubtitles(null);
|
||||
mVideoManager.disableSubs();
|
||||
// clear the default in case there's an error loading the subtitles
|
||||
mDefaultSubIndex = -1;
|
||||
|
||||
// handle setting subtitles as disabled
|
||||
// restart playback if turning off burnt-in subtitles
|
||||
if (index < 0) {
|
||||
mCurrentOptions.setSubtitleStreamIndex(-1);
|
||||
if (burningSubs) {
|
||||
stop();
|
||||
play(mCurrentPosition, -1);
|
||||
} else {
|
||||
if (mFragment != null) mFragment.addManualSubtitles(null);
|
||||
mVideoManager.disableSubs();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -906,77 +958,77 @@ public class PlaybackController {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.subtitle_error));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// handle according to delivery method
|
||||
SubtitleStreamInfo streamInfo = getSubtitleStreamInfo(index);
|
||||
if (streamInfo == null) {
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
} else {
|
||||
switch (streamInfo.getDeliveryMethod()) {
|
||||
return;
|
||||
}
|
||||
|
||||
case Encode:
|
||||
// Gonna need to burn in so start a transcode with the sub index
|
||||
stop();
|
||||
if (!mVideoManager.isNativeMode()) {
|
||||
// handle switching on or between burnt-in subtitles, or switching to non-burnt subtitles
|
||||
// if switching from burnt-in subtitles to another type, playback still needs to be restarted
|
||||
if (burningSubs || streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode) {
|
||||
stop();
|
||||
if (mFragment != null && streamInfo.getDeliveryMethod() == SubtitleDeliveryMethod.Encode)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_burn_sub_warning));
|
||||
play(mCurrentPosition, index);
|
||||
return;
|
||||
}
|
||||
|
||||
// when burnt-in subtitles are selected, mCurrentOptions SubtitleStreamIndex is set in startItem() as soon as playback starts
|
||||
// otherwise mCurrentOptions SubtitleStreamIndex is kept null until now so we knew subtitles needed to be enabled but weren't already
|
||||
|
||||
switch (streamInfo.getDeliveryMethod()) {
|
||||
case Embed:
|
||||
if (!mVideoManager.isNativeMode()) {
|
||||
if (!mVideoManager.setSubtitleTrack(index, getCurrentlyPlayingItem().getMediaStreams())) {
|
||||
// error selecting internal subs
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_burn_sub_warning));
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
} else {
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
}
|
||||
play(mCurrentPosition, index);
|
||||
break;
|
||||
case Embed:
|
||||
if (!mVideoManager.isNativeMode()) {
|
||||
if (mFragment != null)
|
||||
mFragment.addManualSubtitles(null); // in case these were on
|
||||
if (!mVideoManager.setSubtitleTrack(index, getCurrentlyPlayingItem().getMediaStreams())) {
|
||||
// error selecting internal subs
|
||||
if (mFragment != null)
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
}
|
||||
break;
|
||||
}
|
||||
// not using vlc - fall through to external handling
|
||||
case External:
|
||||
if (mFragment != null) mFragment.addManualSubtitles(null);
|
||||
mVideoManager.disableSubs();
|
||||
if (mFragment != null) mFragment.showSubLoadingMsg(true);
|
||||
stream.setDeliveryMethod(SubtitleDeliveryMethod.External);
|
||||
stream.setDeliveryUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/0/Stream.JSON", apiClient.getValue().getApiUrl(), mCurrentStreamInfo.getItemId(), mCurrentStreamInfo.getMediaSourceId(), String.valueOf(stream.getIndex())));
|
||||
apiClient.getValue().getSubtitles(stream.getDeliveryUrl(), new Response<SubtitleTrackInfo>() {
|
||||
}
|
||||
// not using vlc - fall through to external handling
|
||||
case External:
|
||||
if (mFragment != null) mFragment.showSubLoadingMsg(true);
|
||||
stream.setDeliveryMethod(SubtitleDeliveryMethod.External);
|
||||
stream.setDeliveryUrl(String.format("%1$s/Videos/%2$s/%3$s/Subtitles/%4$s/0/Stream.JSON", apiClient.getValue().getApiUrl(), mCurrentStreamInfo.getItemId(), mCurrentStreamInfo.getMediaSourceId(), String.valueOf(stream.getIndex())));
|
||||
apiClient.getValue().getSubtitles(stream.getDeliveryUrl(), new Response<SubtitleTrackInfo>() {
|
||||
|
||||
@Override
|
||||
public void onResponse(final SubtitleTrackInfo info) {
|
||||
@Override
|
||||
public void onResponse(final SubtitleTrackInfo info) {
|
||||
|
||||
if (info != null) {
|
||||
Timber.d("Adding json subtitle track to player");
|
||||
if (mFragment != null) mFragment.addManualSubtitles(info);
|
||||
} else {
|
||||
Timber.e("Empty subtitle result");
|
||||
if (mFragment != null) {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
mFragment.showSubLoadingMsg(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
Timber.e(ex, "Error downloading subtitles");
|
||||
if (info != null) {
|
||||
Timber.d("Adding json subtitle track to player");
|
||||
if (mFragment != null) mFragment.addManualSubtitles(info);
|
||||
mCurrentOptions.setSubtitleStreamIndex(index);
|
||||
mDefaultSubIndex = index;
|
||||
} else {
|
||||
Timber.e("Empty subtitle result");
|
||||
if (mFragment != null) {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
mFragment.showSubLoadingMsg(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
break;
|
||||
case Hls:
|
||||
break;
|
||||
}
|
||||
@Override
|
||||
public void onError(Exception ex) {
|
||||
Timber.e(ex, "Error downloading subtitles");
|
||||
if (mFragment != null) {
|
||||
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_unable_load_subs));
|
||||
mFragment.showSubLoadingMsg(false);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
break;
|
||||
case Hls:
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void pause() {
|
||||
@@ -1396,7 +1448,6 @@ public class PlaybackController {
|
||||
if (mPlaybackState == PlaybackState.PAUSED) {
|
||||
mPlaybackState = PlaybackState.PLAYING;
|
||||
} else {
|
||||
|
||||
// select or disable subtitles
|
||||
Integer currentSubtitleIndex = mCurrentOptions.getSubtitleStreamIndex();
|
||||
if (mDefaultSubIndex >= 0 && currentSubtitleIndex != null && currentSubtitleIndex == mDefaultSubIndex) {
|
||||
|
||||
@@ -32,6 +32,8 @@ public class PlaybackManager {
|
||||
}
|
||||
|
||||
public ArrayList<MediaStream> getInPlaybackSelectableAudioStreams(StreamInfo info) {
|
||||
if (info == null)
|
||||
return null;
|
||||
return info.GetSelectableAudioStreams();
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
private int mVideoVisibleWidth;
|
||||
private int mSarNum;
|
||||
private int mSarDen;
|
||||
private Integer exoplayerAudioIndex = null;
|
||||
|
||||
private long mForcedTime = -1;
|
||||
private long mLastTime = -1;
|
||||
@@ -144,6 +145,12 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
Timber.d("Caught player timeline change - reason: %s", reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED ? "PLAYLIST_CHANGED" : "SOURCE_UPDATE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTracksInfoChanged(TracksInfo tracksInfo) {
|
||||
Timber.d("Tracks info changed");
|
||||
exoplayerAudioIndex = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,6 +297,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
mActivity.finish();
|
||||
return;
|
||||
}
|
||||
exoplayerAudioIndex = null;
|
||||
mExoPlayer.setPlayWhenReady(true);
|
||||
normalWidth = mExoPlayerView.getLayoutParams().width;
|
||||
normalHeight = mExoPlayerView.getLayoutParams().height;
|
||||
@@ -331,6 +339,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
mExoPlayer.setTrackSelectionParameters(mExoPlayer.getTrackSelectionParameters()
|
||||
.buildUpon()
|
||||
.setTrackSelectionOverrides(overrides).build());
|
||||
exoplayerAudioIndex = null;
|
||||
} else if (mVlcPlayer != null) {
|
||||
mVlcPlayer.stop();
|
||||
}
|
||||
@@ -451,6 +460,9 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
if (streamType != MediaStreamType.Subtitle && streamType != MediaStreamType.Audio)
|
||||
return -1;
|
||||
|
||||
if (exoplayerAudioIndex != null && streamType == MediaStreamType.Audio)
|
||||
return exoplayerAudioIndex;
|
||||
|
||||
int chosenTrackType = streamType == MediaStreamType.Subtitle ? C.TRACK_TYPE_TEXT : C.TRACK_TYPE_AUDIO;
|
||||
|
||||
TracksInfo exoTracks = mExoPlayer.getCurrentTracksInfo();
|
||||
@@ -471,8 +483,12 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
Timber.d("failed to parse track ID [%s]", trackFormat.id);
|
||||
return -1;
|
||||
}
|
||||
if (id >= 0 && id < allStreams.size())
|
||||
if (id >= 0 && id < allStreams.size()) {
|
||||
Timber.d("re-retrieved exoplayer track index %s", id);
|
||||
if (streamType == MediaStreamType.Audio)
|
||||
exoplayerAudioIndex = id;
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -642,7 +658,17 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
return ndx;
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Double speed) {
|
||||
public float getPlaybackSpeed(){
|
||||
if (!isInitialized()) {
|
||||
return 1.0f;
|
||||
} else if (isNativeMode()){
|
||||
return mExoPlayer.getPlaybackParameters().speed;
|
||||
} else {
|
||||
return mVlcPlayer.getRate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull float speed) {
|
||||
if (speed < 0.25) {
|
||||
Timber.w("Invalid playback speed requested: %f", speed);
|
||||
return;
|
||||
@@ -650,9 +676,9 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
Timber.d("Setting playback speed: %f", speed);
|
||||
|
||||
if (nativeMode) {
|
||||
mExoPlayer.setPlaybackParameters(new PlaybackParameters(speed.floatValue()));
|
||||
mExoPlayer.setPlaybackParameters(new PlaybackParameters(speed));
|
||||
} else {
|
||||
mVlcPlayer.setRate(speed.floatValue());
|
||||
mVlcPlayer.setRate(speed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,7 +746,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
// Create a new media player
|
||||
ArrayList<String> options = new ArrayList<>(20);
|
||||
options.add("--network-caching=" + buffer);
|
||||
options.add("--no-audio-time-stretch");
|
||||
options.add("--audio-time-stretch");
|
||||
options.add("--avcodec-skiploopfilter");
|
||||
options.add("1");
|
||||
options.add("--avcodec-skip-frame");
|
||||
|
||||
@@ -3,16 +3,16 @@ package org.jellyfin.androidtv.ui.playback
|
||||
class VideoSpeedController(
|
||||
private val parentController: PlaybackController
|
||||
) {
|
||||
enum class SpeedSteps(val speed: Double) {
|
||||
enum class SpeedSteps(val speed: Float) {
|
||||
// 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),
|
||||
SPEED_0_25(speed = 0.25f),
|
||||
SPEED_0_50(speed = 0.5f),
|
||||
SPEED_0_75(speed = 0.75f),
|
||||
SPEED_1_00(speed = 1.0f),
|
||||
SPEED_1_25(speed = 1.25f),
|
||||
SPEED_1_50(speed = 1.50f),
|
||||
SPEED_1_75(speed = 1.75f),
|
||||
SPEED_2_00(speed = 2.0f),
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.jellyfin.androidtv.ui.playback.overlay;
|
||||
|
||||
import static java.lang.Math.round;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.text.format.DateFormat;
|
||||
@@ -78,9 +80,8 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
this.playbackController = playbackController;
|
||||
|
||||
mRefreshEndTime = () -> {
|
||||
setEndTime();
|
||||
if (!isPlaying()) {
|
||||
setEndTime();
|
||||
|
||||
mHandler.postDelayed(mRefreshEndTime, 30000);
|
||||
}
|
||||
};
|
||||
@@ -211,8 +212,10 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
}
|
||||
|
||||
void addMediaActions() {
|
||||
primaryActionsAdapter.clear();
|
||||
secondaryActionsAdapter.clear();
|
||||
if (primaryActionsAdapter.size() > 0)
|
||||
primaryActionsAdapter.clear();
|
||||
if (secondaryActionsAdapter.size() > 0)
|
||||
secondaryActionsAdapter.clear();
|
||||
|
||||
// Primary Items
|
||||
primaryActionsAdapter.add(playPauseAction);
|
||||
@@ -296,6 +299,10 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
} else if (action == playbackSpeedAction) {
|
||||
getPlayerAdapter().getLeanbackOverlayFragment().setFading(false);
|
||||
playbackSpeedAction.handleClickAction(playbackController, getPlayerAdapter().getLeanbackOverlayFragment(), getContext(), view);
|
||||
// Post a callback to calculate the new time, since Exoplayer updates this in an async fashion.
|
||||
// This is a hack, we should instead have onPlaybackParametersChanged call out to this
|
||||
// class to notify rather than poll. But communication is unidirectional at the moment:
|
||||
mHandler.postDelayed(mRefreshEndTime, 5000); // 5 seconds
|
||||
} else if (action == adjustAudioDelayAction) {
|
||||
getPlayerAdapter().getLeanbackOverlayFragment().setFading(false);
|
||||
adjustAudioDelayAction.handleClickAction(playbackController, getPlayerAdapter().getLeanbackOverlayFragment(), getContext(), view);
|
||||
@@ -323,9 +330,11 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
if (mEndsText == null || getPlayerAdapter().getDuration() < 1)
|
||||
return;
|
||||
long msLeft = getPlayerAdapter().getDuration() - getPlayerAdapter().getCurrentPosition();
|
||||
Calendar ends = Calendar.getInstance();
|
||||
ends.setTimeInMillis(ends.getTimeInMillis() + msLeft);
|
||||
mEndsText.setText(getContext().getString(R.string.lbl_playback_control_ends, DateFormat.getTimeFormat(getContext()).format(ends.getTime())));
|
||||
long realTimeLeft = round(msLeft / playbackController.getPlaybackSpeed());
|
||||
|
||||
Calendar endTime = Calendar.getInstance();
|
||||
endTime.setTimeInMillis(endTime.getTimeInMillis() + realTimeLeft);
|
||||
mEndsText.setText(getContext().getString(R.string.lbl_playback_control_ends, DateFormat.getTimeFormat(getContext()).format(endTime.getTime())));
|
||||
}
|
||||
|
||||
private void notifyActionChanged(Action action) {
|
||||
@@ -378,8 +387,10 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
|
||||
}
|
||||
|
||||
void invalidatePlaybackControls() {
|
||||
primaryActionsAdapter.clear();
|
||||
secondaryActionsAdapter.clear();
|
||||
if (primaryActionsAdapter.size() > 0)
|
||||
primaryActionsAdapter.clear();
|
||||
if (secondaryActionsAdapter.size() > 0)
|
||||
secondaryActionsAdapter.clear();
|
||||
addMediaActions();
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ public class VideoPlayerAdapter extends PlayerAdapter {
|
||||
return playbackController.getCurrentlyPlayingItem();
|
||||
}
|
||||
|
||||
public double getPlaybackSpeed() {
|
||||
return playbackController.getPlaybackSpeed();
|
||||
}
|
||||
|
||||
boolean hasChapters() {
|
||||
BaseItemDto item = getCurrentlyPlayingItem();
|
||||
List<ChapterInfoDto> chapters = item.getChapters();
|
||||
|
||||
@@ -27,6 +27,10 @@ public class SelectAudioAction extends CustomAction {
|
||||
public void handleClickAction(PlaybackController playbackController, LeanbackOverlayFragment leanbackOverlayFragment, Context context, View view) {
|
||||
|
||||
List<MediaStream> audioTracks = KoinJavaComponent.<PlaybackManager>get(PlaybackManager.class).getInPlaybackSelectableAudioStreams(playbackController.getCurrentStreamInfo());
|
||||
|
||||
if (audioTracks == null)
|
||||
return;
|
||||
|
||||
int currentAudioIndex = playbackController.getAudioStreamIndex();
|
||||
|
||||
PopupMenu audioMenu = new PopupMenu(context, view, Gravity.END);
|
||||
|
||||
@@ -11,8 +11,9 @@ fun OptionsScreen.aboutCategory() = category {
|
||||
setTitle(R.string.pref_about_title)
|
||||
|
||||
link {
|
||||
// Hardcoded strings for troubleshooting purposes
|
||||
title = "Jellyfin app version"
|
||||
content = BuildConfig.VERSION_NAME
|
||||
content = "jellyfin-androidtv ${BuildConfig.VERSION_NAME} ${BuildConfig.BUILD_TYPE}"
|
||||
icon = R.drawable.ic_jellyfin
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,10 @@ class ButtonRemapPreference(
|
||||
) : DialogPreference(context, attrs) {
|
||||
override fun getDialogLayoutResource() = R.layout.preference_button_remap
|
||||
|
||||
private var innerKeyCode: Int = -1
|
||||
var keyCode: Int
|
||||
get() = innerKeyCode
|
||||
var keyCode: Int = -1
|
||||
set(value) {
|
||||
innerKeyCode = value
|
||||
notifyDependencyChange(false)
|
||||
notifyChanged()
|
||||
field = value
|
||||
callChangeListener(value)
|
||||
}
|
||||
var defaultKeyCode: Int = -1
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.jellyfin.androidtv.preference.constant.AudioBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.NEXTUP_TIMER_DISABLED
|
||||
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||
import org.jellyfin.androidtv.preference.constant.PreferredVideoPlayer
|
||||
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||
import org.jellyfin.androidtv.ui.preference.custom.DurationSeekBarPreference
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.OptionsFragment
|
||||
import org.jellyfin.androidtv.ui.preference.dsl.checkbox
|
||||
@@ -134,10 +135,9 @@ class PlaybackPreferencesScreen : OptionsFragment() {
|
||||
depends { userPreferences[UserPreferences.videoPlayer] == PreferredVideoPlayer.EXTERNAL }
|
||||
}
|
||||
|
||||
checkbox {
|
||||
enum<RefreshRateSwitchingBehavior> {
|
||||
setTitle(R.string.lbl_refresh_switching)
|
||||
setContent(R.string.pref_refresh_switching_description)
|
||||
bind(userPreferences, UserPreferences.refreshRateSwitchingEnabled)
|
||||
bind(userPreferences, UserPreferences.refreshRateSwitchingBehavior)
|
||||
depends { DeviceUtils.is60() && userPreferences[UserPreferences.videoPlayer] != PreferredVideoPlayer.EXTERNAL }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import java.util.HashMap;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class CardPresenter extends Presenter {
|
||||
private static final double ASPECT_RATIO_BANNER = 434 / 79;
|
||||
private static final double ASPECT_RATIO_BANNER = 1000.0 / 185.0;
|
||||
|
||||
private int mStaticHeight = 300;
|
||||
private ImageType mImageType = ImageType.DEFAULT;
|
||||
|
||||
@@ -160,7 +160,7 @@ fun LegacyBaseItemDto.asSdk(): ModernBaseItemDto = ModernBaseItemDto(
|
||||
parentArtItemId = this.parentArtItemId,
|
||||
parentArtImageTag = this.parentArtImageTag,
|
||||
seriesThumbImageTag = this.seriesThumbImageTag,
|
||||
imageBlurHashes = this.imageBlurHashes.asSdk(),
|
||||
imageBlurHashes = this.imageBlurHashes?.asSdk(),
|
||||
seriesStudio = this.seriesStudio,
|
||||
parentThumbItemId = this.parentThumbItemId,
|
||||
parentThumbImageTag = this.parentThumbImageTag,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<string name="settings_title">Preferences</string>
|
||||
<string name="pref_authentication_cat">Authentication</string>
|
||||
<string name="chapters">Chapters</string>
|
||||
<string name="state_disabled">Disabled</string>
|
||||
<string name="lbl_settings">Preferences</string>
|
||||
<string name="lbl_continue_watching">Continue Watching</string>
|
||||
<string name="lbl_next_up">Next Up</string>
|
||||
@@ -147,7 +148,7 @@
|
||||
<string name="lbl_filters">Filters</string>
|
||||
<string name="msg_subtitles_loading">Subtitles loading…</string>
|
||||
<string name="msg_unable_load_subs">Unable to load subtitle</string>
|
||||
<string name="msg_burn_sub_warning">Burned in subs can take a few minutes. Please be patient…</string>
|
||||
<string name="msg_burn_sub_warning">Burning in subtitles. This may take a few seconds…</string>
|
||||
<string name="lbl_recording">Recording</string>
|
||||
<string name="lbl_no_recordings">No Recordings Available</string>
|
||||
<string name="lbl_live_tv_guide">Live TV Guide</string>
|
||||
@@ -204,8 +205,6 @@
|
||||
<string name="lbl_open">Open</string>
|
||||
<string name="lbl_show_backdrop">Show backdrops</string>
|
||||
<string name="pref_show_backdrop_description">Change background image to selected items</string>
|
||||
<string name="lbl_refresh_switching">Refresh rate switching</string>
|
||||
<string name="pref_refresh_switching_description">Monitor refresh rate is changed to match video</string>
|
||||
<string name="lbl_show_premieres">Show season premieres</string>
|
||||
<string name="desc_premieres">Show a row of new episode pilots for any series you watch</string>
|
||||
<string name="lbl_bitstream_dts">Bitstream DTS audio</string>
|
||||
@@ -486,4 +485,17 @@
|
||||
<string name="pref_direct_stream_live_off">Transcoding is used when necessary</string>
|
||||
<string name="pref_direct_stream_live_on">Transcoding is disabled</string>
|
||||
<string name="home_section_resume_book">Continue reading</string>
|
||||
<string name="action_login">Sign in</string>
|
||||
<string name="action_use_password">Use a password</string>
|
||||
<string name="action_use_quickconnect">Use QuickConnect</string>
|
||||
<string name="input_username">Username</string>
|
||||
<string name="input_password">Password</string>
|
||||
<string name="login_connect_to">Connecting to %1$s</string>
|
||||
<string name="login_other_options">Other options:</string>
|
||||
<string name="login_quickconnect_step_1">1. Open the Jellyfin app on your phone or webbrowser, and sign in with your account</string>
|
||||
<string name="login_quickconnect_step_2">2. Open the user menu and go to the Quick Connect page</string>
|
||||
<string name="login_quickconnect_step_3">3. Enter the following code:</string>
|
||||
<string name="lbl_refresh_switching">Refresh rate switching</string>
|
||||
<string name="pref_refresh_rate_scale_on_tv">Scale on TV</string>
|
||||
<string name="pref_refresh_rate_scale_on_device">Scale on device</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- Stored servers & users (without access tokens) -->
|
||||
<include
|
||||
domain="file"
|
||||
path="authentication_store.json" />
|
||||
|
||||
<!-- UserPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv_preferences" />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv.debug_preferences" />
|
||||
<!-- SystemPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="systemprefs" />
|
||||
<!-- AuthenticationPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="AuthenticationPreferences" />
|
||||
</full-backup-content>
|
||||
|
||||
47
app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- Stored servers & users (without access tokens) -->
|
||||
<include
|
||||
domain="file"
|
||||
path="authentication_store.json" />
|
||||
|
||||
<!-- UserPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv_preferences" />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv.debug_preferences" />
|
||||
<!-- SystemPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="systemprefs" />
|
||||
<!-- AuthenticationPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="AuthenticationPreferences" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<!-- Stored servers & users (without access tokens) -->
|
||||
<include
|
||||
domain="file"
|
||||
path="authentication_store.json" />
|
||||
|
||||
<!-- UserPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv_preferences" />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="org.jellyfin.androidtv.debug_preferences" />
|
||||
<!-- SystemPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="systemprefs" />
|
||||
<!-- AuthenticationPreferences -->
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="AuthenticationPreferences" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.jellyfin.androidtv.ui.playback
|
||||
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.doubles.plusOrMinus
|
||||
import io.kotest.matchers.floats.plusOrMinus
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.justRun
|
||||
@@ -17,19 +17,19 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
|
||||
test("VideoSpeedController.SpeedSteps uses intervals of 0.25") {
|
||||
VideoSpeedController.SpeedSteps.values().forEachIndexed { i, v ->
|
||||
v.speed.shouldBe((i + 1) * 0.25 plusOrMinus 0.0001)
|
||||
v.speed.shouldBe((i + 1) * 0.25f plusOrMinus 1.0E-4F)
|
||||
}
|
||||
}
|
||||
|
||||
test("VideoSpeedController speed is 1 by default") {
|
||||
val mockController = mockk<PlaybackController>(relaxed = true)
|
||||
val slot = slot<Double>()
|
||||
val slot = slot<Float>()
|
||||
justRun { mockController.setPlaybackSpeed(capture(slot)) }
|
||||
|
||||
VideoSpeedController(mockController)
|
||||
|
||||
verify { mockController.setPlaybackSpeed(any()) }
|
||||
slot.captured shouldBe (1.0 plusOrMinus 0.0001)
|
||||
slot.captured shouldBe (1.0f plusOrMinus 1.0E-4F)
|
||||
}
|
||||
|
||||
test("VideoSpeedController.currentSpeed getter returns set value") {
|
||||
@@ -44,7 +44,7 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
|
||||
test("VideoSpeedController.currentSpeed updates the speed in the controller") {
|
||||
val mockController = mockk<PlaybackController>(relaxed = true)
|
||||
val slot = slot<Double>()
|
||||
val slot = slot<Float>()
|
||||
justRun { mockController.setPlaybackSpeed(capture(slot)) }
|
||||
|
||||
val controller = VideoSpeedController(mockController)
|
||||
@@ -52,7 +52,7 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
controller.currentSpeed = expected
|
||||
|
||||
verify { mockController.setPlaybackSpeed(any()) }
|
||||
slot.captured shouldBe (expected.speed plusOrMinus 0.0001)
|
||||
slot.captured shouldBe (expected.speed plusOrMinus 1.0E-4F)
|
||||
}
|
||||
|
||||
test("VideoSpeedController.resetSpeedToDefault() works correctly") {
|
||||
@@ -62,26 +62,26 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
videoController.currentSpeed = VideoSpeedController.SpeedSteps.SPEED_2_00
|
||||
videoController.resetSpeedToDefault()
|
||||
|
||||
val slot = slot<Double>()
|
||||
val slot = slot<Float>()
|
||||
justRun { playbackController.setPlaybackSpeed(capture(slot)) }
|
||||
VideoSpeedController(playbackController)
|
||||
|
||||
verify { playbackController.setPlaybackSpeed(any()) }
|
||||
slot.captured shouldBe (VideoSpeedController.SpeedSteps.SPEED_1_00.speed plusOrMinus 0.0001)
|
||||
slot.captured shouldBe (VideoSpeedController.SpeedSteps.SPEED_1_00.speed plusOrMinus 1.0E-4F)
|
||||
}
|
||||
|
||||
test("VideoSpeedController remembers previous instance speed value") {
|
||||
var lastSetSpeed = 1.0
|
||||
var lastSetSpeed = 1.0f
|
||||
|
||||
VideoSpeedController.SpeedSteps.values().forEach { newSpeed ->
|
||||
val mockController = mockk<PlaybackController>(relaxed = true)
|
||||
val slot = slot<Double>()
|
||||
val slot = slot<Float>()
|
||||
justRun { mockController.setPlaybackSpeed(capture(slot)) }
|
||||
|
||||
val controller = VideoSpeedController(mockController)
|
||||
|
||||
verify { mockController.setPlaybackSpeed(any()) }
|
||||
slot.captured shouldBe (lastSetSpeed plusOrMinus 0.0001)
|
||||
slot.captured shouldBe (lastSetSpeed plusOrMinus 1.0E-4F)
|
||||
|
||||
controller.currentSpeed = newSpeed
|
||||
lastSetSpeed = newSpeed.speed
|
||||
@@ -101,13 +101,13 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
}
|
||||
val speedController = VideoSpeedController(mockController)
|
||||
|
||||
verify { mockController.setPlaybackSpeed(1.0) }
|
||||
verify { mockController.setPlaybackSpeed(1.0f) }
|
||||
speedController.currentSpeed shouldBe VideoSpeedController.SpeedSteps.SPEED_1_00
|
||||
|
||||
// Try to set it back to other values should be ignored
|
||||
speedController.currentSpeed = VideoSpeedController.SpeedSteps.SPEED_2_00
|
||||
|
||||
verify { mockController.setPlaybackSpeed(1.0) }
|
||||
verify { mockController.setPlaybackSpeed(1.0f) }
|
||||
speedController.currentSpeed shouldBe VideoSpeedController.SpeedSteps.SPEED_1_00
|
||||
}
|
||||
|
||||
@@ -117,12 +117,12 @@ class VideoSpeedControllerTests : FunSpec({
|
||||
}
|
||||
val speedController = VideoSpeedController(mockController)
|
||||
|
||||
verify { mockController.setPlaybackSpeed(1.0) }
|
||||
verify { mockController.setPlaybackSpeed(1.0f) }
|
||||
speedController.currentSpeed shouldBe VideoSpeedController.SpeedSteps.SPEED_1_00
|
||||
|
||||
speedController.currentSpeed = VideoSpeedController.SpeedSteps.SPEED_2_00
|
||||
|
||||
verify { mockController.setPlaybackSpeed(2.0) }
|
||||
verify { mockController.setPlaybackSpeed(2.0f) }
|
||||
speedController.currentSpeed shouldBe VideoSpeedController.SpeedSteps.SPEED_2_00
|
||||
}
|
||||
})
|
||||
|
||||
3
fastlane/metadata/android/en-US/changelogs/default.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Thank you for using Jellyfin on Android TV! Using the latest released Jellyfin Server is always recommended.
|
||||
|
||||
For more information, please see our blog at jellyfin.org, or read the full changelog on GitHub.
|
||||
14
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
Your media, on your terms.
|
||||
|
||||
The Jellyfin project is an open source, free software media server. No fees, no tracking, no hidden agenda. Get our free server to collect all your audio, video, photos, and more in one place.
|
||||
|
||||
To use the app, you must have a Jellyfin server set up and running. Find out more at <a href="https://jellyfin.org/" target="_blank">jellyfin.org</a>.
|
||||
|
||||
With a Jellyfin server, you can:
|
||||
|
||||
* Watch Live TV and recorded shows from your Jellyfin server (additional hardware/services required)
|
||||
* Stream to a Chromecast device on your network
|
||||
* Stream your media to your Android device
|
||||
* View your collection in an easy to use interface
|
||||
|
||||
This is the official Jellyfin companion app for Android TV. Thank you for using Jellyfin!
|
||||
BIN
fastlane/metadata/android/en-US/images/featureGraphic.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
fastlane/metadata/android/en-US/images/tvBanner.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
fastlane/metadata/android/en-US/images/tvScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
fastlane/metadata/android/en-US/images/tvScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
fastlane/metadata/android/en-US/images/tvScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Television client for Jellyfin, the free software media system
|
||||
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Jellyfin for Android TV
|
||||
4
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=e5444a57cda4a95f90b0c9446a9e1b47d3d7f69057765bfb54bd4f482542d548
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip
|
||||
distributionSha256Sum=29e49b10984e585d8118b7d0bc452f944e386458df27371b49b4ac1dec4b7fda
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||