Compare commits

...

28 Commits

Author SHA1 Message Date
renovate[bot]
62f98901b0 Update Kotlin to v2.0.20
(cherry picked from commit 09027c2bc5)
2024-08-22 17:47:50 +02:00
renovate[bot]
19381684ae Update dependency androidx.tv:tv-material to v1.0.0
(cherry picked from commit f2a47d1c9a)
2024-08-22 17:47:11 +02:00
Niels van Velzen
176a7e3391 Improve behavior when no playable items found
(cherry picked from commit 1f789ac00f)
2024-08-22 17:47:07 +02:00
Niels van Velzen
2dbdbe8c28 Disable direct play when encountering playback error twice
(cherry picked from commit 1199fa5253)
2024-08-22 17:47:06 +02:00
Niels van Velzen
1502dc805b Fix toggling favorites in ItemListFragment
(cherry picked from commit a4ec607543)
2024-08-22 17:47:06 +02:00
Niels van Velzen
29fc35f215 Fix channels interpreted as Live TV channels
(cherry picked from commit ae2d74b263)
2024-08-22 17:47:06 +02:00
Niels van Velzen
af0255440c Enable constant bitrate seeking
(cherry picked from commit e65492c254)
2024-08-22 17:47:05 +02:00
Niels van Velzen
389c48a1fd Fix crash when item doesn't have a default audio/subtitle stream
(cherry picked from commit 9577e11e41)
2024-08-12 08:44:31 +02:00
Niels van Velzen
7d84011d81 Re-use supportsHevc in one additional place
(cherry picked from commit 6111cbec5a)
2024-08-11 09:59:40 +02:00
Joseph Price
36a307cd7f Really don't request HEVC if unsupported
(cherry picked from commit c7a4c89b74)
2024-08-11 09:59:39 +02:00
Niels van Velzen
2a122803c2 Fix InfoRow not using requested media source
(cherry picked from commit b0cd0c5b37)
2024-08-11 09:59:39 +02:00
Niels van Velzen
dbf6dc0f9a Reset display mode when exiting video player
(cherry picked from commit aa5fb63b71)
2024-08-11 09:59:38 +02:00
Niels van Velzen
fd663aff39 Improve InfoRow colors/font readability
(cherry picked from commit d661583a86)
2024-08-11 09:59:38 +02:00
Niels van Velzen
30e34568c1 Fix intros showing next up screen
(cherry picked from commit fe31c19f15)
2024-08-11 09:59:37 +02:00
Niels van Velzen
5fb77314c4 Fix crash when starting next episode
(cherry picked from commit fcd9751d82)
2024-08-11 09:59:37 +02:00
Niels van Velzen
d436c5de3d Graduate reactive homepage to stable
(cherry picked from commit f8c5345871)
2024-08-11 09:59:36 +02:00
renovate[bot]
8157e3e5c1 Update dependency androidx.tv:tv-material to v1.0.0-rc02
(cherry picked from commit 0385f63fbe)
2024-08-11 09:59:20 +02:00
renovate[bot]
3656c18e8f Update dependency androidx.work:work-runtime to v2.9.1
(cherry picked from commit 6cc1031b8a)
2024-08-11 09:59:19 +02:00
renovate[bot]
9f48b7da39 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.3
(cherry picked from commit 587f0fb35a)
2024-08-11 09:59:19 +02:00
Niels van Velzen
ef899f98bc Fix SelectQualityAction crashing when opening menu
(cherry picked from commit 044ce67110)
2024-08-06 15:20:34 +02:00
Niels van Velzen
de6c7904f0 Use "direct play" as default audio behavior
(cherry picked from commit f6c536880d)
2024-08-06 15:20:34 +02:00
Marc Vollmer
0a79d5b60f Fix NullPointerException in LeanbackOverlayFragment.onPause
(cherry picked from commit 3b852a031e)
2024-08-06 15:20:33 +02:00
Niels van Velzen
36e0bc1edc Fix NullPointerException in CustomPlaybackOverlayFragment.onResume
(cherry picked from commit 7bc99d2e66)
2024-08-06 15:20:33 +02:00
Niels van Velzen
8faeb3aac1 Remove context requirement from ExoPlayerProfile
(cherry picked from commit 9ecf952351)
2024-08-06 15:20:11 +02:00
majmongoose
160ef4914f Add Google Chromecast HD limitation (#3820)
* Add identification for Google Chromecast HD

Possibly an upstream bug is preventing this from working, as the chromecast is being idenitified correctly in debugging.

* Update DeviceUtils.kt

Added a note for the reason of the change.

* Removed unintentional imports

* Update app/src/main/java/org/jellyfin/androidtv/util/DeviceUtils.kt

Make changes suggested by maintainer.

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>

* Update DeviceUtils.kt

Removed unneeded isChromecastHD

---------

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>
(cherry picked from commit a8c9f054ab)
2024-08-06 15:20:10 +02:00
Niels van Velzen
474c0774f1 Fix AudioNowPlayingFragment showing wrong item in extremely rare cases
(cherry picked from commit 61b4178dec)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c9ba1ccec Fix race condition in PictureViewerViewModel
Using the showNext/showPrevious functions while the album is still loading can cause an IndexOutOfBoundsException. Check if the album is empty (default value) first before trying to read from it to avoid these crashes.

(cherry picked from commit 9aa1759ae7)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c667ab615 Strip HTML from descriptions in leanback integration
(cherry picked from commit 2e55987939)
2024-08-06 15:20:09 +02:00
29 changed files with 209 additions and 160 deletions

View File

@@ -13,6 +13,7 @@
- [sparky3387](https://github.com/sparky3387)
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
# Emby Contributors

View File

@@ -28,6 +28,7 @@ import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.dp
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.androidtv.util.stripHtml
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
@@ -363,7 +364,7 @@ class LeanbackChannelWorker(
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview)
.setDescription(item.overview?.stripHtml())
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null

View File

@@ -96,7 +96,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/**
* Preferred behavior for audio streaming.
*/
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DOWNMIX_TO_STEREO)
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
/**
* Preferred behavior for audio streaming.
@@ -199,11 +199,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Enable reactive homepage
*/
var homeReactive = booleanPreference("home_reactive", false)
}
init {

View File

@@ -26,6 +26,8 @@ import org.jellyfin.androidtv.util.sdk.getSeasonEpisodeName
import org.jellyfin.androidtv.util.sdk.isNew
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SeriesStatus
import org.jellyfin.sdk.model.api.VideoRangeType
@@ -135,13 +137,23 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
}
}
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
if (defaultIndex != null) {
val byIndex = getOrNull(defaultIndex)
if (byIndex?.type == type) return byIndex
}
return firstOrNull { it.type == type }
}
@Composable
fun InfoRowMediaDetails(item: BaseItemDto) {
val videoStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val audioStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
// Subtitles
if (item.hasSubtitles == true) {
if (subtitleStream != null) {
InfoRowItem(
contentDescription = null,
colors = InfoRowColors.Default,
@@ -219,6 +231,7 @@ fun InfoRowMediaDetails(item: BaseItemDto) {
@Composable
fun BaseItemInfoRow(
item: BaseItemDto,
mediaSource: MediaSourceInfo?,
includeRuntime: Boolean,
) {
val userPreferences = koinInject<UserPreferences>()
@@ -239,7 +252,7 @@ fun BaseItemInfoRow(
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.BOX_SET -> {
@@ -257,7 +270,7 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.SERIES -> {
@@ -265,7 +278,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
InfoRowSeriesStatus(item)
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.PROGRAM -> {
@@ -303,7 +316,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.MUSIC_ARTIST -> {
@@ -343,14 +356,14 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
else -> {
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
}
}
@@ -364,6 +377,7 @@ class BaseItemInfoRowView @JvmOverloads constructor(
attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
private val _item = MutableStateFlow<BaseItemDto?>(null)
private val _mediaSource = MutableStateFlow<MediaSourceInfo?>(null)
private val _includeRuntime = MutableStateFlow(false)
var item: BaseItemDto?
@@ -372,6 +386,12 @@ class BaseItemInfoRowView @JvmOverloads constructor(
_item.value = value
}
var mediaSource: MediaSourceInfo?
get() = _mediaSource.value
set(value) {
_mediaSource.value = value
}
var includeRuntime: Boolean
get() = _includeRuntime.value
set(value) {
@@ -386,8 +406,9 @@ class BaseItemInfoRowView @JvmOverloads constructor(
@Composable
override fun Content() {
val item by _item.collectAsState()
val mediaSource by _mediaSource.collectAsState()
val includeRuntime by _includeRuntime.collectAsState()
item?.let { BaseItemInfoRow(it, includeRuntime) }
item?.let { BaseItemInfoRow(it, mediaSource, includeRuntime) }
}
}

View File

@@ -7,7 +7,7 @@ import androidx.compose.ui.graphics.Color
*/
object InfoRowColors {
val Transparent = Color.Transparent to Color.White
val Default = Color(0x80FFFFFF) to Color.Black
val Green = Color(0x8034D97C) to Color.Black
val Red = Color(0x80F2364D) to Color.Black
val Default = Color(0xB3FFFFFF) to Color.Black
val Green = Color(0xB3089562) to Color.White
val Red = Color(0xB3F2364D) to Color.White
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ProvideTextStyle
@@ -46,6 +47,7 @@ fun InfoRowItem(
value = TextStyle(
color = foregroundColor,
fontSize = if (backgroundColor.alpha > 0f) 12.sp else 16.sp,
fontWeight = if (backgroundColor.alpha > 0f) FontWeight.W600 else FontWeight.W500,
)
) {
Row(

View File

@@ -166,15 +166,13 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (userPreferences[UserPreferences.homeReactive]) {
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
}

View File

@@ -1186,6 +1186,10 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
@Override
public void onResponse(List<BaseItemDto> response) {
if (!getActive()) return;
if (response.isEmpty()) {
Timber.e("No items to play - ignoring play request.");
return;
}
if (item.getType() == BaseItemKind.MUSIC_ARTIST) {
mediaManager.getValue().playNow(requireContext(), response, 0, shuffle);
@@ -1196,7 +1200,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
}
}
});
}
void play(final List<BaseItemDto> items, final int pos, final boolean shuffle) {

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.ui.itemdetail
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.sdk.api.client.ApiClient
@@ -29,7 +30,7 @@ fun ItemListFragment.loadItem(itemId: UUID) {
lifecycleScope.launch {
val item by api.userLibraryApi.getItem(itemId)
setBaseItem(item)
if (isActive) setBaseItem(item)
}
}
@@ -88,7 +89,7 @@ fun ItemListFragment.toggleFavorite(item: BaseItemDto, callback: (item: BaseItem
lifecycleScope.launch {
val userData = itemMutationRepository.setFavorite(
item = item.id,
favorite = item.userData?.isFavorite ?: true
favorite = !(item.userData?.isFavorite ?: false)
)
callback(item.copy(userData = userData))
}

View File

@@ -23,7 +23,6 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
val preferSeriesPoster: Boolean = false,
) : BaseRowItem(
baseRowType = when (item.type) {
BaseItemKind.CHANNEL,
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL -> BaseRowType.LiveTvChannel

View File

@@ -53,6 +53,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
// Album actions
fun showNext() {
if (album.isEmpty()) return
albumIndex++
if (albumIndex == album.size) albumIndex = 0
@@ -61,6 +63,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
}
fun showPrevious() {
if (album.isEmpty()) return
albumIndex--
if (albumIndex == -1) albumIndex = album.size - 1

View File

@@ -144,7 +144,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().toggleRepeat();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mRepeatButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -155,7 +155,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().shuffleAudioQueue();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mShuffleButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -175,7 +175,7 @@ public class AudioNowPlayingFragment extends Fragment {
mArtistButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0) {
if (mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty()) {
navigationRepository.getValue().navigate(Destinations.INSTANCE.itemDetails(mBaseItem.getAlbumArtists().get(0).getId()));
}
}
@@ -217,10 +217,10 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onResume() {
super.onResume();
loadItem();
//link events
mediaManager.getValue().addAudioEventListener(audioEventListener);
updateButtons(mediaManager.getValue().isPlayingAudio());
loadItem();
updateButtons();
// load the item duration and set the position to 0 since it won't be set elsewhere until playback is initialized
if (!mediaManager.getValue().isAudioPlayerInitialized())
@@ -238,13 +238,8 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onPlaybackStateChange(@NonNull PlaybackController.PlaybackState newState, @Nullable org.jellyfin.sdk.model.api.BaseItemDto currentItem) {
Timber.d("**** Got playstate change: %s", newState.toString());
if (newState == PlaybackController.PlaybackState.PLAYING && currentItem != mBaseItem) {
// new item started
loadItem();
updateButtons(true);
} else {
updateButtons(newState == PlaybackController.PlaybackState.PLAYING);
}
if (currentItem != mBaseItem) loadItem();
updateButtons();
}
@Override
@@ -261,7 +256,7 @@ public class AudioNowPlayingFragment extends Fragment {
if (hasQueue) {
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
} else {
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
@@ -313,8 +308,9 @@ public class AudioNowPlayingFragment extends Fragment {
}
}
private void updateButtons(final boolean playing) {
private void updateButtons() {
Timber.d("Updating buttons");
boolean playing = mediaManager.getValue().isPlayingAudio();
requireActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
@@ -334,14 +330,14 @@ public class AudioNowPlayingFragment extends Fragment {
mShuffleButton.setActivated(mediaManager.getValue().isShuffleMode());
if (mBaseItem != null) {
mAlbumButton.setEnabled(mBaseItem.getAlbumId() != null);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty());
}
}
});
}
private String getArtistName(org.jellyfin.sdk.model.api.BaseItemDto item) {
String artistName = item.getArtists() != null && item.getArtists().size() > 0 ? item.getArtists().get(0) : item.getAlbumArtist();
String artistName = item.getArtists() != null && !item.getArtists().isEmpty() ? item.getArtists().get(0) : item.getAlbumArtist();
return artistName != null ? artistName : "";
}
@@ -365,7 +361,7 @@ public class AudioNowPlayingFragment extends Fragment {
public void setCurrentTime(long time) {
// Round the current time as otherwise the time played and time remaining will not be in sync
time = Math.round(time / 1000) * 1000;
time = Math.round(time / 1000L) * 1000L;
mCurrentProgress.setProgress(((Long) time).intValue());
mCurrentPos.setText(TimeUtils.formatMillis(time));
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");

View File

@@ -7,6 +7,7 @@ import android.content.Context;
import android.graphics.Color;
import android.media.AudioManager;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.Spannable;
@@ -16,6 +17,7 @@ import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
@@ -575,7 +577,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// up or down should close panel
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
hidePopupPanel();
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv()) hide(); //also close this if live tv
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv())
hide(); //also close this if live tv
return true;
} else {
return false;
@@ -674,7 +677,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
super.onResume();
// Close player when resuming without a valid playback contoller
if (!playbackControllerContainer.getValue().getPlaybackController().hasFragment()) {
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
if (playbackController == null || !playbackController.hasFragment()) {
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
@@ -1451,5 +1455,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// Show system bars
WindowCompat.setDecorFitsSystemWindows(requireActivity().getWindow(), true);
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
// Reset display mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.preferredDisplayModeId = 0;
getActivity().getWindow().setAttributes(params);
}
}
}

View File

@@ -20,9 +20,11 @@ import org.jellyfin.androidtv.data.compat.VideoOptions;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.UserSettingPreferences;
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
import org.jellyfin.androidtv.preference.constant.NextUpBehavior;
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.ReportingHelper;
@@ -101,7 +103,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private long mCurrentTranscodeStartTime;
private boolean isLiveTv = false;
private boolean directStreamLiveTv;
private boolean exoErrorEncountered;
private int playbackRetries = 0;
private long lastPlaybackError = 0;
@@ -247,8 +248,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
public void playerErrorEncountered() {
exoErrorEncountered = true;
// reset the retry count if it's been more than 30s since previous error
if (playbackRetries > 0 && Instant.now().toEpochMilli() - lastPlaybackError > 30000) {
Timber.d("playback stabilized - retry count reset to 0 from %s", playbackRetries);
@@ -516,18 +515,18 @@ public class PlaybackController implements PlaybackControllerNotifiable {
internalOptions.setItemId(item.getId());
internalOptions.setMediaSources(item.getMediaSources());
internalOptions.setMaxBitrate(maxBitrate);
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
internalOptions.setEnableDirectStream(false);
internalOptions.setMaxAudioChannels(Utils.downMixAudio(mFragment.getContext()) ? 2 : null); //have to downmix at server
if (playbackRetries > 0 || (isLiveTv && !directStreamLiveTv)) internalOptions.setEnableDirectStream(false);
if (playbackRetries > 1) internalOptions.setEnableDirectPlay(false);
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
if (!isLiveTv && currentMediaSource != null) {
internalOptions.setMediaSourceId(currentMediaSource.getId());
}
DeviceProfile internalProfile = new ExoPlayerProfile(
mFragment.getContext(),
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled())
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO,
!DeviceUtils.has4kVideoSupport()
);
internalOptions.setProfile(internalProfile);
return internalOptions;
@@ -926,7 +925,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void resetPlayerErrors() {
exoErrorEncountered = false;
playbackRetries = 0;
}
private void clearPlaybackSessionOptions() {

View File

@@ -185,6 +185,8 @@ public class VideoManager {
httpDataSourceFactory.setReadTimeoutMs(30 * 1000);
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, httpDataSourceFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
extractorsFactory.setConstantBitrateSeekingEnabled(true);
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
return exoPlayerBuilder;
}

View File

@@ -88,7 +88,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
@Override
public void onPause() {
super.onPause();
playerAdapter.getMasterOverlayFragment().onPause();
if (playerAdapter != null) {
playerAdapter.getMasterOverlayFragment().onPause();
}
}
public CustomPlaybackTransportControlGlue getPlayerGlue() {

View File

@@ -10,7 +10,6 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.VideoQualityController
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
class SelectQualityAction(
@@ -39,8 +38,11 @@ class SelectQualityAction(
}
menu.setGroupCheckable(0, true, true)
menu.getItem(qualityProfiles.keys.indexOf(qualityController.currentQuality))?.let { item ->
item.isChecked = true
val currentQualityIndex = qualityProfiles.keys.indexOf(qualityController.currentQuality)
if (currentQualityIndex != -1) {
menu.getItem(currentQualityIndex)?.let { item ->
item.isChecked = true
}
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }

View File

@@ -39,12 +39,6 @@ class DeveloperPreferencesScreen : OptionsFragment() {
}
}
checkbox {
setTitle(R.string.enable_reactive_homepage)
setContent(R.string.enable_playback_module_description)
bind(userPreferences, UserPreferences.homeReactive)
}
// Only show in debug mode
// some strings are hardcoded because these options don't show in beta/release builds
if (BuildConfig.DEVELOPMENT) {

View File

@@ -21,7 +21,7 @@ class MyDetailsOverviewRowPresenter(
fun setItem(row: MyDetailsOverviewRow) {
setTitle(row.item.name)
InfoLayoutHelper.addInfoRow(view.context, row.item, binding.fdMainInfoRow, false)
InfoLayoutHelper.addInfoRow(view.context, row.item, row.item.mediaSources?.getOrNull(row.selectedMediaSourceIndex), binding.fdMainInfoRow, false)
binding.fdGenreRow.text = row.item.genres?.joinToString(" / ")
binding.infoTitle1.text = row.infoItem1?.label

View File

@@ -26,6 +26,9 @@ object DeviceUtils {
// Nvidia Shield TV Model
private const val SHIELD_TV_MODEL = "SHIELD Android TV"
// Google Chromecast HD Model
private const val GOOGLE_CHROMECAST_HD_MODEL = "Chromecast HD"
@JvmStatic
val isFireTv: Boolean = Build.MODEL.startsWith(FIRE_TV_PREFIX)
@@ -53,6 +56,7 @@ object DeviceUtils {
FIRE_STICK_MODEL_GEN_3,
FIRE_STICK_LITE_MODEL,
FIRE_TV_MODEL_GEN_1,
FIRE_TV_MODEL_GEN_2
FIRE_TV_MODEL_GEN_2,
GOOGLE_CHROMECAST_HD_MODEL
)
}

View File

@@ -1,33 +0,0 @@
package org.jellyfin.androidtv.util;
import android.content.Context;
import android.view.View;
import android.widget.LinearLayout;
import org.jellyfin.androidtv.ui.browsing.composable.inforow.BaseItemInfoRowView;
import org.jellyfin.sdk.model.api.BaseItemDto;
public class InfoLayoutHelper {
public static void addInfoRow(Context context, BaseItemDto item, LinearLayout layout, boolean includeRuntime) {
// Find existing BaseItemInfoRowView or create a new one
BaseItemInfoRowView baseItemInfoRowView = null;
for (int i = 0; i < layout.getChildCount(); i++) {
View child = layout.getChildAt(i);
if (child instanceof BaseItemInfoRowView) {
baseItemInfoRowView = (BaseItemInfoRowView) child;
break;
}
}
if (baseItemInfoRowView == null) {
baseItemInfoRowView = new BaseItemInfoRowView(context);
layout.addView(baseItemInfoRowView);
}
// Update item info
baseItemInfoRowView.setItem(item);
baseItemInfoRowView.setIncludeRuntime(includeRuntime);
}
}

View File

@@ -0,0 +1,48 @@
package org.jellyfin.androidtv.util
import android.content.Context
import android.widget.LinearLayout
import org.jellyfin.androidtv.ui.browsing.composable.inforow.BaseItemInfoRowView
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
object InfoLayoutHelper {
@JvmStatic
fun addInfoRow(
context: Context,
item: BaseItemDto?,
mediaSource: MediaSourceInfo?,
layout: LinearLayout,
includeRuntime: Boolean
) {
// Find existing BaseItemInfoRowView or create a new one
var baseItemInfoRowView: BaseItemInfoRowView? = null
for (i in 0 until layout.childCount) {
val child = layout.getChildAt(i)
if (child is BaseItemInfoRowView) {
baseItemInfoRowView = child
break
}
}
if (baseItemInfoRowView == null) {
baseItemInfoRowView = BaseItemInfoRowView(context)
layout.addView(baseItemInfoRowView)
}
// Update item info
baseItemInfoRowView.item = item
baseItemInfoRowView.mediaSource = mediaSource ?: item?.mediaSources?.firstOrNull()
baseItemInfoRowView.includeRuntime = includeRuntime
}
@JvmStatic
fun addInfoRow(
context: Context,
item: BaseItemDto?,
layout: LinearLayout,
includeRuntime: Boolean
) = addInfoRow(context, item, null, layout, includeRuntime)
}

View File

@@ -10,6 +10,11 @@ import org.jellyfin.androidtv.R
*/
fun String.toHtmlSpanned(): Spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
/**
* Remove HTML tags from string and return the plain representation.
*/
fun String.stripHtml(): String = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
/**
* Utility to get the string for the "Load channels" button in the Live TV guide.
*/

View File

@@ -1,16 +1,11 @@
package org.jellyfin.androidtv.util
import android.content.Context
import android.media.AudioManager
import android.widget.Toast
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserPreferences.Companion.audioBehaviour
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import timber.log.Timber
import java.util.UUID
import kotlin.math.roundToInt
@@ -98,17 +93,6 @@ object Utils : KoinComponent {
return themeColor
}
@JvmStatic
fun downMixAudio(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (am.isBluetoothA2dpOn) {
Timber.i("Downmixing audio due to wired headset")
return true
}
return get<UserPreferences>()[audioBehaviour] === AudioBehavior.DOWNMIX_TO_STEREO
}
@JvmStatic
fun getSafeSeekPosition(position: Long, duration: Long): Long = when {
position >= duration -> (duration - 1000).coerceAtLeast(0)

View File

@@ -1,9 +1,6 @@
package org.jellyfin.androidtv.util.profile
import android.content.Context
import org.jellyfin.androidtv.constant.Codec
import org.jellyfin.androidtv.util.DeviceUtils
import org.jellyfin.androidtv.util.Utils
import org.jellyfin.androidtv.util.profile.ProfileHelper.audioDirectPlayProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceAV1CodecProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceHevcCodecProfile
@@ -14,6 +11,7 @@ import org.jellyfin.androidtv.util.profile.ProfileHelper.max1080pProfileConditio
import org.jellyfin.androidtv.util.profile.ProfileHelper.maxAudioChannelsCodecProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.photoDirectPlayProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.subtitleProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.supportsHevc
import org.jellyfin.apiclient.model.dlna.CodecProfile
import org.jellyfin.apiclient.model.dlna.CodecType
import org.jellyfin.apiclient.model.dlna.DeviceProfile
@@ -27,9 +25,10 @@ import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
import org.jellyfin.apiclient.model.dlna.TranscodingProfile
class ExoPlayerProfile(
context: Context,
disableVideoDirectPlay: Boolean = false,
isAC3Enabled: Boolean = false,
disableVideoDirectPlay: Boolean,
isAC3Enabled: Boolean,
downMixAudio: Boolean,
disable4KVideo: Boolean
) : DeviceProfile() {
private val downmixSupportedAudioCodecs = arrayOf(
Codec.Audio.AAC,
@@ -78,12 +77,12 @@ class ExoPlayerProfile(
this.context = EncodingContext.Streaming
container = Codec.Container.TS
videoCodec = buildList {
if (deviceHevcCodecProfile.ContainsCodec(Codec.Video.HEVC, Codec.Container.TS)) add(Codec.Video.HEVC)
if (supportsHevc) add(Codec.Video.HEVC)
add(Codec.Video.H264)
}.joinToString(",")
audioCodec = when {
Utils.downMixAudio(context) -> downmixSupportedAudioCodecs
else -> allSupportedAudioCodecsWithoutFFmpegExperimental
audioCodec = when (downMixAudio) {
true -> downmixSupportedAudioCodecs
false -> allSupportedAudioCodecsWithoutFFmpegExperimental
}.joinToString(",")
protocol = "hls"
copyTimestamps = false
@@ -129,22 +128,26 @@ class ExoPlayerProfile(
Codec.Video.AV1
).joinToString(",")
audioCodec = when {
Utils.downMixAudio(context) -> downmixSupportedAudioCodecs
else -> allSupportedAudioCodecs
audioCodec = when (downMixAudio) {
true -> downmixSupportedAudioCodecs
false -> allSupportedAudioCodecs
}.joinToString(",")
})
}
// Audio direct play
add(audioDirectPlayProfile(allSupportedAudioCodecs + arrayOf(
Codec.Audio.MPA,
Codec.Audio.WAV,
Codec.Audio.WMA,
Codec.Audio.OGG,
Codec.Audio.OGA,
Codec.Audio.WEBMA,
Codec.Audio.APE,
)))
add(
audioDirectPlayProfile(
allSupportedAudioCodecs + arrayOf(
Codec.Audio.MPA,
Codec.Audio.WAV,
Codec.Audio.WMA,
Codec.Audio.OGG,
Codec.Audio.OGA,
Codec.Audio.WEBMA,
Codec.Audio.APE,
)
)
)
// Photo direct play
add(photoDirectPlayProfile)
}.toTypedArray()
@@ -157,7 +160,7 @@ class ExoPlayerProfile(
conditions = buildList {
add(h264VideoProfileCondition)
add(h264VideoLevelProfileCondition)
if (!DeviceUtils.has4kVideoSupport()) addAll(max1080pProfileConditions)
if (disable4KVideo) addAll(max1080pProfileConditions)
}.toTypedArray()
})
// H264 ref frames profile
@@ -204,15 +207,14 @@ class ExoPlayerProfile(
// AV1 profile
add(deviceAV1CodecProfile)
// Limit video resolution support for older devices
if (!DeviceUtils.has4kVideoSupport()) {
if (disable4KVideo) {
add(CodecProfile().apply {
type = CodecType.Video
conditions = max1080pProfileConditions
})
}
// Audio channel profile
if (!Utils.downMixAudio(context)) add(maxAudioChannelsCodecProfile(channels = 8))
else add(maxAudioChannelsCodecProfile(channels = 2))
add(maxAudioChannelsCodecProfile(channels = if (downMixAudio) 2 else 8))
}.toTypedArray()
subtitleProfiles = arrayOf(

View File

@@ -63,13 +63,17 @@ object ProfileHelper {
}
}
val supportsHevc by lazy {
MediaTest.supportsHevc()
}
val deviceHevcCodecProfile by lazy {
CodecProfile().apply {
type = CodecType.Video
codec = Codec.Video.HEVC
conditions = when {
!MediaTest.supportsHevc() -> {
!supportsHevc -> {
// The following condition is a method to exclude all HEVC
Timber.i("*** Does NOT support HEVC")
arrayOf(
@@ -107,7 +111,7 @@ object ProfileHelper {
val deviceHevcLevelCodecProfiles by lazy {
buildList {
if (MediaTest.supportsHevc()) {
if (supportsHevc) {
add(CodecProfile().apply {
type = CodecType.Video
codec = Codec.Video.HEVC

View File

@@ -55,7 +55,9 @@ class SdkPlaybackHelper(
) {
getScope(context).launch {
runCatching {
getItems(mainItem, allowIntros, shuffle)
val items = getItems(mainItem, allowIntros, shuffle)
if (items.isEmpty() && !mainItem.mediaSources.isNullOrEmpty()) listOf(mainItem)
else items
}.fold(
onSuccess = { items -> outerResponse.onResponse(items) },
onFailure = { exception ->
@@ -217,9 +219,10 @@ class SdkPlaybackHelper(
val addIntros = allowIntros && userPreferences[UserPreferences.cinemaModeEnabled]
if (addIntros) {
val intros = runCatching {
api.userLibraryApi.getIntros(mainItem.id).content.items
}.getOrNull().orEmpty()
val intros = runCatching { api.userLibraryApi.getIntros(mainItem.id).content.items }.getOrNull()
.orEmpty()
// Force the type to be trailer as the legacy playback UI uses it to determine if it should show the next up screen
.map { it.copy(type = BaseItemKind.TRAILER) }
intros + parts
} else {

View File

@@ -20,19 +20,19 @@ androidx-media3 = "1.4.0"
androidx-preference = "1.2.1"
androidx-recyclerview = "1.3.2"
androidx-startup = "1.1.1"
androidx-tv = "1.0.0-rc01"
androidx-tv = "1.0.0"
androidx-tvprovider = "1.1.0-alpha01"
androidx-window = "1.3.0"
androidx-work = "2.9.0"
androidx-work = "2.9.1"
coil = "2.7.0"
detekt = "1.23.6"
jellyfin-androidx-media = "1.3.1+2"
jellyfin-apiclient = "v0.7.10"
jellyfin-sdk = "1.5.2"
jellyfin-sdk = "1.5.3"
koin = "3.5.6"
koin-compose = "3.5.6"
kotest = "5.9.1"
kotlin = "2.0.0"
kotlin = "2.0.20"
kotlinx-coroutines = "1.8.1"
kotlinx-serialization = "1.7.1"
markwon = "4.6.2"

View File

@@ -84,14 +84,15 @@ class ExoPlayerBackend(
}
),
DefaultExtractorsFactory().apply {
val isLowRamDevice =
context.getSystemService<ActivityManager>()?.isLowRamDevice == true
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
setTsExtractorTimestampSearchBytes(
when (isLowRamDevice) {
true -> TS_SEARCH_BYTES_LM
false -> TS_SEARCH_BYTES_HM
}
)
setConstantBitrateSeekingEnabled(true)
setConstantBitrateSeekingAlwaysEnabled(true)
}
))
.setPauseAtEndOfMediaItems(true)