Compare commits

...

15 Commits

Author SHA1 Message Date
Niels van Velzen
8eecfab5d7 Disable tunneling 2024-01-07 14:53:05 +01:00
Niels van Velzen
0f9ffcacb9 Defer home initialization until user info is available
(cherry picked from commit d721482127)
2024-01-06 13:50:56 +01:00
Niels van Velzen
aa7c45d462 Disable crossfading for CardLegacyImageView to fix scaling issue
(cherry picked from commit 1fbc19740b)
2024-01-05 19:54:19 +01:00
Gwilherm Folliot
156f5ecd3c Fix crash when DISPLAY_CONTENT while playing
When a video is playing and on another device you browse libraries on a casting device it cause the app to crash

The onDisplayContent method is trying to read ExoPlayer state but from the socket thread and not the main thread

java.lang.IllegalStateException: Player is accessed on the wrong thread.
Current thread: 'DefaultDispatcher-worker-4'
Expected thread: 'main'
See https://developer.android.com/guide/topics/media/issues/player-accessed-on-wrong-thread
	at androidx.media3.exoplayer.ExoPlayerImpl.verifyApplicationThread(ExoPlayerImpl.java:2842)
	at androidx.media3.exoplayer.ExoPlayerImpl.getPlaybackState(ExoPlayerImpl.java:514)
	at androidx.media3.common.BasePlayer.isPlaying(BasePlayer.java:124)
	at org.jellyfin.androidtv.ui.playback.VideoManager.isPlaying(VideoManager.java:341)
	at org.jellyfin.androidtv.ui.playback.PlaybackController.isPlaying(PlaybackController.java:262)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler.onDisplayContent(SocketHandler.kt:184)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler.lambda$13$lambda$8(SocketHandler.kt:103)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler$$ExternalSyntheticLambda3.onReceive(Unknown Source:4)
	at org.jellyfin.androidtv.data.eventhandling.SocketHandler$_init_$lambda$13$$inlined$addGeneralCommandsListener$default$1.onReceive(ListenerRegistrationExtensions.kt:68)
	at org.jellyfin.sdk.api.sockets.helper.ListenerHelper.forwardMessage(ListenerHelper.kt:37)
	at org.jellyfin.sdk.api.sockets.SocketInstance.forwardMessage(SocketInstance.kt:283)
	at org.jellyfin.sdk.api.sockets.SocketInstance.access$forwardMessage(SocketInstance.kt:35)
	at org.jellyfin.sdk.api.sockets.SocketInstance$connectAndBind$2.invokeSuspend(SocketInstance.kt:220)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108)
	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:793)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:697)
	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:684)

(cherry picked from commit 0116530794)
2024-01-05 19:54:18 +01:00
Niels van Velzen
e56fd0d024 Set TsExtractor TimestampSearchBytes parameter based on device memory capabilities
(cherry picked from commit 76acd17d81)
2024-01-05 19:54:18 +01:00
Niels van Velzen
06e245fe48 Enable tunneling in VideoManager
(cherry picked from commit e646192196)
2024-01-05 19:54:17 +01:00
Niels van Velzen
9f0fa644a7 Enable tunneling in ExoPlayerBackend
(cherry picked from commit 1f0345f16f)
2024-01-05 19:54:17 +01:00
Niels van Velzen
5b213199dd Add <queries> element to Manifest for remote trailers
(cherry picked from commit b91fd1a0f5)
2024-01-05 19:54:16 +01:00
Niels van Velzen
b4f6273ede Disable SocketHandler when using RewriteMediaManager
(cherry picked from commit 332b6af3da)
2024-01-05 19:54:16 +01:00
Niels van Velzen
66c624e16c Replace ExoPlayer with androidx.media3
(cherry picked from commit bd204fdf87)
2024-01-05 19:54:15 +01:00
Niels van Velzen
e47916f1c2 Fix crash and inconsistent ordering when using ShuffleOrderIndexProvider
(cherry picked from commit b9e19b2515)
2024-01-05 19:54:15 +01:00
Niels van Velzen
ab7875cbb3 Fix opening collections via search results showing empty screen
(cherry picked from commit 2bbbb93dbf)
2024-01-05 19:54:14 +01:00
Niels van Velzen
5f15615a96 Fix playing audio from search queues entire row
(cherry picked from commit 8b43402192)
2024-01-05 19:54:14 +01:00
Niels van Velzen
9cda86ab84 Fix focus issue when pressing back in Fire TV soft keyboard
(cherry picked from commit c70f814064)
2024-01-05 19:54:14 +01:00
Niels van Velzen
1873497fec Fix RewriteMediaManager never used in production builds
(cherry picked from commit 75b6b12147)
2023-12-18 22:21:02 +01:00
27 changed files with 261 additions and 154 deletions

View File

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

View File

@@ -139,8 +139,10 @@ dependencies {
implementation(libs.bundles.koin)
// Media players
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.ui)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
implementation(libs.libvlc)
// Markdown

View File

@@ -28,6 +28,15 @@
android:name="android.hardware.microphone"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data
android:host="youtube.com"
android:scheme="https" />
</intent>
</queries>
<application
android:name=".JellyfinApplication"
android:allowBackup="true"

View File

@@ -14,6 +14,7 @@ import org.jellyfin.androidtv.ui.navigation.Destinations
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
import org.jellyfin.androidtv.ui.playback.MediaManager
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
import org.jellyfin.androidtv.util.apiclient.PlaybackHelper
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
@@ -137,36 +138,52 @@ class SocketHandler(
@Suppress("ComplexMethod")
private fun onPlayStateMessage(message: PlayStateMessage) = coroutineScope.launch(Dispatchers.Main) {
Timber.i("Received PlayStateMessage with command ${message.request.command}")
val playbackController = playbackControllerContainer.playbackController
// Audio playback uses the mediaManager, video playback and live tv use the playbackController
if (mediaManager.isAudioPlayerInitialized) when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
} else when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
when {
// Ignore RewriteMediaManager
mediaManager is RewriteMediaManager && mediaManager.hasAudioQueueItems() -> {
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
return@launch
}
// LegacyMediaManager
mediaManager.hasAudioQueueItems() -> when (message.request.command) {
PlaystateCommand.STOP -> mediaManager.stopAudio(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> mediaManager.playPauseAudio()
PlaystateCommand.NEXT_TRACK -> mediaManager.nextAudioItem()
PlaystateCommand.PREVIOUS_TRACK -> mediaManager.prevAudioItem()
// Not implemented
PlaystateCommand.SEEK,
PlaystateCommand.REWIND,
PlaystateCommand.FAST_FORWARD -> Unit
}
// PlaybackController
else -> {
val playbackController = playbackControllerContainer.playbackController
when (message.request.command) {
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
PlaystateCommand.SEEK -> playbackController?.seek(
(message.request.seekPositionTicks ?: 0) / TICKS_TO_MS
)
PlaystateCommand.REWIND -> playbackController?.rewind()
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
}
}
}
}
private fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) {
private fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = coroutineScope.launch(Dispatchers.Main) {
val playbackController = playbackControllerContainer.playbackController
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
Timber.i("Not launching $itemId: playback in progress")
return
return@launch
}
Timber.i("Launching $itemId")

View File

@@ -35,7 +35,7 @@ val playbackModule = module {
factory {
val preferences = get<UserPreferences>()
val useRewrite = preferences[UserPreferences.playbackRewriteAudioEnabled] && BuildConfig.DEVELOPMENT
val useRewrite = preferences[UserPreferences.playbackRewriteAudioEnabled]
if (useRewrite) get<RewriteMediaManager>()
else get<LegacyMediaManager>()

View File

@@ -82,7 +82,10 @@ class AsyncImageView @JvmOverloads constructor(
}.build())
} else {
imageLoader.enqueue(ImageRequest.Builder(context).apply {
crossfade(crossFadeDuration.inWholeMilliseconds.toInt())
val crossFadeDurationMs = crossFadeDuration.inWholeMilliseconds.toInt()
if (crossFadeDurationMs > 0) crossfade(crossFadeDurationMs)
else crossfade(false)
target(this@AsyncImageView)
data(url)
placeholder(placeholderOrBlurHash)

View File

@@ -1,52 +1,36 @@
package org.jellyfin.androidtv.ui.browsing;
import android.os.Bundle;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.data.querying.StdItemQuery;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.apiclient.model.querying.ItemFields;
import org.jellyfin.sdk.model.api.BaseItemKind;
public class CollectionFragment extends EnhancedBrowseFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
@Override
protected void setupQueries(RowLoader rowLoader) {
if (Utils.getSafeValue(mFolder.getChildCount(), 0) > 0) {
StdItemQuery movies = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
movies.setParentId(mFolder.getId().toString());
movies.setIncludeItemTypes(new String[]{"Movie"});
mRows.add(new BrowseRowDef(getString(R.string.lbl_movies), movies, 100));
StdItemQuery movies = new StdItemQuery(new ItemFields[]{
ItemFields.PrimaryImageAspectRatio,
ItemFields.Overview,
ItemFields.ItemCounts,
ItemFields.DisplayPreferencesId,
ItemFields.ChildCount,
ItemFields.MediaStreams,
ItemFields.MediaSources
});
movies.setParentId(mFolder.getId().toString());
movies.setIncludeItemTypes(new String[]{BaseItemKind.MOVIE.getSerialName()});
mRows.add(new BrowseRowDef(getString(R.string.lbl_movies), movies, 100));
StdItemQuery series = new StdItemQuery();
series.setParentId(mFolder.getId().toString());
series.setIncludeItemTypes(new String[]{"Series"});
mRows.add(new BrowseRowDef(getString(R.string.lbl_tv_series), series, 100));
StdItemQuery others = new StdItemQuery();
others.setParentId(mFolder.getId().toString());
others.setExcludeItemTypes(new String[]{"Movie", "Series"});
mRows.add(new BrowseRowDef(getString(R.string.lbl_other), others, 100));
rowLoader.loadRows(mRows);
}
StdItemQuery series = new StdItemQuery();
series.setParentId(mFolder.getId().toString());
series.setIncludeItemTypes(new String[]{BaseItemKind.SERIES.getSerialName()});
mRows.add(new BrowseRowDef(getString(R.string.lbl_tv_series), series, 100));
StdItemQuery others = new StdItemQuery();
others.setParentId(mFolder.getId().toString());
others.setExcludeItemTypes(new String[]{BaseItemKind.MOVIE.getSerialName(), BaseItemKind.SERIES.getSerialName()});
mRows.add(new BrowseRowDef(getString(R.string.lbl_other), others, 100));
rowLoader.loadRows(mRows);
}
}

View File

@@ -17,11 +17,14 @@ import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.jellyfin.androidtv.auth.repository.UserRepository
import org.jellyfin.androidtv.constant.CustomMessage
import org.jellyfin.androidtv.constant.HomeSectionType
@@ -88,15 +91,17 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
adapter = MutableObjectAdapter<Row>(PositionableListRowPresenter())
val currentUser = userRepository.currentUser.value
lifecycleScope.launch(Dispatchers.IO) {
val currentUser = withTimeout(30.seconds) {
userRepository.currentUser.filterNotNull().first()
}
// Start out with default sections
val homesections = userSettingPreferences.homesections
var includeLiveTvRows = false
// Check for live TV support
if (homesections.contains(HomeSectionType.LIVE_TV) && currentUser?.policy?.enableLiveTvAccess == true) {
if (homesections.contains(HomeSectionType.LIVE_TV) && currentUser.policy?.enableLiveTvAccess == true) {
// This is kind of ugly, but it mirrors how web handles the live TV rows on the home screen
// If we can retrieve one live TV recommendation, then we should display the rows
val recommendedPrograms by api.liveTvApi.getRecommendedPrograms(

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import androidx.annotation.Nullable;
import org.jellyfin.androidtv.constant.LiveTvOption;
import org.jellyfin.androidtv.constant.QueryType;
import org.jellyfin.androidtv.data.model.ChapterItemInfo;
import org.jellyfin.androidtv.preference.LibraryPreferences;
import org.jellyfin.androidtv.preference.PreferencesRepository;
@@ -106,6 +107,8 @@ public class ItemLauncher {
} else if (mediaManager.hasAudioQueueItems() && rowItem instanceof AudioQueueItem && pos < mediaManager.getCurrentAudioQueueSize()) {
Timber.d("playing audio queue item");
mediaManager.playFrom(pos);
} else if (adapter.getQueryType() == QueryType.Search) {
mediaManager.playNow(context, rowItem.getBaseItem());
} else {
Timber.d("playing audio item");
List<BaseItemDto> audioItemsAsList = new ArrayList<>();

View File

@@ -9,14 +9,16 @@ import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import androidx.annotation.OptIn;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DefaultDataSourceFactory;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
import org.jellyfin.androidtv.R;
import org.jellyfin.androidtv.constant.QueryType;
@@ -53,6 +55,7 @@ import java.util.Random;
import kotlin.Lazy;
import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
public class LegacyMediaManager implements MediaManager {
private Context context;

View File

@@ -18,25 +18,28 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.TrackSelectionOverride;
import androidx.media3.common.TrackSelectionParameters;
import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.ts.TsExtractor;
import androidx.media3.ui.AspectRatioFrameLayout;
import androidx.media3.ui.PlayerView;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Tracks;
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
import com.google.android.exoplayer2.extractor.ts.TsExtractor;
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory;
import com.google.android.exoplayer2.source.TrackGroup;
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride;
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.StyledPlayerView;
import com.google.common.collect.ImmutableSet;
import org.jellyfin.androidtv.R;
@@ -58,6 +61,7 @@ import java.util.Optional;
import timber.log.Timber;
@OptIn(markerClass = UnstableApi.class)
public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
public final static int ZOOM_FIT = 0;
public final static int ZOOM_AUTO_CROP = 1;
@@ -76,7 +80,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
private SurfaceView mSubtitlesSurface;
private FrameLayout mSurfaceFrame;
private ExoPlayer mExoPlayer;
private StyledPlayerView mExoPlayerView;
private PlayerView mExoPlayerView;
private LibVLC mLibVLC;
private MediaPlayer mVlcPlayer;
private Media mCurrentMedia;
@@ -94,7 +98,7 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
private long mMetaDuration = -1;
private long mMetaVLCStreamStartPosition = -1;
private long lastExoPlayerPosition = -1;
private boolean nightModeEnabled = false;
private boolean nightModeEnabled;
private boolean nativeMode = false;
private boolean mSurfaceReady = false;
@@ -202,6 +206,16 @@ public class VideoManager implements IVLCVout.OnNewVideoLayoutListener {
defaultRendererFactory.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
exoPlayerBuilder.setRenderersFactory(defaultRendererFactory);
DefaultTrackSelector trackSelector = new DefaultTrackSelector(context);
trackSelector.setParameters(trackSelector.buildUponParameters()
.setAudioOffloadPreferences(new TrackSelectionParameters.AudioOffloadPreferences.Builder()
.setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build()
)
.build()
);
exoPlayerBuilder.setTrackSelector(trackSelector);
DefaultExtractorsFactory defaultExtractorsFactory = new DefaultExtractorsFactory().setTsExtractorTimestampSearchBytes(TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES * 3);
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(context, defaultExtractorsFactory));

View File

@@ -7,7 +7,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.leanback.app.RowsSupportFragment
import androidx.lifecycle.lifecycleScope
@@ -63,12 +65,23 @@ class TextSearchFragment : Fragment() {
}
private fun EditText.onSubmit(onSubmit: (String) -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
onSubmit(text.toString())
true
} else {
false
setOnEditorActionListener { view, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE,
EditorInfo.IME_ACTION_SEARCH,
EditorInfo.IME_ACTION_PREVIOUS -> {
onSubmit(text.toString())
// Manually close IME to workaround focus issue with Fire TV
context.getSystemService<InputMethodManager>()
?.hideSoftInputFromWindow(view.windowToken, 0)
// Focus on search results
binding.resultsFrame.requestFocus()
true
}
else -> false
}
}
}

View File

@@ -12,6 +12,7 @@
MODIFIED to add Jellyfin specifics
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:lb="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="androidx.leanback.widget.BaseCardView">
@@ -26,6 +27,7 @@
tools:ignore="UnusedAttribute"
tools:visibility="visible">
<!-- Crossfading is broken with this specific layout, especially when using thumbnails -->
<org.jellyfin.androidtv.ui.AsyncImageView
android:id="@+id/main_image"
android:layout_width="wrap_content"
@@ -33,6 +35,7 @@
android:background="@drawable/shape_card_image_background"
android:contentDescription="@null"
android:scaleType="centerCrop"
app:crossfadeDuration="0"
lb:layout_viewType="main"
tools:src="@drawable/app_logo" />

View File

@@ -31,7 +31,7 @@
</FrameLayout>
<com.google.android.exoplayer2.ui.StyledPlayerView
<androidx.media3.ui.PlayerView
android:id="@+id/exoPlayerView"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@@ -25,10 +25,9 @@ androidx-work = "2.9.0"
blurhash = "0.2.0"
coil = "2.5.0"
detekt = "1.23.4"
exoplayer = "2.19.1"
gson = "2.8.9"
jellyfin-androidx-media = "1.2.0+1"
jellyfin-apiclient = "v0.7.10"
jellyfin-exoplayer-ffmpegextension = "2.19.1+1"
jellyfin-sdk = "1.4.6"
junit = "4.13.2"
koin = "3.5.0"
@@ -79,6 +78,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" }
@@ -93,8 +93,9 @@ koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", versi
koin-androidx-workmanager = { module = "io.insert-koin:koin-androidx-workmanager", version.ref = "koin" }
# Media players
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
jellyfin-exoplayer-ffmpegextension = { group = "org.jellyfin.exoplayer", name = "exoplayer-ffmpeg-extension", version.ref = "jellyfin-exoplayer-ffmpegextension" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" }
jellyfin-androidx-media3-ffmpeg-decoder = { group = "org.jellyfin.media3", name = "media3-ffmpeg-decoder", version.ref = "jellyfin-androidx-media" }
libvlc = { module = "org.videolan.android:libvlc-all", version.ref = "libvlc" }
# Markwon

View File

@@ -93,14 +93,15 @@ internal class MediaSessionPlayer(
val previous = state.queue.peekPrevious()
val next = state.queue.peekNext()
listOfNotNull(previous, current, next)
val playlist = listOfNotNull(previous, current, next)
.distinctBy { it.metadata.mediaId }
.map {
MediaItemData.Builder(requireNotNull(it.metadata.mediaId)).apply {
setMediaItem(it.metadata.toMediaItem())
setDurationUs(it.metadata.duration?.inWholeMicroseconds ?: C.TIME_UNSET)
}.build()
}.let(::setPlaylist)
}
setPlaylist(playlist)
setPlaybackState(when (state.playState.value) {
PlayState.STOPPED -> STATE_IDLE
@@ -109,7 +110,7 @@ internal class MediaSessionPlayer(
PlayState.ERROR -> STATE_ENDED
})
setCurrentMediaItemIndex(if (previous == null) 0 else 1)
setCurrentMediaItemIndex(if (previous == null || playlist.size <= 1) 0 else 1)
} else {
setPlaybackState(STATE_IDLE)
setCurrentMediaItemIndex(C.INDEX_UNSET)

View File

@@ -91,18 +91,14 @@ class DefaultPlayerQueueState(
override fun replaceQueue(queue: Queue) {
Timber.d("Queue changed, setting index to 0")
_current.value = queue
orderIndexProvider.reset()
if (orderIndexProvider != defaultOrderIndexProvider) defaultOrderIndexProvider.reset()
currentQueueIndicesPlayed.clear()
coroutineScope.launch {
when (state.playbackOrder.value) {
PlaybackOrder.DEFAULT -> setIndex(0)
PlaybackOrder.RANDOM,
PlaybackOrder.SHUFFLE -> setIndex((0 until queue.size).random())
}
_current.value = queue
orderIndexProvider.reset()
if (orderIndexProvider != defaultOrderIndexProvider) defaultOrderIndexProvider.reset()
currentQueueIndicesPlayed.clear()
setIndex(0)
}
}

View File

@@ -20,13 +20,13 @@ internal class ShuffleOrderIndexProvider : OrderIndexProvider {
} else {
val remainingIndices = (0..queue.size).filterNot {
it in playedIndices || it in nextIndices
}.shuffled()
}
List(min(amount, remainingItemsSize)) { i ->
if (i <= nextIndices.lastIndex) {
if (i < nextIndices.lastIndex) {
nextIndices[i]
} else {
val index = remainingIndices[i - nextIndices.size]
val index = remainingIndices.random()
nextIndices.add(index)
index
}

View File

@@ -29,9 +29,12 @@ dependencies {
implementation(libs.kotlinx.coroutines)
implementation(libs.kotlinx.coroutines.guava)
// AndroidX
implementation(libs.androidx.core)
// ExoPlayer
implementation(libs.exoplayer)
implementation(libs.jellyfin.exoplayer.ffmpegextension)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
// Logging
implementation(libs.timber)

View File

@@ -1,13 +1,22 @@
package org.jellyfin.playback.exoplayer
import android.app.ActivityManager
import android.content.Context
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.video.VideoSize
import androidx.annotation.OptIn
import androidx.core.content.getSystemService
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.TsExtractor
import org.jellyfin.playback.core.backend.BasePlayerBackend
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.PlayableMediaStream
@@ -21,22 +30,40 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.milliseconds
@OptIn(UnstableApi::class)
class ExoPlayerBackend(
private val context: Context,
) : BasePlayerBackend() {
companion object {
const val TS_SEARCH_BYTES_LM = TsExtractor.TS_PACKET_SIZE * 1800
const val TS_SEARCH_BYTES_HM = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
}
private var currentStream: PlayableMediaStream? = null
private val exoPlayer by lazy {
val renderersFactory = DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
}
ExoPlayer.Builder(context, renderersFactory)
ExoPlayer.Builder(context)
.setRenderersFactory(DefaultRenderersFactory(context).apply {
setEnableDecoderFallback(true)
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
})
.setTrackSelector(DefaultTrackSelector(context).apply {
setParameters(buildUponParameters().apply {
setAudioOffloadPreferences(TrackSelectionParameters.AudioOffloadPreferences.DEFAULT.buildUpon().apply {
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build())
})
})
.setMediaSourceFactory(DefaultMediaSourceFactory(
context,
DefaultExtractorsFactory().apply {
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
setTsExtractorTimestampSearchBytes(when (isLowRamDevice) {
true -> TS_SEARCH_BYTES_LM
false -> TS_SEARCH_BYTES_HM
})
}
))
.setPauseAtEndOfMediaItems(true)
.build()
.also { player -> player.addListener(PlayerListener()) }

View File

@@ -1,6 +1,6 @@
package org.jellyfin.playback.exoplayer.mapping
import com.google.android.exoplayer2.util.MimeTypes
import androidx.media3.common.MimeTypes
fun getFfmpegAudioMimeType(codec: String): String {
return ffmpegAudioMimeTypes.getOrDefault(codec, codec)

View File

@@ -1,6 +1,8 @@
package org.jellyfin.playback.exoplayer.mapping
import com.google.android.exoplayer2.util.MimeTypes
import androidx.annotation.OptIn
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
fun getFfmpegContainerMimeType(codec: String): String {
// Find in container mime type list
@@ -13,6 +15,7 @@ fun getFfmpegContainerMimeType(codec: String): String {
}
}
@OptIn(UnstableApi::class)
val ffmpegContainerMimeTypes = mapOf(
"aac" to MimeTypes.AUDIO_AAC,
"alaw" to MimeTypes.AUDIO_ALAW,

View File

@@ -1,6 +1,8 @@
package org.jellyfin.playback.exoplayer.support
import com.google.android.exoplayer2.RendererCapabilities
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.RendererCapabilities
enum class AdaptiveSupport {
SEAMLESS,
@@ -8,6 +10,7 @@ enum class AdaptiveSupport {
NOT_SUPPORTED;
companion object {
@OptIn(UnstableApi::class)
fun fromFlags(flags: Int) = when (RendererCapabilities.getAdaptiveSupport(flags)) {
RendererCapabilities.ADAPTIVE_SEAMLESS -> SEAMLESS
RendererCapabilities.ADAPTIVE_NOT_SEAMLESS -> NOT_SEAMLESS

View File

@@ -1,6 +1,8 @@
package org.jellyfin.playback.exoplayer.support
import com.google.android.exoplayer2.RendererCapabilities
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.RendererCapabilities
enum class DecoderSupport {
PRIMARY,
@@ -8,6 +10,7 @@ enum class DecoderSupport {
FALLBACK;
companion object {
@OptIn(UnstableApi::class)
fun fromFlags(flags: Int) = when (RendererCapabilities.getDecoderSupport(flags)) {
RendererCapabilities.DECODER_SUPPORT_PRIMARY -> PRIMARY
RendererCapabilities.DECODER_SUPPORT_FALLBACK_MIMETYPE -> FALLBACK_MIMETYPE

View File

@@ -1,9 +1,11 @@
package org.jellyfin.playback.exoplayer.support
import com.google.android.exoplayer2.BaseRenderer
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.RendererCapabilities
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.BaseRenderer
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RendererCapabilities
import org.jellyfin.playback.core.support.PlaySupportReport
data class ExoPlayerPlaySupportReport(
@@ -16,6 +18,7 @@ data class ExoPlayerPlaySupportReport(
override val canPlay = format == FormatSupport.HANDLED || tunneling == true || hardwareAcceleration == true
companion object {
@OptIn(UnstableApi::class)
fun fromFlags(flags: Int): ExoPlayerPlaySupportReport = ExoPlayerPlaySupportReport(
format = FormatSupport.fromFlags(RendererCapabilities.getFormatSupport(flags)),
adaptive = AdaptiveSupport.fromFlags(RendererCapabilities.getAdaptiveSupport(flags)),
@@ -24,12 +27,14 @@ data class ExoPlayerPlaySupportReport(
decoder = DecoderSupport.fromFlags(RendererCapabilities.getDecoderSupport(flags)),
)
@OptIn(UnstableApi::class)
private fun tunnelingFromFlags(flags: Int) = when (RendererCapabilities.getTunnelingSupport(flags)) {
RendererCapabilities.TUNNELING_SUPPORTED -> true
RendererCapabilities.TUNNELING_NOT_SUPPORTED -> false
else -> null
}
@OptIn(UnstableApi::class)
private fun hardwareAccelerationFromFlags(flags: Int) = when (RendererCapabilities.getHardwareAccelerationSupport(flags)) {
RendererCapabilities.HARDWARE_ACCELERATION_SUPPORTED -> true
RendererCapabilities.HARDWARE_ACCELERATION_NOT_SUPPORTED -> false
@@ -41,6 +46,7 @@ data class ExoPlayerPlaySupportReport(
fun ExoPlayer.getPlaySupportReport(format: Format): ExoPlayerPlaySupportReport =
ExoPlayerPlaySupportReport.fromFlags(supportsFormat(format))
@OptIn(UnstableApi::class)
fun ExoPlayer.supportsFormat(format: Format): Int {
var capabilities = 0

View File

@@ -1,6 +1,9 @@
package org.jellyfin.playback.exoplayer.support
import com.google.android.exoplayer2.RendererCapabilities
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.RendererCapabilities
enum class FormatSupport {
HANDLED,
@@ -10,12 +13,13 @@ enum class FormatSupport {
UNSUPPORTED_TYPE;
companion object {
@OptIn(UnstableApi::class)
fun fromFlags(flags: Int) = when (RendererCapabilities.getFormatSupport(flags)) {
RendererCapabilities.FORMAT_HANDLED -> HANDLED
RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES -> EXCEEDS_CAPABILITIES
RendererCapabilities.FORMAT_UNSUPPORTED_DRM -> UNSUPPORTED_DRM
RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE -> UNSUPPORTED_SUBTYPE
RendererCapabilities.FORMAT_UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
C.FORMAT_HANDLED -> HANDLED
C.FORMAT_EXCEEDS_CAPABILITIES -> EXCEEDS_CAPABILITIES
C.FORMAT_UNSUPPORTED_DRM -> UNSUPPORTED_DRM
C.FORMAT_UNSUPPORTED_SUBTYPE -> UNSUPPORTED_SUBTYPE
C.FORMAT_UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
else -> null
}
}

View File

@@ -1,11 +1,14 @@
package org.jellyfin.playback.exoplayer.support
import com.google.android.exoplayer2.Format
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import org.jellyfin.playback.core.mediastream.MediaStream
import org.jellyfin.playback.core.mediastream.MediaStreamAudioTrack
import org.jellyfin.playback.exoplayer.mapping.getFfmpegAudioMimeType
import org.jellyfin.playback.exoplayer.mapping.getFfmpegContainerMimeType
@OptIn(UnstableApi::class)
fun MediaStream.toFormat() = Format.Builder().also { f ->
f.setId(identifier)
f.setContainerMimeType(getFfmpegContainerMimeType(container.format))