Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eecfab5d7 | ||
|
|
0f9ffcacb9 | ||
|
|
aa7c45d462 | ||
|
|
156f5ecd3c | ||
|
|
e56fd0d024 | ||
|
|
06e245fe48 | ||
|
|
9f0fa644a7 | ||
|
|
5b213199dd | ||
|
|
b4f6273ede | ||
|
|
66c624e16c | ||
|
|
e47916f1c2 | ||
|
|
ab7875cbb3 | ||
|
|
5f15615a96 | ||
|
|
9cda86ab84 | ||
|
|
1873497fec |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user