Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d84011d81 | ||
|
|
36a307cd7f | ||
|
|
2a122803c2 | ||
|
|
dbf6dc0f9a | ||
|
|
fd663aff39 | ||
|
|
30e34568c1 | ||
|
|
5fb77314c4 | ||
|
|
d436c5de3d | ||
|
|
8157e3e5c1 | ||
|
|
3656c18e8f | ||
|
|
9f48b7da39 | ||
|
|
ef899f98bc | ||
|
|
de6c7904f0 | ||
|
|
0a79d5b60f | ||
|
|
36e0bc1edc | ||
|
|
8faeb3aac1 | ||
|
|
160ef4914f | ||
|
|
474c0774f1 | ||
|
|
0c9ba1ccec | ||
|
|
0c667ab615 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = get(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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) : "");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -518,16 +520,16 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
internalOptions.setMaxBitrate(maxBitrate);
|
||||
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
|
||||
internalOptions.setEnableDirectStream(false);
|
||||
internalOptions.setMaxAudioChannels(Utils.downMixAudio(mFragment.getContext()) ? 2 : null); //have to downmix at server
|
||||
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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,9 +217,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 {
|
||||
|
||||
@@ -20,15 +20,15 @@ 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-rc02"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user