Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4af31285 | ||
|
|
8d5bd8fd4b | ||
|
|
fdbea3e613 | ||
|
|
b323af2f11 | ||
|
|
5bd6d9f2c2 | ||
|
|
3258933d39 | ||
|
|
3a6fdfece9 | ||
|
|
23a5572024 | ||
|
|
9a3022a590 | ||
|
|
f56faa2ad0 | ||
|
|
5e1883935d | ||
|
|
69d6283cd5 | ||
|
|
1b492c573d | ||
|
|
d1909aef27 | ||
|
|
6cea36f909 | ||
|
|
7745856646 | ||
|
|
788f585199 | ||
|
|
90f941fc9a | ||
|
|
b653d89285 | ||
|
|
c50a8356b3 | ||
|
|
9be3b5f81f | ||
|
|
a7a37054f2 | ||
|
|
49182a2a71 | ||
|
|
f473b3acb5 | ||
|
|
5caf4ac11b | ||
|
|
4ec568c311 | ||
|
|
3cdeba071c | ||
|
|
d80df757fc | ||
|
|
58b936b334 | ||
|
|
e22267b089 | ||
|
|
bf9a5ea5af | ||
|
|
8fabf7904d | ||
|
|
7bbcfb50cd | ||
|
|
b13eaea746 | ||
|
|
21b4103cb8 | ||
|
|
540eb286b3 |
1
.github/workflows/app-build.yaml
vendored
1
.github/workflows/app-build.yaml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release-*
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -123,7 +123,7 @@ class ServerRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
Timber.d(buildString {
|
||||
Timber.i(buildString {
|
||||
append("Recommendations: ")
|
||||
if (greatRecommendation == null) append(0)
|
||||
else append(1)
|
||||
|
||||
@@ -66,6 +66,7 @@ object Codec {
|
||||
const val VP8 = "vp8"
|
||||
const val VP9 = "vp9"
|
||||
const val AV1 = "av1"
|
||||
const val VC1 = "vc1"
|
||||
}
|
||||
|
||||
object Subtitle {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
} else {
|
||||
id = Integer.parseInt(trackFormat.id);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Timber.d("failed to parse track ID [%s]", trackFormat.id);
|
||||
break;
|
||||
int id;
|
||||
try {
|
||||
if (group.id.contains(":")) {
|
||||
id = Integer.parseInt(group.id.split(":")[1]);
|
||||
} else {
|
||||
id = Integer.parseInt(group.id);
|
||||
}
|
||||
matchedIndex = id;
|
||||
} catch (NumberFormatException e) {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ fun SearchTextInput(
|
||||
BasicTextField(
|
||||
modifier = modifier,
|
||||
value = query,
|
||||
singleLine = true,
|
||||
interactionSource = interactionSource,
|
||||
onValueChange = { onQueryChange(it) },
|
||||
keyboardActions = KeyboardActions { onQuerySubmit() },
|
||||
|
||||
@@ -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,14 +108,21 @@ private fun MainToolbar(
|
||||
colors = if (activeButton == MainToolbarActiveButton.User) activeButtonColors else ButtonDefaults.colors(),
|
||||
contentPadding = if (userImageVisible) PaddingValues(3.dp) else IconButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Image(
|
||||
painter = if (userImageVisible) userImagePainter else rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_user)),
|
||||
contentDescription = stringResource(R.string.lbl_switch_user),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(IconButtonDefaults.Shape)
|
||||
)
|
||||
if (!userImageVisible) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_user),
|
||||
contentDescription = stringResource(R.string.lbl_switch_user),
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = userImagePainter,
|
||||
contentDescription = stringResource(R.string.lbl_switch_user),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(IconButtonDefaults.Shape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NowPlayingComposable(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,36 +97,32 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
/// HDR exclude list
|
||||
// VC1
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = Codec.Video.VC1
|
||||
|
||||
// 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)
|
||||
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 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
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user