Compare commits

...

18 Commits

Author SHA1 Message Date
Niels van Velzen
b653d89285 Fix search input not set to single line 2025-11-17 17:56:35 +01:00
Niels van Velzen
c50a8356b3 Do not clear audio stream index when starting item
(cherry picked from commit 04da7eb69e)
2025-11-17 17:47:51 +01:00
Niels van Velzen
9be3b5f81f Increase log level for useful debug log messages
(cherry picked from commit fd5ff1cdd9)
2025-11-17 17:47:51 +01:00
Niels van Velzen
a7a37054f2 Use media source instead of item in switchAudioStream
(cherry picked from commit 7bc34dce91)
2025-11-17 17:47:50 +01:00
Niels van Velzen
49182a2a71 Use MediaSource runtime when available
(cherry picked from commit ceaaa51812)
2025-11-17 17:47:50 +01:00
Ivan Noleto
f473b3acb5 Fix play all and shuffle album/artist playback (#5135)
(cherry picked from commit 2e2d9ec9b4)
2025-11-17 17:47:49 +01:00
Ivan Noleto
5caf4ac11b Fix music playback sort order to match web client
(cherry picked from commit d3c84df367)
2025-11-17 17:47:49 +01:00
Niels van Velzen
4ec568c311 Fix getting supportedSampleRates on Fire OS throwing NullPointerException
(cherry picked from commit eb362165b1)
2025-11-17 17:47:49 +01:00
Niels van Velzen
3cdeba071c Fix crash in FullDetailsFragmentHelper.resumePlayback
(cherry picked from commit 0d9abb9f17)
2025-11-09 17:32:36 +01:00
Niels van Velzen
d80df757fc Remove display Dolby Vision checks
(cherry picked from commit a330cdcc26)
2025-11-09 17:32:35 +01:00
Niels van Velzen
58b936b334 Disable visibilityThreshold in rememberPlayerProgress
(cherry picked from commit 56ac9ca8ff)
2025-11-09 17:32:35 +01:00
Niels van Velzen
e22267b089 Get audio duration from player instead of item metadata in AudioNowPlayingFragment
(cherry picked from commit 1dba22557d)
2025-11-09 17:32:34 +01:00
Frederik Boster
bf9a5ea5af Fix low quality card images
To avoid low quality card images the scaling is moved to the server-side by requesting images with appropriate dimensions in all cases.

Fixes #4955

(cherry picked from commit f748a566dd)
2025-11-09 17:32:33 +01:00
Niels van Velzen
8fabf7904d Infer subtitle format from media stream path for external players
(cherry picked from commit 55f3fd7b05)
2025-11-09 17:32:32 +01:00
Niels van Velzen
7bbcfb50cd Avoid divide by zero in Seekbar
(cherry picked from commit ffc6765cfe)
2025-11-09 17:32:32 +01:00
Jens van Almsick
b13eaea746 fix: clear only audio stream index on media item start
Before clearPlaybackSessionOptions() was called which also cleared the
subtitle index, but because baking in subs will restart the playback
this lead to an infinite loop.

(cherry picked from commit a1dd6fcfd9)
2025-11-09 17:32:31 +01:00
Niels van Velzen
21b4103cb8 Revert "fix: audio track selection on subsequent video media items"
This reverts commit 5c4ebcf7da.

(cherry picked from commit 3fe6ed6f7b)
2025-11-01 15:09:48 +01:00
Niels van Velzen
540eb286b3 Downgrade AGP to v8.11.1 2025-11-01 14:33:19 +01:00
33 changed files with 148 additions and 171 deletions

View File

@@ -123,7 +123,7 @@ class ServerRepositoryImpl(
}
}
Timber.d(buildString {
Timber.i(buildString {
append("Recommendations: ")
if (greatRecommendation == null) append(0)
else append(1)

View File

@@ -67,7 +67,7 @@ fun Seekbar(
enabled: Boolean = true,
colors: SeekbarColors = SeekbarDefaults.colors(),
) {
val durationMs = duration.inWholeMilliseconds.toFloat()
val durationMs = duration.inWholeMilliseconds.toFloat().coerceAtLeast(1f)
val progressPercentage = progress.inWholeMilliseconds.toFloat() / durationMs
val bufferPercentage = buffer.inWholeMilliseconds.toFloat() / durationMs
val seekForwardPercentage = seekForwardAmount.inWholeMilliseconds.toFloat() / durationMs

View File

@@ -878,7 +878,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
private void refreshCurrentItem() {
if (mCurrentItem == null) return;
Timber.d("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
Timber.i("Refresh item \"%s\"", mCurrentItem.getFullName(requireContext()));
ItemRowAdapterHelperKt.refreshItem(mAdapter, api.getValue(), this, mCurrentItem, () -> {
//Now - if filtered make sure we still pass
if (mAdapter.getFilters() == null) return null;

View File

@@ -112,7 +112,7 @@ class MainActivity : FragmentActivity() {
workManager.enqueue(OneTimeWorkRequestBuilder<LeanbackChannelWorker>().build())
lifecycleScope.launch(Dispatchers.IO) {
Timber.d("MainActivity stopped")
Timber.i("MainActivity stopped")
sessionRepository.restoreSession(destroyOnly = true)
}
}

View File

@@ -43,7 +43,7 @@ fun rememberPlayerProgress(
active: Duration,
duration: Duration,
): Float {
val animatable = remember { Animatable(0f) }
val animatable = remember { Animatable(0f, 0f) }
LaunchedEffect(playing, duration) {
val activeMs = active.inWholeMilliseconds.toFloat()

View File

@@ -231,7 +231,7 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
val adapter = currentRow?.adapter as? ItemRowAdapter ?: return
val item = currentItem ?: return
Timber.d("Refresh item ${item.getFullName(requireContext())}")
Timber.i("Refresh item ${item.getFullName(requireContext())}")
adapter.refreshItem(api, this, item)
}

View File

@@ -250,7 +250,7 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
loadItem(lastPlayedItem.getId());
dataRefreshService.getValue().setLastPlayedItem(null); //blank this out so a detail screen we back up to doesn't also do this
} else {
Timber.d("Updating info after playback");
Timber.i("Updating info after playback");
FullDetailsFragmentHelperKt.getItem(FullDetailsFragment.this, mBaseItem.getId(), item -> {
if (item == null) return null;

View File

@@ -270,12 +270,10 @@ fun FullDetailsFragment.resumePlayback(v: View) {
getString(R.string.msg_video_playback_error),
Toast.LENGTH_LONG
).show()
}
if (nextUpEpisode?.userData?.playbackPositionTicks == 0L) {
} else if (nextUpEpisode.userData?.playbackPositionTicks == 0L) {
play(nextUpEpisode, 0, false)
} else {
showResumeMenu(v, nextUpEpisode!!)
showResumeMenu(v, nextUpEpisode)
}
}
}

View File

@@ -230,7 +230,7 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
}
@Override
public void onProgress(long pos) {
public void onProgress(long pos, long duration) {
if (mCurrentlyPlayingRow != null) {
mCurrentlyPlayingRow.updateCurrentTime(pos);
}
@@ -351,7 +351,7 @@ public class ItemListFragment extends Fragment implements View.OnKeyListener {
}
private void play(List<BaseItemDto> items, int ndx, boolean shuffle) {
Timber.d("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
Timber.i("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
int pos = 0;
BaseItemDto item = items.size() > 0 ? items.get(ndx) : null;

View File

@@ -197,7 +197,7 @@ public class MusicFavoritesListFragment extends Fragment implements View.OnKeyLi
}
@Override
public void onProgress(long pos) {
public void onProgress(long pos, long duration) {
if (mCurrentlyPlayingRow != null) {
mCurrentlyPlayingRow.updateCurrentTime(pos);
}
@@ -283,7 +283,7 @@ public class MusicFavoritesListFragment extends Fragment implements View.OnKeyLi
};
private void play(List<BaseItemDto> items, int ndx, boolean shuffle) {
Timber.d("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
Timber.i("play items: %d, ndx: %d, shuffle: %b", items.size(), ndx, shuffle);
playbackLauncher.getValue().launch(requireContext(), items, 0, false, ndx, shuffle);
}

View File

@@ -130,7 +130,7 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
val seriesPrimaryImage = baseItem?.seriesPrimaryImage
return when {
preferSeriesPoster && seriesPrimaryImage != null -> imageHelper.getImageUrl(seriesPrimaryImage)
preferSeriesPoster && seriesPrimaryImage != null -> imageHelper.getImageUrl(seriesPrimaryImage, fillWidth, fillHeight)
imageType == ImageType.BANNER -> imageHelper.getBannerImageUrl(
requireNotNull(

View File

@@ -71,7 +71,7 @@ public class ItemLauncher {
case BaseItem:
BaseItemDto baseItem = rowItem.getBaseItem();
try {
Timber.d("Item selected: %s (%s)", baseItem.getName(), baseItem.getType().toString());
Timber.i("Item selected: %s (%s)", baseItem.getName(), baseItem.getType().toString());
} catch (Exception e) {
//swallow it
}

View File

@@ -468,18 +468,18 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
return;
}
if (isCurrentlyRetrieving()) {
Timber.d("Not loading more because currently retrieving");
Timber.i("Not loading more because currently retrieving");
return;
}
// This needs tobe based on the actual estimated cards on screen via type of presenter and WindowAlignmentOffsetPercent
if (chunkSize > 0) {
// we can use chunkSize as indicator on when to load
if (pos >= (itemsLoaded - (chunkSize / 1.7))) {
Timber.d("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s> with chunkSize <%s>", pos, itemsLoaded, totalItems, chunkSize);
Timber.i("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s> with chunkSize <%s>", pos, itemsLoaded, totalItems, chunkSize);
retrieveNext();
}
} else if (pos >= itemsLoaded - 20) {
Timber.d("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s>", pos, itemsLoaded, totalItems);
Timber.i("Loading more items trigger pos <%s> itemsLoaded <%s> from total <%s>", pos, itemsLoaded, totalItems);
retrieveNext();
}
}

View File

@@ -47,7 +47,7 @@ fun <T : Any> ItemRowAdapter.setItems(
items: Collection<T>,
transform: (T, Int) -> BaseRowItem?,
) {
Timber.d("Creating items from $itemsLoaded existing and ${items.size} new, adapter size is ${size()}")
Timber.i("Creating items from $itemsLoaded existing and ${items.size} new, adapter size is ${size()}")
val allItems = buildList {
// Add current items before loaded items

View File

@@ -68,7 +68,7 @@ class NavigationRepositoryImpl(
override val currentAction = _currentAction.asSharedFlow()
override fun navigate(destination: Destination, replace: Boolean) {
Timber.d("Navigating to $destination (via navigate function)")
Timber.i("Navigating to $destination (via navigate function)")
val action = when (destination) {
is Destination.Fragment -> NavigationAction.NavigateFragment(destination, true, replace, false)
}
@@ -84,7 +84,7 @@ class NavigationRepositoryImpl(
override fun goBack(): Boolean {
if (fragmentHistory.empty()) return false
Timber.d("Navigating back")
Timber.i("Navigating back")
fragmentHistory.pop()
_currentAction.tryEmit(NavigationAction.GoBack)
return true
@@ -94,7 +94,7 @@ class NavigationRepositoryImpl(
fragmentHistory.clear()
val actualDestination = destination ?: defaultDestination
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory))
Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
Timber.i("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
}
}

View File

@@ -4,7 +4,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
interface AudioEventListener {
fun onPlaybackStateChange(newState: PlaybackController.PlaybackState, currentItem: BaseItemDto?) = Unit
fun onProgress(pos: Long) = Unit
fun onProgress(pos: Long, duration: Long) = Unit
fun onQueueStatusChanged(hasQueue: Boolean) = Unit
fun onQueueReplaced() = Unit
}

View File

@@ -65,7 +65,6 @@ public class AudioNowPlayingFragment extends Fragment {
private TextView mCurrentNdx;
private TextView mCurrentPos;
private TextView mRemainingTime;
private int mCurrentDuration;
private RowsSupportFragment mRowsFragment;
private ArrayObjectAdapter mRowsAdapter;
private PositionableListRowPresenter mAudioQueuePresenter;
@@ -239,7 +238,7 @@ public class AudioNowPlayingFragment extends Fragment {
// 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())
setCurrentTime(0);
setCurrentTime(0, 0);
}
@Override
@@ -258,8 +257,8 @@ public class AudioNowPlayingFragment extends Fragment {
}
@Override
public void onProgress(long pos) {
setCurrentTime(pos);
public void onProgress(long pos, long duration) {
setCurrentTime(pos, duration);
}
@Override
@@ -354,17 +353,17 @@ public class AudioNowPlayingFragment extends Fragment {
mAlbumTitle.setText(null);
}
mCurrentNdx.setText(getString(R.string.lbl_now_playing_track, mediaManager.getValue().getCurrentAudioQueueDisplayPosition(), mediaManager.getValue().getCurrentAudioQueueDisplaySize()));
mCurrentDuration = ((Long) ((item.getRunTimeTicks() != null ? item.getRunTimeTicks() : 0) / 10000)).intValue();
addGenres(mGenreRow);
backgroundService.getValue().setBackground(item);
}
}
public void setCurrentTime(long time) {
// Round the current time as otherwise the time played and time remaining will not be in sync
public void setCurrentTime(long time, long duration) {
// Round the time to seconds so both the position and remaining times are in sync
time = Math.round(time / 1000L) * 1000L;
mCurrentPos.setText(TimeUtils.formatMillis(time));
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");
mRemainingTime.setText("-" + TimeUtils.formatMillis(duration - time));
}
private void addGenres(TextView textView) {

View File

@@ -698,7 +698,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
@Override
public void onStop() {
super.onStop();
Timber.d("Stopping!");
Timber.i("Stopping!");
if (leanbackOverlayFragment != null)
leanbackOverlayFragment.setOnKeyInterceptListener(null);
@@ -706,7 +706,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// end playback from here if this fragment belongs to the current session.
// if it doesn't, playback has already been stopped elsewhere, and the references to this have been replaced
if (playbackControllerContainer.getValue().getPlaybackController() != null && playbackControllerContainer.getValue().getPlaybackController().getFragment() == this) {
Timber.d("this fragment belongs to the current session, ending it");
Timber.i("this fragment belongs to the current session, ending it");
playbackControllerContainer.getValue().getPlaybackController().endPlayback();
}

View File

@@ -119,12 +119,16 @@ class ExternalPlayerActivity : FragmentActivity() {
?.sortedWith(compareBy<MediaStream> { it.isDefault }.thenBy { it.index })
.orEmpty()
val subtitleUrls = externalSubtitles.map {
val subtitleUrls = externalSubtitles.map { mediaStream ->
// We cannot use the DeliveryUrl as that is only populated when using the playback info API, which we skip as we'll always direct
// play when using external players. We need to infer the subtitle format based on its path (similar to how the server
// calculates it)
val format = mediaStream.path?.substringAfterLast('.', missingDelimiterValue = mediaStream.codec.orEmpty()) ?: "srt"
api.subtitleApi.getSubtitleUrl(
routeItemId = item.id,
routeMediaSourceId = mediaSource.id.toString(),
routeIndex = it.index,
routeFormat = it.codec.orEmpty(),
routeIndex = mediaStream.index,
routeFormat = format,
)
}.toTypedArray()
val subtitleNames = externalSubtitles.map { it.displayTitle ?: it.title.orEmpty() }.toTypedArray()

View File

@@ -240,7 +240,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void playerErrorEncountered() {
// 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);
Timber.i("playback stabilized - retry count reset to 0 from %s", playbackRetries);
playbackRetries = 0;
}
@@ -270,7 +270,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
mDisplayModes = display.getSupportedModes();
Timber.i("** Available display refresh rates:");
for (Display.Mode mDisplayMode : mDisplayModes) {
Timber.d("display mode %s - %dx%d@%f", mDisplayMode.getModeId(), mDisplayMode.getPhysicalWidth(), mDisplayMode.getPhysicalHeight(), mDisplayMode.getRefreshRate());
Timber.i("display mode %s - %dx%d@%f", mDisplayMode.getModeId(), mDisplayMode.getPhysicalWidth(), mDisplayMode.getPhysicalHeight(), mDisplayMode.getRefreshRate());
}
}
@@ -301,7 +301,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (rate != sourceRate && rate != sourceRate * 2 && rate != Math.round(sourceRate * 2.5)) // Skip inappropriate rates
continue;
Timber.d("qualifying display mode: %s - %dx%d@%f", mode.getModeId(), mode.getPhysicalWidth(), mode.getPhysicalHeight(), mode.getRefreshRate());
Timber.i("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
@@ -435,7 +435,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
BaseItemDto item = getCurrentlyPlayingItem();
if (item == null) {
Timber.d("item is null - aborting play");
Timber.w("item is null - aborting play");
Utils.showToast(mFragment.getContext(), mFragment.getString(R.string.msg_cannot_play));
mFragment.closePlayer();
return;
@@ -607,13 +607,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
private void startItem(BaseItemDto item, long position, StreamInfo response) {
if (!hasInitializedVideoManager() || !hasFragment()) {
Timber.d("Error - attempting to play without:%s%s", hasInitializedVideoManager() ? "" : " [videoManager]", hasFragment() ? "" : " [overlay fragment]");
Timber.w("Error - attempting to play without:%s%s", hasInitializedVideoManager() ? "" : " [videoManager]", hasFragment() ? "" : " [overlay fragment]");
return;
}
// clear options on start of every item
clearPlaybackSessionOptions();
mStartPosition = position;
mCurrentStreamInfo = response;
mCurrentOptions.setMediaSourceId(response.getMediaSource().getId());
@@ -633,8 +630,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
// get subtitle info
mCurrentOptions.setSubtitleStreamIndex(response.getMediaSource().getDefaultSubtitleStreamIndex() != null ? response.getMediaSource().getDefaultSubtitleStreamIndex() : null);
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", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
Timber.i("default audio index set to %s remote default %s", mDefaultAudioIndex, response.getMediaSource().getDefaultAudioStreamIndex());
Timber.i("default sub index set to %s remote default %s", mCurrentOptions.getSubtitleStreamIndex(), response.getMediaSource().getDefaultSubtitleStreamIndex());
Long mbPos = position * 10000;
@@ -759,15 +756,15 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (!(isPlaying() || isPaused()) || index < 0)
return;
BaseItemDto currentItem = getCurrentlyPlayingItem();
if (currentItem == null
|| currentItem.getMediaStreams() == null
|| index >= currentItem.getMediaStreams().size()) {
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
if (currentMediaSource == null
|| currentMediaSource.getMediaStreams() == null
|| index >= currentMediaSource.getMediaStreams().size()) {
return;
}
String lastAudioIsoCode = videoQueueManager.getValue().getLastPlayedAudioLanguageIsoCode();
String currentAudioIsoCode = currentItem.getMediaStreams().get(index).getLanguage();
String currentAudioIsoCode = currentMediaSource.getMediaStreams().get(index).getLanguage();
if (currentAudioIsoCode != null
&& (lastAudioIsoCode == null || !lastAudioIsoCode.equals(currentAudioIsoCode))) {
@@ -777,11 +774,11 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
int currAudioIndex = getAudioStreamIndex();
Timber.d("trying to switch audio stream from %s to %s", currAudioIndex, index);
Timber.i("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);
Timber.i("setting mCurrentOptions audio stream index from %s to %s", mCurrentOptions.getAudioStreamIndex(), index);
mCurrentOptions.setAudioStreamIndex(index);
}
return;
@@ -790,12 +787,12 @@ public class PlaybackController implements PlaybackControllerNotifiable {
// get current timestamp first
refreshCurrentPosition();
if (!isTranscoding() && mVideoManager.setExoPlayerTrack(index, MediaStreamType.AUDIO, getCurrentlyPlayingItem().getMediaStreams())) {
mCurrentOptions.setMediaSourceId(getCurrentMediaSource().getId());
if (!isTranscoding() && mVideoManager.setExoPlayerTrack(index, MediaStreamType.AUDIO, currentMediaSource.getMediaStreams())) {
mCurrentOptions.setMediaSourceId(currentMediaSource.getId());
mCurrentOptions.setAudioStreamIndex(index);
} else {
startSpinner();
mCurrentOptions.setMediaSourceId(getCurrentMediaSource().getId());
mCurrentOptions.setMediaSourceId(currentMediaSource.getId());
mCurrentOptions.setAudioStreamIndex(index);
stop();
playInternal(getCurrentlyPlayingItem(), mCurrentPosition, mCurrentOptions);
@@ -804,7 +801,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
public void pause() {
Timber.d("pause called at %s", mCurrentPosition);
Timber.i("pause called at %s", mCurrentPosition);
// if playback is paused and the seekbar is scrubbed, it will call pause even if already paused
if (mPlaybackState == PlaybackState.PAUSED) {
Timber.d("already paused, ignoring");
@@ -838,7 +835,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void stop() {
refreshCurrentPosition();
Timber.d("stop called at %s", mCurrentPosition);
Timber.i("stop called at %s", mCurrentPosition);
stopReportLoop();
if (mPlaybackState != PlaybackState.IDLE && mPlaybackState != PlaybackState.UNDEFINED) {
mPlaybackState = PlaybackState.IDLE;
@@ -887,7 +884,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
wasSeeking = false;
burningSubs = false;
mCurrentStreamInfo = null;
mCurrentOptions.setAudioStreamIndex(null);
}
public void next() {
@@ -897,7 +893,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
resetPlayerErrors();
mCurrentIndex++;
videoQueueManager.getValue().setCurrentMediaPosition(mCurrentIndex);
Timber.d("Moving to index: %d out of %d total items.", mCurrentIndex, mItems.size());
Timber.i("Moving to index: %d out of %d total items.", mCurrentIndex, mItems.size());
spinnerOff = false;
play(0);
}
@@ -910,7 +906,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
resetPlayerErrors();
mCurrentIndex--;
videoQueueManager.getValue().setCurrentMediaPosition(mCurrentIndex);
Timber.d("Moving to index: %d out of %d total items.", mCurrentIndex, mItems.size());
Timber.i("Moving to index: %d out of %d total items.", mCurrentIndex, mItems.size());
spinnerOff = false;
play(0);
}
@@ -933,7 +929,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
public void seek(long pos, boolean skipToNext) {
if (pos <= 0) pos = 0;
Timber.d("Trying to seek from %s to %d", mCurrentPosition, pos);
Timber.i("Trying to seek from %s to %d", mCurrentPosition, pos);
Timber.d("Container: %s", mCurrentStreamInfo == null ? "unknown" : mCurrentStreamInfo.getContainer());
if (!hasInitializedVideoManager()) {
@@ -1032,8 +1028,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
refreshCurrentPosition();
currentSkipPos = Utils.getSafeSeekPosition((currentSkipPos == 0 ? mCurrentPosition : currentSkipPos) + msec, getDuration());
Timber.d("Skip amount requested was %s. Calculated position is %s", msec, currentSkipPos);
Timber.d("Duration reported as: %s current pos: %s", getDuration(), mCurrentPosition);
Timber.i("Skip amount requested was %s. Calculated position is %s", msec, currentSkipPos);
Timber.i("Duration reported as: %s current pos: %s", getDuration(), mCurrentPosition);
mSeekPosition = currentSkipPos;
mHandler.postDelayed(skipRunnable, 800);
@@ -1158,7 +1154,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
return;
}
Timber.d("Moving to next queue item. Index: %s", (mCurrentIndex + 1));
Timber.i("Moving to next queue item. Index: %s", (mCurrentIndex + 1));
boolean stillWatchingEnabled = userPreferences.getValue().get(UserPreferences.Companion.getStillWatchingBehavior()) != StillWatchingBehavior.DISABLED;
boolean nextUpEnabled = userPreferences.getValue().get(UserPreferences.Companion.getNextUpBehavior()) != NextUpBehavior.DISABLED;
if ((stillWatchingEnabled || nextUpEnabled) && curItem.getType() != BaseItemKind.TRAILER) {
@@ -1247,7 +1243,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
@Override
public void onCompletion() {
Timber.d("On Completion fired");
Timber.i("On Completion fired");
itemComplete();
}
@@ -1274,6 +1270,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
if (hasInitializedVideoManager()) {
duration = mVideoManager.getDuration();
} else if (getCurrentMediaSource() != null && getCurrentMediaSource().getRunTimeTicks() != null) {
duration = getCurrentMediaSource().getRunTimeTicks() / 10000;
} else if (getCurrentlyPlayingItem() != null && getCurrentlyPlayingItem().getRunTimeTicks() != null) {
duration = getCurrentlyPlayingItem().getRunTimeTicks() / 10000;
}

View File

@@ -161,7 +161,7 @@ public class VideoManager {
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, int reason) {
// discontinuity for reason internal usually indicates an error, and that the player will reset to its default timestamp
if (reason == Player.DISCONTINUITY_REASON_INTERNAL) {
Timber.d("Caught player discontinuity (reason internal) - oldPos: %s newPos: %s", oldPosition.positionMs, newPosition.positionMs);
Timber.i("Caught player discontinuity (reason internal) - oldPos: %s newPos: %s", oldPosition.positionMs, newPosition.positionMs);
}
}
@@ -431,7 +431,7 @@ public class VideoManager {
id = Integer.parseInt(trackFormat.id);
}
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
Timber.w("failed to parse track ID [%s]", trackFormat.id);
break;
}
matchedIndex = id;
@@ -489,7 +489,7 @@ public class VideoManager {
boolean isSelected = groupInfo.isTrackSelected(i);
Format trackFormat = group.getFormat(i);
Timber.d("track %s group %s/%s trackType %s label %s mime %s isSelected %s isSupported %s",
Timber.i("track %s group %s/%s trackType %s label %s mime %s isSelected %s isSupported %s",
trackFormat.id, i + 1, group.length, trackType, trackFormat.label, trackFormat.sampleMimeType, isSelected, isSupported);
if (trackType != chosenTrackType || trackFormat.id == null)
@@ -505,7 +505,7 @@ public class VideoManager {
if (id != exoTrackID)
continue;
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
Timber.w("failed to parse track ID [%s]", trackFormat.id);
continue;
}
@@ -519,7 +519,7 @@ public class VideoManager {
return true;
}
Timber.d("matched exoplayer track %s to mediaStream track %s", trackFormat.id, index);
Timber.i("matched exoplayer track %s to mediaStream track %s", trackFormat.id, index);
matchedGroup = group;
}
}
@@ -532,7 +532,7 @@ public class VideoManager {
mExoPlayerSelectionParams.setOverrideForType(new TrackSelectionOverride(matchedGroup, 0));
mExoPlayer.setTrackSelectionParameters(mExoPlayerSelectionParams.build());
} catch (Exception e) {
Timber.d("Error setting track selection");
Timber.w("Error setting track selection");
return false;
}
return true;

View File

@@ -65,8 +65,11 @@ public class VideoPlayerAdapter extends PlayerAdapter {
@Override
public long getDuration() {
return getCurrentlyPlayingItem() != null && getCurrentlyPlayingItem().getRunTimeTicks() != null ?
getCurrentlyPlayingItem().getRunTimeTicks() / 10000 : -1;
Long runTimeTicks = null;
if (getCurrentMediaSource() != null) runTimeTicks = getCurrentMediaSource().getRunTimeTicks();
if (runTimeTicks == null && getCurrentlyPlayingItem() != null) runTimeTicks = getCurrentlyPlayingItem().getRunTimeTicks();
if (runTimeTicks != null) return runTimeTicks / 10000;
return -1;
}
@Override

View File

@@ -99,7 +99,7 @@ class RewriteMediaManager(
launch {
while (true) {
notifyListeners {
onProgress(playbackManager.state.positionInfo.active.inWholeMilliseconds)
onProgress(playbackManager.state.positionInfo.active.inWholeMilliseconds, playbackManager.state.positionInfo.duration.inWholeMilliseconds)
}
delay(@Suppress("MagicNumber") 100)
}

View File

@@ -391,10 +391,17 @@ public class CardPresenter extends Presenter {
int fillWidth = Math.round(holder.getCardWidth() * holder.mCardView.getResources().getDisplayMetrics().density);
int fillHeight = Math.round(holder.getCardHeight() * holder.mCardView.getResources().getDisplayMetrics().density);
holder.updateCardViewImage(
image == null ? rowItem.getImageUrl(holder.mCardView.getContext(), imageHelper.getValue(), mImageType, fillWidth, fillHeight) : imageHelper.getValue().getImageUrl(image),
image == null ? null : image.getBlurHash()
);
final String imageUrl;
final String blurHash;
if (image == null) {
imageUrl = rowItem.getImageUrl(holder.mCardView.getContext(), imageHelper.getValue(), mImageType, fillWidth, fillHeight);
blurHash = null;
} else {
imageUrl = imageHelper.getValue().getImageUrl(image, fillWidth, fillHeight);
blurHash = image.getBlurHash();
}
holder.updateCardViewImage(imageUrl, blurHash);
}
@Override

View File

@@ -13,6 +13,7 @@ import org.jellyfin.androidtv.util.apiclient.itemImages
import org.jellyfin.sdk.model.api.ImageType
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.math.roundToInt
class UserViewCardPresenter(
val small: Boolean,
@@ -25,10 +26,24 @@ class UserViewCardPresenter(
fun setItem(rowItem: BaseRowItem?) {
val baseItem = rowItem?.baseItem
// Determine size
val cardWidth: Int
val cardHeight: Int
if (small) {
cardWidth = 133
cardHeight = 75
} else {
cardWidth = 224
cardHeight = 126
}
val fillWidth = (cardWidth * cardView.resources.displayMetrics.density).roundToInt()
val fillHeight = (cardHeight * cardView.resources.displayMetrics.density).roundToInt()
// Load image
val image = baseItem?.itemImages[ImageType.PRIMARY]
cardView.mainImageView.load(
url = image?.let(imageHelper::getImageUrl),
url = image?.let { imageHelper.getImageUrl(it, fillWidth, fillHeight) },
blurHash = image?.blurHash,
placeholder = ContextCompat.getDrawable(cardView.context, R.drawable.tile_land_folder),
aspectRatio = ImageHelper.ASPECT_RATIO_16_9,
@@ -39,11 +54,7 @@ class UserViewCardPresenter(
cardView.setTitleText(rowItem?.getName(cardView.context))
// Set size
if (small) {
cardView.setMainImageDimensions(133, 75)
} else {
cardView.setMainImageDimensions(224, 126)
}
cardView.setMainImageDimensions(cardWidth, cardHeight)
}
}

View File

@@ -53,6 +53,7 @@ fun SearchTextInput(
BasicTextField(
modifier = modifier,
value = query,
singleLine = true,
interactionSource = interactionSource,
onValueChange = { onQueryChange(it) },
keyboardActions = KeyboardActions { onQuerySubmit() },

View File

@@ -136,7 +136,7 @@ class StartupActivity : FragmentActivity() {
}?.toUUIDOrNull()
val itemIsUserView = intent.getBooleanExtra(EXTRA_ITEM_IS_USER_VIEW, false)
Timber.d("Determining next activity (action=${intent.action}, itemId=$itemId, itemIsUserView=$itemIsUserView)")
Timber.i("Determining next activity (action=${intent.action}, itemId=$itemId, itemIsUserView=$itemIsUserView)")
// Start session
(application as? JellyfinApplication)?.onSessionStart()
@@ -167,7 +167,7 @@ class StartupActivity : FragmentActivity() {
val intent = Intent(this, MainActivity::class.java)
// Clear navigation history
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME)
Timber.d("Opening next activity $intent")
Timber.i("Opening next activity $intent")
startActivity(intent)
finishAfterTransition()
}

View File

@@ -31,7 +31,8 @@ class ImageHelper(
const val MAX_PRIMARY_IMAGE_HEIGHT: Int = 370
}
fun getImageUrl(image: JellyfinImage): String = image.getUrl(api)
fun getImageUrl(image: JellyfinImage, fillWidth: Int, fillHeight: Int): String =
image.getUrl(api, null, null, fillWidth, fillHeight)
fun getImageAspectRatio(item: BaseItemDto, preferParentThumb: Boolean): Double {
if (preferParentThumb && (item.parentThumbItemId != null || item.seriesThumbImageTag != null)) {

View File

@@ -6,7 +6,6 @@ import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
import android.util.Size
import android.view.Display
import androidx.core.content.ContextCompat
import timber.log.Timber
@@ -16,13 +15,6 @@ class MediaCodecCapabilitiesTest(
private val display by lazy { ContextCompat.getDisplayOrDefault(context) }
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
@Suppress("DEPRECATION")
private val supportedHdrTypes by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) display.mode.supportedHdrTypes.toList()
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) display.hdrCapabilities.supportedHdrTypes.toList()
else emptyList()
}
// Map common Dolby Vision Profiles to their corresponding CodecProfileLevel constant
private object DolbyVisionProfiles {
val Profile5: Int by lazy {
@@ -294,16 +286,4 @@ class MediaCodecCapabilitiesTest(
return Size(maxWidth, maxHeight)
}
fun supportsDolbyVision(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
}
fun supportsHdr10(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10)
}
fun supportsHdr10Plus(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && supportedHdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS)
}
}

View File

@@ -98,10 +98,6 @@ fun createDeviceProfile(
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
/// HDR capabilities
// Display
val supportsDolbyVisionDisplay = mediaTest.supportsDolbyVision()
val supportsHdr10Display = mediaTest.supportsHdr10()
val supportsHdr10PlusDisplay = mediaTest.supportsHdr10Plus()
// Codecs
// AV1
@@ -366,32 +362,8 @@ fun createDeviceProfile(
/// HDR exclude list
// TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypes = buildSet {
if (jellyfinTenEleven) add("DOVIInvalid")
if (!supportsDolbyVisionDisplay) {
add(VideoRangeType.DOVI.serialName)
if (jellyfinTenEleven) {
add("DOVIWithEL")
if (!supportsHdr10PlusDisplay) {
add("DOVIWithHDR10Plus")
add("DOVIWithELHDR10Plus")
}
}
if (!supportsHdr10Display) add(VideoRangeType.DOVI_WITH_HDR10.serialName)
}
if (!supportsHdr10PlusDisplay) {
add(VideoRangeType.HDR10_PLUS.serialName)
if (!supportsHdr10Display) add(VideoRangeType.HDR10.serialName)
}
}
val unsupportedRangeTypesAv1 = buildSet {
// Base of unsupported types for display
addAll(unsupportedRangeTypes)
if (jellyfinTenEleven) add("DOVIInvalid")
if (!supportsAV1DolbyVision) {
add(VideoRangeType.DOVI.serialName)
@@ -408,8 +380,7 @@ fun createDeviceProfile(
// TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK
val unsupportedRangeTypesHevc = buildSet {
// Base of unsupported types for display
addAll(unsupportedRangeTypes)
if (jellyfinTenEleven) add("DOVIInvalid")
if (!supportsHevcDolbyVisionEL) {
if (jellyfinTenEleven) {
@@ -440,21 +411,10 @@ fun createDeviceProfile(
// The notEquals condition will always fail the ConditionProcessor test in the server so we use applyConditions to only have the codec
// profile be active when the media in question uses one of the unsupported range types. The server will then use the value of the
// notEquals in the StreamBuilder to create a correct transcode pipeline
if (unsupportedRangeTypes.isNotEmpty()) codecProfile {
type = CodecType.VIDEO
conditions {
ProfileConditionValue.VIDEO_RANGE_TYPE notEquals unsupportedRangeTypes.joinToString("|")
}
applyConditions {
ProfileConditionValue.VIDEO_RANGE_TYPE inCollection unsupportedRangeTypes
}
}
// Codecs
// AV1
if (unsupportedRangeTypesAv1.isNotEmpty() && unsupportedRangeTypesAv1 != unsupportedRangeTypes) codecProfile {
if (unsupportedRangeTypesAv1.isNotEmpty()) codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.AV1
@@ -468,7 +428,7 @@ fun createDeviceProfile(
}
// HEVC
if (unsupportedRangeTypesHevc.isNotEmpty() && unsupportedRangeTypesHevc != unsupportedRangeTypes) codecProfile {
if (unsupportedRangeTypesHevc.isNotEmpty()) codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.HEVC

View File

@@ -117,7 +117,8 @@ fun createDeviceProfileReport(
appendLine(" - inputChannelCountRanges: ${it.joinToString(", ") { it.prettyFormat() }}")
}
audio.bitrateRange?.let { appendLine(" - bitrateRange: ${it.prettyFormat()}") }
audio.supportedSampleRates?.takeIf { it.isNotEmpty() }?.let {
// Note: Fire OS has a bug in the getter for supportedSampleRates that throws NullPointerException
runCatching { audio.supportedSampleRates }.getOrNull()?.takeIf { it.isNotEmpty() }?.let {
appendLine(" - supportedSampleRates: ${it.joinToString(", ")}")
}
audio.supportedSampleRateRanges?.takeIf { it.isNotEmpty() }?.let {
@@ -183,20 +184,24 @@ fun createDeviceProfileReport(
val mediaTest = MediaCodecCapabilitiesTest(context)
val codecHDRSupport = buildMap<String, Map<HdrFormats, Boolean>> {
if(mediaTest.supportsAV1()) {
put(Codec.Video.AV1, mapOf(
HdrFormats.DOLBY_VISION to mediaTest.supportsAV1DolbyVision(),
HdrFormats.HDR10 to mediaTest.supportsAV1HDR10(),
HdrFormats.HDR10_PLUS to mediaTest.supportsAV1HDR10Plus()
))
if (mediaTest.supportsAV1()) {
put(
Codec.Video.AV1, mapOf(
HdrFormats.DOLBY_VISION to mediaTest.supportsAV1DolbyVision(),
HdrFormats.HDR10 to mediaTest.supportsAV1HDR10(),
HdrFormats.HDR10_PLUS to mediaTest.supportsAV1HDR10Plus()
)
)
}
if(mediaTest.supportsHevc()) {
put(Codec.Video.HEVC, mapOf(
HdrFormats.DOLBY_VISION to mediaTest.supportsHevcDolbyVision(),
HdrFormats.DOLBY_VISION_EL to mediaTest.supportsHevcDolbyVisionEL(),
HdrFormats.HDR10 to mediaTest.supportsHevcHDR10(),
HdrFormats.HDR10_PLUS to mediaTest.supportsHevcHDR10Plus()
))
if (mediaTest.supportsHevc()) {
put(
Codec.Video.HEVC, mapOf(
HdrFormats.DOLBY_VISION to mediaTest.supportsHevcDolbyVision(),
HdrFormats.DOLBY_VISION_EL to mediaTest.supportsHevcDolbyVisionEL(),
HdrFormats.HDR10 to mediaTest.supportsHevcHDR10(),
HdrFormats.HDR10_PLUS to mediaTest.supportsHevcHDR10Plus()
)
)
}
}

View File

@@ -24,6 +24,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFilter
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks
@@ -151,8 +152,11 @@ class SdkPlaybackHelper(
val response by api.itemsApi.getItems(
isMissing = false,
mediaTypes = listOf(MediaType.AUDIO),
filters = listOf(ItemFilter.IS_NOT_FOLDER),
sortBy = if (shuffle) listOf(ItemSortBy.RANDOM) else listOf(
ItemSortBy.ALBUM_ARTIST,
ItemSortBy.ALBUM,
ItemSortBy.PARENT_INDEX_NUMBER,
ItemSortBy.INDEX_NUMBER,
ItemSortBy.SORT_NAME
),
recursive = true,
@@ -168,7 +172,13 @@ class SdkPlaybackHelper(
val response by api.itemsApi.getItems(
isMissing = false,
mediaTypes = listOf(MediaType.AUDIO),
sortBy = if (shuffle) listOf(ItemSortBy.RANDOM) else listOf(ItemSortBy.SORT_NAME),
filters = listOf(ItemFilter.IS_NOT_FOLDER),
sortBy = if (shuffle) listOf(ItemSortBy.RANDOM) else listOf(
ItemSortBy.ALBUM,
ItemSortBy.PARENT_INDEX_NUMBER,
ItemSortBy.INDEX_NUMBER,
ItemSortBy.SORT_NAME
),
recursive = true,
limit = ITEM_QUERY_LIMIT,
fields = ItemRepository.itemFields,

View File

@@ -4,7 +4,7 @@ accompanist = "0.37.3"
acra = "5.13.1"
android-compileSdk = "36"
android-desugar = "2.1.5"
android-gradle = "8.13.0"
android-gradle = "8.11.1"
android-minSdk = "21"
android-targetSdk = "36"
androidx-activity = "1.11.0"