Compare commits
36 Commits
v0.15.8
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786e8d7a33 | ||
|
|
36fd201113 | ||
|
|
21c7b6de09 | ||
|
|
e2a8c7a285 | ||
|
|
683c326c0c | ||
|
|
9bbbfa4e26 | ||
|
|
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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.jellyfin.androidtv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
@@ -13,12 +12,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.acra.config.dialog
|
||||
import org.acra.config.httpSender
|
||||
import org.acra.config.limiter
|
||||
import org.acra.data.StringFormat
|
||||
import org.acra.ktx.initAcra
|
||||
import org.acra.sender.HttpSender
|
||||
import org.jellyfin.androidtv.auth.SessionRepository
|
||||
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||
import org.jellyfin.androidtv.util.AutoBitrate
|
||||
@@ -77,26 +70,4 @@ class JellyfinApplication : TvApp() {
|
||||
// Detect auto bitrate
|
||||
GlobalScope.launch(Dispatchers.IO) { autoBitrate.detect() }
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
|
||||
httpSender {
|
||||
uri = "https://collector.tracepot.com/a2eda9d9"
|
||||
httpMethod = HttpSender.Method.POST
|
||||
}
|
||||
|
||||
dialog {
|
||||
withResTitle(R.string.acra_dialog_title)
|
||||
withResText(R.string.acra_dialog_text)
|
||||
withResTheme(R.style.Theme_Jellyfin)
|
||||
}
|
||||
|
||||
limiter {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ public class ChannelCardView extends FrameLayout {
|
||||
}
|
||||
|
||||
public void setItem(final ChannelInfoDto channel) {
|
||||
binding.name.setText(channel.getNumber() + " " + channel.getName());
|
||||
if (channel.getNumber() != null) binding.name.setText(channel.getNumber() + " " + channel.getName());
|
||||
else binding.name.setText(channel.getName());
|
||||
|
||||
BaseItemDto program = channel.getCurrentProgram();
|
||||
if (program != null) {
|
||||
if (program.getEndDate() != null && System.currentTimeMillis() > TimeUtils.convertToLocalDate(program.getEndDate()).getTime()) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -1258,7 +1259,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,13 +1484,19 @@ 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));
|
||||
if (subtitlesBackgroundEnabled) {
|
||||
// Disable the text outlining when the background is enabled
|
||||
binding.subtitlesText.setStrokeWidth(0.0f);
|
||||
span.setSpan(new PaddedLineBackgroundSpan(ContextCompat.getColor(requireContext(), R.color.black_opaque), SUBTITLE_PADDING), 0, span.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
// get the alignment gravity of the TextView
|
||||
// extract the absolute horizontal gravity so the span can draw its background aligned
|
||||
int gravity = binding.subtitlesText.getGravity();
|
||||
int horizontalGravity = Gravity.getAbsoluteGravity(gravity, binding.subtitlesText.getLayoutDirection()) & Gravity.HORIZONTAL_GRAVITY_MASK;
|
||||
span.setSpan(new PaddedLineBackgroundSpan(ContextCompat.getColor(requireContext(), R.color.black_opaque), SUBTITLE_PADDING, horizontalGravity), 0, span.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
binding.subtitlesText.setText(span);
|
||||
|
||||
@@ -90,8 +90,11 @@ public class GetPlaybackInfoResponse extends Response<PlaybackInfoResponse> {
|
||||
|
||||
streamInfo = new StreamInfo();
|
||||
streamInfo.setItemId(options.getItemId());
|
||||
streamInfo.setMediaType(DlnaProfileType.Audio);
|
||||
|
||||
if (isVideo) {
|
||||
streamInfo.setMediaType(DlnaProfileType.Video);
|
||||
} else {
|
||||
streamInfo.setMediaType(DlnaProfileType.Audio);
|
||||
}
|
||||
streamInfo.setMediaSource(mediaSourceInfo);
|
||||
streamInfo.setRunTimeTicks(mediaSourceInfo.getRunTimeTicks());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -711,15 +758,14 @@ public class PlaybackController {
|
||||
|
||||
mCurrentStreamInfo = response;
|
||||
|
||||
// set the audio index so that it is preserved if the stream is rebuilt when seeking
|
||||
mCurrentOptions.setAudioStreamIndex(response.getMediaSource().getDefaultAudioStreamIndex());
|
||||
// set media source Id in case we need to switch to transcoding
|
||||
mCurrentOptions.setMediaSourceId(response.getMediaSource().getId());
|
||||
setDefaultAudioIndex(response);
|
||||
|
||||
|
||||
// get subtitle info
|
||||
mSubtitleStreams = response.GetSubtitleProfiles(false, apiClient.getValue().getApiUrl(), apiClient.getValue().getAccessToken());
|
||||
mDefaultSubIndex = response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : mDefaultSubIndex;
|
||||
setDefaultAudioIndex(response);
|
||||
Timber.d("default audio index set to %s remote default %s", mDefaultAudioIndex, response.getMediaSource().getDefaultAudioStreamIndex());
|
||||
Timber.d("default sub index set to %s remote default %s", mDefaultSubIndex, response.getMediaSource().getDefaultSubtitleStreamIndex());
|
||||
|
||||
// if burning in, set the subtitle index and the burningSubs flag so that onPrepared and switchSubtitleStream will know that we already have subtitles enabled
|
||||
@@ -729,6 +775,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 +795,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();
|
||||
|
||||
@@ -806,17 +854,21 @@ public class PlaybackController {
|
||||
}
|
||||
|
||||
public int getAudioStreamIndex() {
|
||||
int currIndex = mDefaultAudioIndex;
|
||||
int currIndex = -1;
|
||||
|
||||
// if not transcoding, libVLC & exoplayer are able to switch tracks and report their selected track
|
||||
if (hasInitializedVideoManager() && !isTranscoding() && mVideoManager.getVLCAudioTrack() > -1) {
|
||||
currIndex = mVideoManager.getVLCAudioTrack();
|
||||
} else if (hasInitializedVideoManager() && !isTranscoding() && isNativeMode() && mVideoManager.getExoPlayerTrack(MediaStreamType.Audio, getCurrentlyPlayingItem().getMediaStreams()) > -1) {
|
||||
currIndex = mVideoManager.getExoPlayerTrack(MediaStreamType.Audio, getCurrentlyPlayingItem().getMediaStreams());
|
||||
} else if (!isTranscoding() && isNativeMode() && mCurrentOptions.getAudioStreamIndex() != null) {
|
||||
// Use stream index from mCurrentOptions if it's set.
|
||||
// This should be null until the player has been queried at least once after playback starts
|
||||
//
|
||||
// Use DefaultAudioStreamIndex for transcoding since they are encoded with only one stream
|
||||
//
|
||||
// Otherwise, query the players
|
||||
if (mCurrentOptions.getAudioStreamIndex() != null) {
|
||||
currIndex = mCurrentOptions.getAudioStreamIndex();
|
||||
} else if (getCurrentMediaSource().getDefaultAudioStreamIndex() != null) {
|
||||
} else if (isTranscoding() && getCurrentMediaSource().getDefaultAudioStreamIndex() != null) {
|
||||
currIndex = getCurrentMediaSource().getDefaultAudioStreamIndex();
|
||||
} else if (hasInitializedVideoManager() && !isTranscoding()) {
|
||||
currIndex = isNativeMode() ? mVideoManager.getExoPlayerTrack(MediaStreamType.Audio, getCurrentlyPlayingItem().getMediaStreams()) :
|
||||
mVideoManager.getVLCAudioTrack(getCurrentlyPlayingItem().getMediaStreams());
|
||||
}
|
||||
return currIndex;
|
||||
}
|
||||
@@ -841,8 +893,13 @@ public class PlaybackController {
|
||||
if (mDefaultAudioIndex != -1)
|
||||
return;
|
||||
|
||||
if (bestGuessAudioTrack(info.getMediaSource()) != null)
|
||||
mDefaultAudioIndex = bestGuessAudioTrack(info.getMediaSource());
|
||||
Integer remoteDefault = info.getMediaSource().getDefaultAudioStreamIndex();
|
||||
Integer bestGuess = bestGuessAudioTrack(info.getMediaSource());
|
||||
|
||||
if (remoteDefault != null)
|
||||
mDefaultAudioIndex = remoteDefault;
|
||||
else if (bestGuess != null)
|
||||
mDefaultAudioIndex = bestGuess;
|
||||
Timber.d("default audio index set to %s", mDefaultAudioIndex);
|
||||
}
|
||||
|
||||
@@ -854,6 +911,10 @@ public class PlaybackController {
|
||||
Timber.d("trying to switch audio stream from %s to %s", currAudioIndex, index);
|
||||
if (currAudioIndex == index) {
|
||||
Timber.d("skipping setting audio stream, already set to requested index %s", index);
|
||||
if (mCurrentOptions.getAudioStreamIndex() == null || mCurrentOptions.getAudioStreamIndex() != index) {
|
||||
Timber.d("setting mCurrentOptions audio stream index from %s to %s", mCurrentOptions.getAudioStreamIndex(), index);
|
||||
mCurrentOptions.setAudioStreamIndex(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -863,7 +924,7 @@ public class PlaybackController {
|
||||
if (isNativeMode() && !isTranscoding() && mVideoManager.setExoPlayerTrack(index, MediaStreamType.Audio, getCurrentlyPlayingItem().getMediaStreams())) {
|
||||
mCurrentOptions.setMediaSourceId(getCurrentMediaSource().getId());
|
||||
mCurrentOptions.setAudioStreamIndex(index);
|
||||
} else if (!isNativeMode() && !isTranscoding() && mVideoManager.setVLCAudioTrack(index) == index) {
|
||||
} else if (!isNativeMode() && !isTranscoding() && mVideoManager.setVLCAudioTrack(index, getCurrentlyPlayingItem().getMediaStreams()) == index) {
|
||||
// if setAudioTrack succeeded it will return the requested index
|
||||
mCurrentOptions.setMediaSourceId(getCurrentMediaSource().getId());
|
||||
mCurrentOptions.setAudioStreamIndex(index);
|
||||
@@ -882,20 +943,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 +970,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 +1460,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();
|
||||
}
|
||||
|
||||
@@ -56,6 +58,8 @@ public class PlaybackManager {
|
||||
request.setDeviceProfile(options.getProfile());
|
||||
request.setMaxAudioChannels(options.getMaxAudioChannels());
|
||||
|
||||
request.setAllowAudioStreamCopy(true);
|
||||
|
||||
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, false, startPositionTicks));
|
||||
}
|
||||
|
||||
@@ -65,14 +69,24 @@ public class PlaybackManager {
|
||||
request.setUserId(apiClient.getCurrentUserId());
|
||||
request.setMaxStreamingBitrate(Long.valueOf(options.getMaxBitrate()));
|
||||
request.setMediaSourceId(options.getMediaSourceId());
|
||||
request.setAudioStreamIndex(options.getAudioStreamIndex());
|
||||
request.setSubtitleStreamIndex(options.getSubtitleStreamIndex());
|
||||
request.setStartTimeTicks(startPositionTicks);
|
||||
request.setDeviceProfile(options.getProfile());
|
||||
request.setEnableDirectStream(options.getEnableDirectStream());
|
||||
request.setEnableDirectPlay(options.getEnableDirectPlay());
|
||||
request.setMaxAudioChannels(options.getMaxAudioChannels());
|
||||
|
||||
Integer audioIdx = options.getAudioStreamIndex();
|
||||
if (audioIdx != null && audioIdx >= 0) {
|
||||
request.setAudioStreamIndex(audioIdx);
|
||||
}
|
||||
Integer subIdx = options.getSubtitleStreamIndex();
|
||||
if (subIdx != null && subIdx >= 0) {
|
||||
request.setSubtitleStreamIndex(subIdx);
|
||||
}
|
||||
|
||||
request.setAllowVideoStreamCopy(true);
|
||||
request.setAllowAudioStreamCopy(true);
|
||||
|
||||
apiClient.GetPlaybackInfoWithPost(request, new GetPlaybackInfoResponse(this, deviceInfo, apiClient, options, response, true, startPositionTicks));
|
||||
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@ import org.koin.java.KoinJavaComponent;
|
||||
import org.videolan.libvlc.LibVLC;
|
||||
import org.videolan.libvlc.Media;
|
||||
import org.videolan.libvlc.interfaces.IVLCVout;
|
||||
import org.videolan.libvlc.MediaPlayer;
|
||||
import org.videolan.libvlc.MediaPlayer.TrackDescription;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import timber.log.Timber;
|
||||
@@ -67,7 +68,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
private PlayerView mExoPlayerView;
|
||||
private AspectRatioFrameLayout mAspectRatioFrameLayout;
|
||||
private LibVLC mLibVLC;
|
||||
private org.videolan.libvlc.MediaPlayer mVlcPlayer;
|
||||
private MediaPlayer mVlcPlayer;
|
||||
private Media mCurrentMedia;
|
||||
private VlcEventHandler mVlcHandler = new VlcEventHandler();
|
||||
private Handler mHandler = new Handler();
|
||||
@@ -144,6 +145,11 @@ 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -412,37 +418,43 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setSubtitleTrack(int index, @Nullable List<MediaStream> allStreams) {
|
||||
if (!nativeMode && allStreams != null) {
|
||||
//find the relative order of our sub index within the sub tracks in VLC
|
||||
int vlcIndex = 1; // start at 1 to account for "disabled"
|
||||
for (MediaStream stream : allStreams) {
|
||||
if (stream.getType() == MediaStreamType.Subtitle && !stream.getIsExternal()) {
|
||||
if (stream.getIndex() == index) {
|
||||
break;
|
||||
}
|
||||
vlcIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
org.videolan.libvlc.MediaPlayer.TrackDescription vlcSub;
|
||||
try {
|
||||
vlcSub = getSubtitleTracks()[vlcIndex];
|
||||
|
||||
} catch (IndexOutOfBoundsException e) {
|
||||
Timber.e("Could not locate subtitle with index %s in vlc track info", index);
|
||||
return false;
|
||||
} 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;
|
||||
}
|
||||
|
||||
Timber.i("Setting Vlc sub to %s", vlcSub.name);
|
||||
return mVlcPlayer.setSpuTrack(vlcSub.id);
|
||||
private int offsetStreamIndex(int index, boolean adjustByAdding, @Nullable List<MediaStream> allStreams) {
|
||||
if (index < 0 || allStreams == null)
|
||||
return -1;
|
||||
|
||||
for (MediaStream stream : allStreams) {
|
||||
if (!stream.getIsExternal())
|
||||
break;
|
||||
index += adjustByAdding ? 1 : -1;
|
||||
}
|
||||
|
||||
return false;
|
||||
return index < 0 || index >= allStreams.size() ? -1 : index;
|
||||
}
|
||||
|
||||
public boolean setSubtitleTrack(int index, @Nullable List<MediaStream> allStreams) {
|
||||
if (isNativeMode() || allStreams == null)
|
||||
return false;
|
||||
|
||||
int vlcIndex = offsetStreamIndex(index, false, allStreams);
|
||||
|
||||
if (vlcIndex < 0)
|
||||
return false;
|
||||
|
||||
TrackDescription vlcSub = null;
|
||||
for (TrackDescription subTrack: mVlcPlayer.getSpuTracks()) {
|
||||
Timber.d("libvlc subtitle track %s %s", subTrack.id, subTrack.name);
|
||||
if (subTrack.id == vlcIndex)
|
||||
vlcSub = subTrack;
|
||||
}
|
||||
|
||||
if (vlcSub == null) {
|
||||
Timber.e("Could not locate subtitle with index %s in vlc track info", vlcIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
Timber.i("Setting Vlc sub to %s", vlcSub.name);
|
||||
return mVlcPlayer.setSpuTrack(vlcSub.id);
|
||||
|
||||
}
|
||||
|
||||
public int getExoPlayerTrack(@Nullable MediaStreamType streamType, @Nullable List<MediaStream> allStreams) {
|
||||
@@ -453,8 +465,11 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
|
||||
int chosenTrackType = streamType == MediaStreamType.Subtitle ? C.TRACK_TYPE_TEXT : C.TRACK_TYPE_AUDIO;
|
||||
|
||||
int matchedIndex = -2;
|
||||
TracksInfo exoTracks = mExoPlayer.getCurrentTracksInfo();
|
||||
for (TracksInfo.TrackGroupInfo groupInfo : exoTracks.getTrackGroupInfos()) {
|
||||
if (matchedIndex > -2)
|
||||
break;
|
||||
// Group level information.
|
||||
@C.TrackType int trackType = groupInfo.getTrackType();
|
||||
TrackGroup group = groupInfo.getTrackGroup();
|
||||
@@ -463,58 +478,50 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
Format trackFormat = group.getFormat(i);
|
||||
if (trackType == chosenTrackType) {
|
||||
if (groupInfo.isTrackSelected(i)) {
|
||||
// we found the track, set to -1 first to handle failed int parsing
|
||||
matchedIndex = -1;
|
||||
if (trackFormat.id != null) {
|
||||
int id;
|
||||
try {
|
||||
id = Integer.parseInt(trackFormat.id) - 1;
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
} catch (NumberFormatException e) {
|
||||
Timber.d("failed to parse track ID [%s]", trackFormat.id);
|
||||
return -1;
|
||||
break;
|
||||
}
|
||||
if (id >= 0 && id < allStreams.size())
|
||||
return id;
|
||||
matchedIndex = id;
|
||||
}
|
||||
return -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
|
||||
// offset the stream index to account for external streams
|
||||
int exoTrackID = offsetStreamIndex(matchedIndex, true, allStreams);
|
||||
if (exoTrackID < 0)
|
||||
return -1;
|
||||
|
||||
Timber.d("re-retrieved exoplayer track index %s", exoTrackID);
|
||||
return exoTrackID;
|
||||
}
|
||||
|
||||
public boolean setExoPlayerTrack(int index, @Nullable MediaStreamType streamType, @Nullable List<MediaStream> allStreams) {
|
||||
if (!nativeMode || !isInitialized() || streamType == null || allStreams == null || index < 0 || index >= allStreams.size())
|
||||
return false;
|
||||
if (streamType != MediaStreamType.Subtitle && streamType != MediaStreamType.Audio)
|
||||
if (!nativeMode || !isInitialized() || allStreams == null || streamType != MediaStreamType.Subtitle && streamType != MediaStreamType.Audio)
|
||||
return false;
|
||||
|
||||
int chosenTrackType = streamType == MediaStreamType.Subtitle ? C.TRACK_TYPE_TEXT : C.TRACK_TYPE_AUDIO;
|
||||
|
||||
TracksInfo exoTracks = mExoPlayer.getCurrentTracksInfo();
|
||||
|
||||
List<String> internallyRenderedSubFormats = Arrays.asList("ass", "ssa");
|
||||
|
||||
// make sure the chosen track exists, is the correct type, and isn't external
|
||||
boolean isValidMediaStream = false;
|
||||
for (MediaStream stream : allStreams) {
|
||||
Timber.d("MediaStream track %s type %s label %s codec %s isExternal %s", stream.getIndex(), stream.getType(), stream.getTitle(), stream.getCodec(), stream.getIsExternal());
|
||||
if (stream.getType() == streamType) {
|
||||
if (stream.getIndex() == index) {
|
||||
if (stream.getIsExternal())
|
||||
continue;
|
||||
if (streamType == MediaStreamType.Subtitle && !internallyRenderedSubFormats.contains(stream.getCodec())) {
|
||||
continue;
|
||||
}
|
||||
isValidMediaStream = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidMediaStream)
|
||||
MediaStream candidate = allStreams.get(index);
|
||||
if (candidate.getIsExternal() || candidate.getType() != streamType)
|
||||
return false;
|
||||
|
||||
// MediaStream IDs start at 0 and exoplayer tracks start at 1
|
||||
// force selection of the chosen track by setting its TrackGroup as the only allowed group for its type
|
||||
int exoTrackID = offsetStreamIndex(index, false, allStreams);
|
||||
if (exoTrackID < 0)
|
||||
return false;
|
||||
|
||||
// print the streams for debugging
|
||||
for (MediaStream stream : allStreams) {
|
||||
Timber.d("MediaStream track %s type %s label %s codec %s isExternal %s", stream.getIndex(), stream.getType(), stream.getTitle(), stream.getCodec(), stream.getIsExternal());
|
||||
}
|
||||
|
||||
// design choices for exoplayer track selection overrides:
|
||||
// * build upon the prior parameters so we can mix overrides of different track types without erasing priors
|
||||
@@ -523,8 +530,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
// if we want most formats to be handled by the external subtitle handler (which has adjustable size, background), we leave sub track selection disabled
|
||||
// if we decide to use exoplayer to render a specific subtitle format, allow subtitle track selection and restrict selection to the chosen group
|
||||
|
||||
int exoTrackID = index + 1;
|
||||
|
||||
TracksInfo exoTracks = mExoPlayer.getCurrentTracksInfo();
|
||||
TrackGroup matchedGroup = null;
|
||||
for (TracksInfo.TrackGroupInfo groupInfo : exoTracks.getTrackGroupInfos()) {
|
||||
// Group level information.
|
||||
@@ -581,68 +587,66 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
return true;
|
||||
}
|
||||
|
||||
public int getVLCAudioTrack() {
|
||||
public int getVLCAudioTrack(@Nullable List<MediaStream> allStreams) {
|
||||
if (!isInitialized() || nativeMode)
|
||||
return -1;
|
||||
return mVlcPlayer.getAudioTrack();
|
||||
|
||||
int ndx = offsetStreamIndex(mVlcPlayer.getAudioTrack(), true, allStreams);
|
||||
Timber.d("re-retrieved libVLC audio track index %s", ndx);
|
||||
|
||||
return ndx;
|
||||
}
|
||||
|
||||
public int setVLCAudioTrack(int ndx) {
|
||||
if (!isInitialized() || ndx < 0)
|
||||
public int setVLCAudioTrack(int ndx, @Nullable List<MediaStream> allStreams) {
|
||||
if (!isInitialized() || isNativeMode())
|
||||
return -1;
|
||||
|
||||
if (isNativeMode()) {
|
||||
Timber.e("Cannot set audio track in native mode");
|
||||
int vlcID = offsetStreamIndex(ndx, false, allStreams);
|
||||
if (vlcID < 0)
|
||||
return -1;
|
||||
}
|
||||
|
||||
//debug
|
||||
Timber.d("Setting VLC audio track index to: %d", ndx);
|
||||
for (org.videolan.libvlc.MediaPlayer.TrackDescription track : mVlcPlayer.getAudioTracks()) {
|
||||
TrackDescription vlcTrack = null;
|
||||
Timber.d("Setting VLC audio track index to: %d", vlcID);
|
||||
|
||||
int vlcNdx = 0;
|
||||
for (TrackDescription track : mVlcPlayer.getAudioTracks()) {
|
||||
Timber.d("VLC Audio Track: %s / %d", track.name, track.id);
|
||||
}
|
||||
//
|
||||
|
||||
boolean matched = false;
|
||||
for (org.videolan.libvlc.MediaPlayer.TrackDescription track : mVlcPlayer.getAudioTracks()) {
|
||||
if (track.id == ndx) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
if (track.id == vlcID)
|
||||
vlcTrack = track;
|
||||
if (vlcTrack == null)
|
||||
vlcNdx++;
|
||||
}
|
||||
|
||||
org.videolan.libvlc.MediaPlayer.TrackDescription vlcTrack = null;
|
||||
|
||||
if (matched) {
|
||||
try {
|
||||
vlcTrack = mVlcPlayer.getAudioTracks()[ndx];
|
||||
} catch (IndexOutOfBoundsException e) {
|
||||
Timber.e("Could not locate audio with index %s in vlc track info", ndx);
|
||||
return -1;
|
||||
} catch (NullPointerException e) {
|
||||
Timber.e("Could not locate audio track with index %s in vlc, null exception", ndx);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched || vlcTrack == null) {
|
||||
Timber.e("Could not locate audio track with index %s in vlc", ndx);
|
||||
if (vlcTrack == null) {
|
||||
Timber.e("Could not locate audio track with index %s in vlc", vlcID);
|
||||
return -1;
|
||||
} else if (mVlcPlayer.getAudioTrack() == ndx) {
|
||||
}
|
||||
|
||||
if (mVlcPlayer.getAudioTrack() == vlcID) {
|
||||
Timber.d("provided index points to the audio track already in use, aborting");
|
||||
return -1;
|
||||
return ndx;
|
||||
}
|
||||
|
||||
if (mVlcPlayer.setAudioTrack(vlcTrack.id)) {
|
||||
Timber.i("Setting by ID was successful");
|
||||
} else {
|
||||
Timber.i("Setting by ID not successful, trying index");
|
||||
mVlcPlayer.setAudioTrack(ndx);
|
||||
Timber.i("Setting by ID not successful, trying index %s", vlcNdx);
|
||||
mVlcPlayer.setAudioTrack(vlcNdx);
|
||||
}
|
||||
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 +654,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,10 +707,6 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
}
|
||||
}
|
||||
|
||||
public org.videolan.libvlc.MediaPlayer.TrackDescription[] getSubtitleTracks() {
|
||||
return nativeMode ? null : mVlcPlayer.getSpuTracks();
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
stopPlayback();
|
||||
releasePlayer();
|
||||
@@ -720,7 +720,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");
|
||||
@@ -746,7 +746,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
|
||||
mLibVLC = new LibVLC(mActivity, options);
|
||||
Timber.i("Network buffer set to %d", buffer);
|
||||
|
||||
mVlcPlayer = new org.videolan.libvlc.MediaPlayer(mLibVLC);
|
||||
mVlcPlayer = new MediaPlayer(mLibVLC);
|
||||
setVlcAudioOptions();
|
||||
|
||||
mSurfaceHolder.addCallback(mSurfaceCallback);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.shared
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.Gravity
|
||||
import android.text.style.LineBackgroundSpan
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
@@ -14,7 +15,8 @@ import kotlin.math.roundToInt
|
||||
*/
|
||||
class PaddedLineBackgroundSpan(
|
||||
@ColorInt private val backgroundColor: Int,
|
||||
@Dimension private val horizontalPadding: Int
|
||||
@Dimension private val horizontalPadding: Int,
|
||||
private val horizontalGravity: Int,
|
||||
) : LineBackgroundSpan {
|
||||
private val backgroundRect = Rect()
|
||||
|
||||
@@ -35,11 +37,25 @@ class PaddedLineBackgroundSpan(
|
||||
// Measure the current line of text
|
||||
val textWidth = paint.measureText(text, start, end).roundToInt()
|
||||
|
||||
@Suppress("RtlHardcoded")
|
||||
val rectLeft = when(horizontalGravity) {
|
||||
Gravity.CENTER_HORIZONTAL -> ((right - textWidth) / 2) - horizontalPadding
|
||||
Gravity.RIGHT -> right - textWidth - horizontalPadding
|
||||
else -> left - horizontalPadding
|
||||
}
|
||||
|
||||
@Suppress("RtlHardcoded")
|
||||
val rectRight = when(horizontalGravity) {
|
||||
Gravity.CENTER_HORIZONTAL -> right - rectLeft
|
||||
Gravity.RIGHT -> right + horizontalPadding
|
||||
else -> left + textWidth + horizontalPadding
|
||||
}
|
||||
|
||||
// Set the dimensions of the background rectangle
|
||||
backgroundRect.set(
|
||||
left - horizontalPadding,
|
||||
rectLeft,
|
||||
top,
|
||||
left + textWidth + horizontalPadding,
|
||||
rectRight,
|
||||
bottom
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.jellyfin.androidtv.util.profile;
|
||||
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.AVCLevel4;
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10;
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.H263Level10;
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.H263Level45;
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.HEVCMainTierLevel5;
|
||||
import static android.media.MediaCodecInfo.CodecProfileLevel.HEVCProfileMain10;
|
||||
import static android.media.MediaFormat.MIMETYPE_VIDEO_AVC;
|
||||
import static android.media.MediaFormat.MIMETYPE_VIDEO_H263;
|
||||
import static android.media.MediaFormat.MIMETYPE_VIDEO_HEVC;
|
||||
|
||||
@@ -36,6 +39,10 @@ public class MediaCodecCapabilitiesTest {
|
||||
return hasDecoder(MIMETYPE_VIDEO_HEVC, HEVCProfileMain10, HEVCMainTierLevel5);
|
||||
}
|
||||
|
||||
public boolean supportsAVCHigh10() {
|
||||
return hasDecoder(MIMETYPE_VIDEO_AVC, AVCProfileHigh10, AVCLevel4);
|
||||
}
|
||||
|
||||
private boolean checkDecoder(String mime, int profile, int level) {
|
||||
if (!hasDecoder(mime, profile, level)) {
|
||||
Timber.i("no %s decoder for profile %d and level %d", mime, profile, level);
|
||||
|
||||
@@ -84,11 +84,12 @@ object ProfileHelper {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.EqualsAny,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
arrayOf(
|
||||
listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline"
|
||||
"constrained baseline",
|
||||
if (MediaTest.supportsAVCHigh10()) "high 10" else null
|
||||
).joinToString("|")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
android:layout_gravity="bottom|center_horizontal"
|
||||
android:layout_marginHorizontal="120dp"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
app:strokeWidth="5.0"
|
||||
|
||||
@@ -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
|
||||
|
||||