Compare commits

...

36 Commits

Author SHA1 Message Date
Niels van Velzen
dc4af31285 Fix ExoPlayerBackend.playItem not calling exoPlayer.play 2026-02-03 13:41:00 +01:00
Niels van Velzen
8d5bd8fd4b Revert "Bump upcomingMinimumServerVersion to 10.11"
This reverts commit f56faa2ad0.
2026-02-03 13:39:04 +01:00
Niels van Velzen
fdbea3e613 Specify audio attributes in VideoManager
(cherry picked from commit 7acfc41498)
2026-02-03 13:38:50 +01:00
Niels van Velzen
b323af2f11 Increase amount of cached MediaItems to 10
(cherry picked from commit 913beda916)
2026-02-03 13:38:41 +01:00
Niels van Velzen
5bd6d9f2c2 Support layout direction in ToolbarLayout
(cherry picked from commit 59a36bacf1)
2026-02-03 13:38:33 +01:00
Niels van Velzen
3258933d39 Fix 720 and 420 Kbit/s bitrate options not working
(cherry picked from commit a01d539b92)
2026-02-03 13:38:25 +01:00
Niels van Velzen
3a6fdfece9 Run build workflow on release branches
(cherry picked from commit 81d84e47aa)
2025-12-28 16:44:26 +01:00
Niels van Velzen
23a5572024 Fix AVC high 10 reporting as main 10 in device profile
(cherry picked from commit 9f328d0281)
2025-12-25 11:21:28 +01:00
tobster093
9a3022a590 Fix Live TV direct play stability (#5229)
(cherry picked from commit ac89159455)
2025-12-23 10:53:24 +01:00
Niels van Velzen
f56faa2ad0 Bump upcomingMinimumServerVersion to 10.11
(cherry picked from commit 0e7f52fd42)
2025-12-23 10:53:11 +01:00
Niels van Velzen
5e1883935d Disallow seeking when duration is unknown
(cherry picked from commit 66cafdd3c8)
2025-12-13 10:56:51 +01:00
Niels van Velzen
69d6283cd5 Use AAC over HLS for music transcoding
(cherry picked from commit 25161f11db)
2025-12-13 10:56:45 +01:00
Damontecres
1b492c573d Add VC-1 to device direct play profile
Adds direct play support for VC-1 video codec on devices with a builtin decoder

(cherry picked from commit 6674239bae)
2025-12-13 10:56:40 +01:00
Jens van Almsick
d1909aef27 utilize group id for track selection
group ids always start at 0 so we don't need the ```indexStartsAtOne```
parameter anymore

(cherry picked from commit 66573c399f)
2025-12-01 11:34:02 +01:00
Jens van Almsick
6cea36f909 clear audio stream index on item start
(cherry picked from commit a1e6b17faf)
2025-12-01 11:34:01 +01:00
Niels van Velzen
7745856646 Fix tinting in account switcher button fallback icon
(cherry picked from commit 07e3809227)
2025-12-01 11:33:31 +01:00
clams4shoes
788f585199 Add AV1 detection support for older Android versions (#5186)
* Fix AV1 support on Fire OS

* Use API checks instead of reflection

(cherry picked from commit da71c6f6a0)
2025-12-01 11:33:02 +01:00
Douglas Newman
90f941fc9a Fix Vimu Player Integration (#5187)
* fix activity result status code handling for vimu

* Fixup

---------

Co-authored-by: Niels van Velzen <git@ndat.nl>
(cherry picked from commit 7e95582f89)
2025-12-01 11:32:56 +01:00
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
42 changed files with 318 additions and 242 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- master
- release-*
pull_request:
permissions:

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

@@ -66,6 +66,7 @@ object Codec {
const val VP8 = "vp8"
const val VP9 = "vp9"
const val AV1 = "av1"
const val VC1 = "vc1"
}
object Subtitle {

View File

@@ -27,6 +27,7 @@ import org.jellyfin.playback.media3.session.media3SessionPlugin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.koin.android.ext.koin.androidContext
import kotlin.time.Duration
import org.koin.core.scope.Scope
import org.koin.dsl.module
import kotlin.time.Duration.Companion.milliseconds
@@ -39,10 +40,12 @@ val playbackModule = module {
single { PlaybackLauncher(get(), get(), get(), get()) }
// OkHttp data source using OkHttpFactory from SDK
single<HttpDataSource.Factory> {
val okHttpFactory = get<OkHttpFactory>()
val httpClientOptions = get<HttpClientOptions>()
val httpClientOptions = get<HttpClientOptions>().copy(
// Disable request timeout for media playback as this causes issues with Live TV
requestTimeout = Duration.ZERO
)
OkHttpDataSource.Factory(okHttpFactory.createClient(httpClientOptions))
}

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,19 @@ 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) : "");
if (duration == 0L) mRemainingTime.setText(null);
else 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

@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
@@ -62,6 +63,8 @@ class ExternalPlayerActivity : FragmentActivity() {
private const val API_VIMU_TITLE = "forcename"
private const val API_VIMU_SEEK_POSITION = "startfrom"
private const val API_VIMU_RESUME = "forceresume"
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
private const val API_VIMU_RESULT_ERROR = 4
// The extra keys used by various video players to read the end position
private val resultPositionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
@@ -75,7 +78,7 @@ class ExternalPlayerActivity : FragmentActivity() {
Timber.i("Playback finished with result code ${result.resultCode}")
videoQueueManager.setCurrentMediaPosition(videoQueueManager.getCurrentMediaPosition() + 1)
if (result.resultCode != RESULT_OK) {
if (result.isError) {
Toast.makeText(this, R.string.video_error_unknown_error, Toast.LENGTH_LONG).show()
finish()
} else {
@@ -83,6 +86,11 @@ class ExternalPlayerActivity : FragmentActivity() {
}
}
private val ActivityResult.isError get() = when (data?.action) {
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_ERROR
else -> resultCode != RESULT_OK
}
private var currentItem: Pair<BaseItemDto, MediaSourceInfo>? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -119,12 +127,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,12 +607,11 @@ 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();
mCurrentOptions.setAudioStreamIndex(null); // reset audio stream index to allow auto selection on new item
mStartPosition = position;
mCurrentStreamInfo = response;
@@ -633,8 +632,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 +758,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 +776,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 +789,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 +803,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 +837,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 +886,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
wasSeeking = false;
burningSubs = false;
mCurrentStreamInfo = null;
mCurrentOptions.setAudioStreamIndex(null);
}
public void next() {
@@ -897,7 +895,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 +908,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 +931,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 +1030,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 +1156,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 +1245,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
@Override
public void onCompletion() {
Timber.d("On Completion fired");
Timber.i("On Completion fired");
itemComplete();
}
@@ -1274,6 +1272,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

@@ -40,9 +40,11 @@ private fun createStreamInfo(
source.isRemote && source.path != null -> source.path
else -> api.videosApi.getVideoStreamUrl(
itemId = itemId,
container = container,
mediaSourceId = source.id,
static = true,
tag = source.eTag,
liveStreamId = source.liveStreamId,
)
}
} else if (options.enableDirectStream && source.supportsDirectStream) {

View File

@@ -16,6 +16,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.core.graphics.TypefaceCompat;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
@@ -161,7 +162,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);
}
}
@@ -220,6 +221,11 @@ public class VideoManager {
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
exoPlayerBuilder.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build(), true);
return exoPlayerBuilder;
}
@@ -377,7 +383,7 @@ public class VideoManager {
}
}
private int offsetStreamIndex(int index, boolean adjustByAdding, boolean indexStartsAtOne, @Nullable List<org.jellyfin.sdk.model.api.MediaStream> allStreams) {
private int offsetStreamIndex(int index, boolean adjustByAdding, @Nullable List<org.jellyfin.sdk.model.api.MediaStream> allStreams) {
if (index < 0 || allStreams == null)
return -1;
@@ -394,7 +400,6 @@ public class VideoManager {
break;
index += adjustByAdding ? 1 : -1;
}
index += indexStartsAtOne ? (adjustByAdding ? -1 : 1) : 0;
return index < 0 || index > allStreams.size() ? -1 : index;
}
@@ -416,26 +421,22 @@ public class VideoManager {
@C.TrackType int trackType = groupInfo.getType();
TrackGroup group = groupInfo.getMediaTrackGroup();
for (int i = 0; i < group.length; i++) {
// Individual track information.
Format trackFormat = group.getFormat(i);
if (trackType == chosenTrackType) {
if (groupInfo.isTrackSelected(i)) {
// we found the track, set to -1 first to handle failed int parsing
matchedIndex = -1;
if (trackFormat.id != null) {
int id;
try {
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
if (group.id.contains(":")) {
id = Integer.parseInt(group.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
id = Integer.parseInt(group.id);
}
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
Timber.w("failed to parse group ID [%s]", group.id);
break;
}
matchedIndex = id;
}
break;
}
}
@@ -443,7 +444,7 @@ public class VideoManager {
}
// offset the stream index to account for external streams
int exoTrackID = offsetStreamIndex(matchedIndex, true, true, allStreams);
int exoTrackID = offsetStreamIndex(matchedIndex, true, allStreams);
if (exoTrackID < 0)
return -1;
@@ -461,7 +462,7 @@ public class VideoManager {
Optional<MediaStream> candidateOptional = allStreams.stream().filter(stream -> stream.getIndex() == index && !stream.isExternal() && stream.getType() == streamType).findFirst();
if (!candidateOptional.isPresent()) return false;
int exoTrackID = offsetStreamIndex(index, false, true, allStreams);
int exoTrackID = offsetStreamIndex(index, false, allStreams);
if (exoTrackID < 0)
return false;
@@ -489,23 +490,23 @@ 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)
if (trackType != chosenTrackType)
continue;
int id;
try {
if (trackFormat.id.contains(":")) {
id = Integer.parseInt(trackFormat.id.split(":")[1]);
if (group.id.contains(":")) {
id = Integer.parseInt(group.id.split(":")[1]);
} else {
id = Integer.parseInt(trackFormat.id);
id = Integer.parseInt(group.id);
}
if (id != exoTrackID)
continue;
} catch (NumberFormatException e) {
Timber.d("failed to parse track ID [%s]", trackFormat.id);
Timber.w("failed to parse group ID [%s]", group.id);
continue;
}
@@ -519,7 +520,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 +533,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

@@ -12,6 +12,7 @@ import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
import org.jellyfin.playback.core.PlaybackManager
import org.jellyfin.playback.core.model.PlayState
import org.koin.compose.koinInject
import kotlin.time.Duration
import kotlin.time.times
@Composable
@@ -40,5 +41,6 @@ fun PlayerSeekbar(
onSeek = { progress -> playbackManager.state.seek(progress) },
modifier = modifier,
colors = colors,
enabled = positionInfo.duration > Duration.ZERO,
)
}

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

@@ -15,7 +15,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -109,8 +108,14 @@ private fun MainToolbar(
colors = if (activeButton == MainToolbarActiveButton.User) activeButtonColors else ButtonDefaults.colors(),
contentPadding = if (userImageVisible) PaddingValues(3.dp) else IconButtonDefaults.ContentPadding,
) {
if (!userImageVisible) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
contentDescription = stringResource(R.string.lbl_switch_user),
)
} else {
Image(
painter = if (userImageVisible) userImagePainter else rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_user)),
painter = userImagePainter,
contentDescription = stringResource(R.string.lbl_switch_user),
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -118,6 +123,7 @@ private fun MainToolbar(
.clip(IconButtonDefaults.Shape)
)
}
}
NowPlayingComposable(
onFocusableChange = {},

View File

@@ -89,9 +89,9 @@ fun ToolbarLayout(
).maxOrNull() ?: 0
layout(constraints.maxWidth, height) {
startPlaceables.forEach { it.place(0, (height - it.height) / 2) }
startPlaceables.forEach { it.placeRelative(0, (height - it.height) / 2) }
centerPlaceables.forEach { it.place((constraints.maxWidth - it.width) / 2, (height - it.height) / 2) }
endPlaceables.forEach { it.place(constraints.maxWidth - it.width, (height - it.height) / 2) }
endPlaceables.forEach { it.placeRelative(constraints.maxWidth - it.width, (height - it.height) / 2) }
}
}

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,8 +6,8 @@ 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 androidx.media3.common.MimeTypes
import timber.log.Timber
class MediaCodecCapabilitiesTest(
@@ -16,13 +16,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 {
@@ -37,9 +30,32 @@ class MediaCodecCapabilitiesTest(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
CodecProfileLevel.DolbyVisionProfileDvheSt else -1
}
val Profile10: Int by lazy {
}
// Some devices (e.g., Fire OS) may support AV1 below the official API level
// Use the platform constant if the API level is met; otherwise fall back to the literal value
// Reference:
// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/media/java/android/media/MediaCodecInfo.java
private object AV1ProfileLevel {
val ProfileMain10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
CodecProfileLevel.AV1ProfileMain10 else 0x2
}
val ProfileMain10HDR10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
CodecProfileLevel.AV1ProfileMain10HDR10 else 0x1000
}
val ProfileMain10HDR10Plus: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
CodecProfileLevel.AV1ProfileMain10HDR10Plus else 0x2000
}
val DolbyVisionProfile10: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
CodecProfileLevel.DolbyVisionProfileDvav110 else -1
CodecProfileLevel.DolbyVisionProfileDvav110 else 0x400
}
val Level5: Int by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
CodecProfileLevel.AV1Level5 else 0x1000
}
}
@@ -81,35 +97,31 @@ class MediaCodecCapabilitiesTest(
CodecProfileLevel.HEVCMainTierLevel62 to 186,
)
fun supportsAV1(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1)
fun supportsAV1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_AV1)
fun supportsAV1Main10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10,
CodecProfileLevel.AV1Level5
fun supportsAV1Main10(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
AV1ProfileLevel.ProfileMain10,
AV1ProfileLevel.Level5
)
fun supportsAV1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
fun supportsAV1DolbyVision(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION,
DolbyVisionProfiles.Profile10,
MimeTypes.VIDEO_DOLBY_VISION,
AV1ProfileLevel.DolbyVisionProfile10,
CodecProfileLevel.DolbyVisionLevelHd24
)
fun supportsAV1HDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10,
CodecProfileLevel.AV1Level5
fun supportsAV1HDR10(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
AV1ProfileLevel.ProfileMain10HDR10,
AV1ProfileLevel.Level5
)
fun supportsAV1HDR10Plus(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
hasDecoder(
MediaFormat.MIMETYPE_VIDEO_AV1,
CodecProfileLevel.AV1ProfileMain10HDR10Plus,
CodecProfileLevel.AV1Level5
fun supportsAV1HDR10Plus(): Boolean = hasDecoder(
MimeTypes.VIDEO_AV1,
AV1ProfileLevel.ProfileMain10HDR10Plus,
AV1ProfileLevel.Level5
)
fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
@@ -188,6 +200,8 @@ class MediaCodecCapabilitiesTest(
}?.second ?: 0
}
fun supportsVc1(): Boolean = hasCodecForMime(MimeTypes.VIDEO_VC1)
private fun getDecoderLevel(mime: String, profile: Int): Int {
var maxLevel = 0
@@ -294,16 +308,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

@@ -15,6 +15,7 @@ import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
import kotlin.math.roundToInt
private val downmixSupportedAudioCodecs = arrayOf(
Codec.Audio.AAC,
@@ -45,13 +46,13 @@ private val supportedAudioCodecs = arrayOf(
)
private fun UserPreferences.getMaxBitrate(): Int {
var maxBitrate = this[UserPreferences.maxBitrate].toIntOrNull()
var maxBitrate = this[UserPreferences.maxBitrate].toFloatOrNull()
// The value "0" was used in an older release, make sure we prevent that from being used to avoid video not playing
if (maxBitrate == null || maxBitrate < 1) maxBitrate = UserPreferences.maxBitrate.defaultValue.toInt()
if (maxBitrate == null || maxBitrate < 0.01f) maxBitrate = UserPreferences.maxBitrate.defaultValue.toFloat()
// Convert megabit to bit
return maxBitrate * 1_000_000
return (maxBitrate * 1_000_000).roundToInt()
}
fun createDeviceProfile(
@@ -93,15 +94,13 @@ fun createDeviceProfile(
val avcHigh10Level = mediaTest.getAVCHigh10Level()
val supportsAV1 = mediaTest.supportsAV1()
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
val supportsVC1 = mediaTest.supportsVc1()
val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264)
val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265)
val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1)
val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1)
/// HDR capabilities
// Display
val supportsDolbyVisionDisplay = mediaTest.supportsDolbyVision()
val supportsHdr10Display = mediaTest.supportsHdr10()
val supportsHdr10PlusDisplay = mediaTest.supportsHdr10Plus()
// Codecs
// AV1
@@ -144,9 +143,10 @@ fun createDeviceProfile(
type = DlnaProfileType.AUDIO
context = EncodingContext.STREAMING
container = Codec.Container.MP3
container = Codec.Container.TS
protocol = MediaStreamProtocol.HLS
audioCodec(Codec.Audio.MP3)
audioCodec(Codec.Audio.AAC)
}
/// Direct play profiles
@@ -176,6 +176,7 @@ fun createDeviceProfile(
Codec.Video.HEVC,
Codec.Video.MPEG,
Codec.Video.MPEG2VIDEO,
Codec.Video.VC1,
Codec.Video.VP8,
Codec.Video.VP9,
)
@@ -204,7 +205,7 @@ fun createDeviceProfile(
"main",
"baseline",
"constrained baseline",
if (supportsAVCHigh10) "main 10" else null
if (supportsAVCHigh10) "high 10" else null
)
}
}
@@ -329,6 +330,19 @@ fun createDeviceProfile(
}
}
// VC1 profile
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.VC1
conditions {
when {
!supportsVC1 -> ProfileConditionValue.VIDEO_PROFILE equals "none"
else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none"
}
}
}
// Get max resolutions for common codecs
// AVC
codecProfile {
@@ -363,35 +377,22 @@ fun createDeviceProfile(
}
}
// VC1
codecProfile {
type = CodecType.VIDEO
codec = Codec.Video.VC1
conditions {
ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionVC1.width
ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionVC1.height
}
}
/// 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 +409,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 +440,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 +457,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 {
@@ -184,19 +185,23 @@ fun createDeviceProfileReport(
val codecHDRSupport = buildMap<String, Map<HdrFormats, Boolean>> {
if (mediaTest.supportsAV1()) {
put(Codec.Video.AV1, mapOf(
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(
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"

View File

@@ -37,9 +37,11 @@ class JellyfinMediaStreamResolver(
conversionMethod = MediaConversionMethod.None,
url = api.videosApi.getVideoStreamUrl(
itemId = baseItem.id,
container = mediaInfo.mediaSource.container,
mediaSourceId = mediaInfo.mediaSource.id,
static = true,
tag = mediaInfo.mediaSource.eTag,
liveStreamId = mediaInfo.mediaSource.liveStreamId,
)
)
@@ -49,9 +51,11 @@ class JellyfinMediaStreamResolver(
conversionMethod = MediaConversionMethod.None,
url = api.audioApi.getAudioStreamUrl(
itemId = baseItem.id,
container = mediaInfo.mediaSource.container,
mediaSourceId = mediaInfo.mediaSource.id,
static = true,
tag = mediaInfo.mediaSource.eTag,
liveStreamId = mediaInfo.mediaSource.liveStreamId,
)
)

View File

@@ -48,6 +48,7 @@ class ExoPlayerBackend(
companion object {
const val TS_SEARCH_BYTES_LM = TsExtractor.TS_PACKET_SIZE * 1800
const val TS_SEARCH_BYTES_HM = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
const val MEDIA_ITEM_COUNT_MAX = 10
}
private var currentStream: PlayableMediaStream? = null
@@ -181,11 +182,13 @@ class ExoPlayerBackend(
setUri(stream.url)
}.build()
// Remove any old preloaded items (skips the first which is the playing item)
while (exoPlayer.mediaItemCount > 1) exoPlayer.removeMediaItem(0)
// Add new item
// Remove any excessive items from the start
while (exoPlayer.mediaItemCount > MEDIA_ITEM_COUNT_MAX - 1) exoPlayer.removeMediaItem(0)
// Add new item to the end of the media item list
exoPlayer.addMediaItem(mediaItem)
// Instruct exoplayer to prepare
exoPlayer.prepare()
}
@@ -195,14 +198,27 @@ class ExoPlayerBackend(
currentStream = stream
val streamIsPrepared = (0 until exoPlayer.mediaItemCount).any { index ->
var preparedItemIndex = (0 until exoPlayer.mediaItemCount).firstOrNull { index ->
exoPlayer.getMediaItemAt(index).mediaId == stream.hashCode().toString()
}
if (!streamIsPrepared) prepareItem(item)
// Prepare the item now if it doesn't exist yet
if (preparedItemIndex == null) {
prepareItem(item)
preparedItemIndex = exoPlayer.mediaItemCount - 1
}
Timber.i("Playing ${item.mediaStream?.url}")
exoPlayer.seekToNextMediaItem()
// Seek to prepared media item
when (preparedItemIndex) {
exoPlayer.currentMediaItemIndex - 1 -> exoPlayer.seekToPreviousMediaItem()
exoPlayer.currentMediaItemIndex + 1 -> exoPlayer.seekToNextMediaItem()
exoPlayer.currentMediaItemIndex -> Unit
else -> exoPlayer.seekTo(preparedItemIndex, 0)
}
// Enjoy!
exoPlayer.play()
}
@@ -222,7 +238,7 @@ class ExoPlayerBackend(
}
override fun seekTo(position: Duration) {
if (!exoPlayer.isCommandAvailable(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)) {
if (!exoPlayer.isCommandAvailable(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) || !exoPlayer.isCurrentMediaItemSeekable) {
Timber.w("Trying to seek but ExoPlayer doesn't support it for the current item")
}