Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9eeae6bf0 | ||
|
|
c2e33b9b13 | ||
|
|
9c4b121574 | ||
|
|
ddb1be18bc | ||
|
|
4a4c1ed6ca | ||
|
|
c28759e165 | ||
|
|
45a5adbc0b | ||
|
|
118eacfa90 | ||
|
|
91ccf32f77 | ||
|
|
065e738773 | ||
|
|
65a7bbf501 | ||
|
|
d661c2faf3 | ||
|
|
8175dbd1c0 | ||
|
|
f068be6945 | ||
|
|
fe465db020 | ||
|
|
d6fe265b38 | ||
|
|
8eecfab5d7 | ||
|
|
0f9ffcacb9 | ||
|
|
aa7c45d462 | ||
|
|
156f5ecd3c | ||
|
|
e56fd0d024 | ||
|
|
06e245fe48 | ||
|
|
9f0fa644a7 | ||
|
|
5b213199dd | ||
|
|
b4f6273ede | ||
|
|
66c624e16c | ||
|
|
e47916f1c2 | ||
|
|
ab7875cbb3 | ||
|
|
5f15615a96 | ||
|
|
9cda86ab84 | ||
|
|
1873497fec |
4
.github/workflows/app-publish.yaml
vendored
4
.github/workflows/app-publish.yaml
vendored
@@ -27,6 +27,8 @@ jobs:
|
||||
- name: Sign APK
|
||||
id: signApk
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE }}
|
||||
@@ -36,6 +38,8 @@ jobs:
|
||||
- name: Sign app bundle
|
||||
id: signAab
|
||||
uses: r0adkll/sign-android-release@349ebdef58775b1e0d8099458af0816dc79b6407 # tag=v1
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "34.0.0"
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/bundle/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE }}
|
||||
|
||||
@@ -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
|
||||
@@ -126,47 +127,65 @@ class SocketHandler(
|
||||
private fun onPlayMessage(message: PlayMessage) {
|
||||
val itemId = message.request.itemIds?.firstOrNull() ?: return
|
||||
|
||||
PlaybackHelper.retrieveAndPlay(
|
||||
itemId.toString(),
|
||||
false,
|
||||
message.request.startPositionTicks,
|
||||
context
|
||||
)
|
||||
runCatching {
|
||||
PlaybackHelper.retrieveAndPlay(
|
||||
itemId.toString(),
|
||||
false,
|
||||
message.request.startPositionTicks,
|
||||
context
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||
}
|
||||
|
||||
@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>()
|
||||
|
||||
@@ -76,6 +76,9 @@ private suspend fun getRandomLibraryShowcase(
|
||||
sortBy = listOf(ItemSortBy.Random),
|
||||
limit = 5,
|
||||
imageTypes = listOf(ImageType.BACKDROP),
|
||||
// TODO: Add preferences for these two settings
|
||||
maxOfficialRating = "PG-13",
|
||||
// hasParentalRating = true,
|
||||
)
|
||||
|
||||
val item = response.items?.firstOrNull { item ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.jellyfin.androidtv.ui.browsing;
|
||||
|
||||
import static org.koin.java.KoinJavaComponent.inject;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.util.DisplayMetrics;
|
||||
@@ -19,6 +18,7 @@ import android.widget.PopupWindow;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.leanback.widget.BaseGridView;
|
||||
import androidx.leanback.widget.OnItemViewClickedListener;
|
||||
import androidx.leanback.widget.OnItemViewSelectedListener;
|
||||
@@ -85,7 +85,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
private final static int CHUNK_SIZE_MINIMUM = 25;
|
||||
|
||||
private String mainTitle;
|
||||
private Activity mActivity;
|
||||
private FragmentActivity mActivity;
|
||||
private BaseRowItem mCurrentItem;
|
||||
private CompositeClickedListener mClickedListener = new CompositeClickedListener();
|
||||
private CompositeSelectedListener mSelectedListener = new CompositeSelectedListener();
|
||||
@@ -758,7 +758,7 @@ public class BrowseGridFragment extends Fragment implements View.OnKeyListener {
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getFilterUnwatchedOnly(), mAdapter.getFilters().isUnwatchedOnly());
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getSortBy(), mAdapter.getSortBy());
|
||||
libraryPreferences.set(LibraryPreferences.Companion.getSortOrder(), getSortOption(mAdapter.getSortBy()).order);
|
||||
CoroutineUtils.runBlocking((coroutineScope, continuation) -> libraryPreferences.commit(continuation));
|
||||
CoroutineUtils.runOnLifecycle(getLifecycle(), (coroutineScope, continuation) -> libraryPreferences.commit(continuation));
|
||||
}
|
||||
|
||||
private void addTools() {
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
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(new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount,
|
||||
ItemFields.MediaStreams,
|
||||
ItemFields.MediaSources
|
||||
});
|
||||
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(new ItemFields[]{
|
||||
ItemFields.PrimaryImageAspectRatio,
|
||||
ItemFields.Overview,
|
||||
ItemFields.ItemCounts,
|
||||
ItemFields.DisplayPreferencesId,
|
||||
ItemFields.ChildCount,
|
||||
ItemFields.MediaStreams,
|
||||
ItemFields.MediaSources
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.browsing
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -26,6 +27,7 @@ import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||
import org.jellyfin.androidtv.ui.screensaver.InAppScreensaver
|
||||
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||
import org.jellyfin.androidtv.util.applyTheme
|
||||
import org.jellyfin.androidtv.util.isMediaSessionKeyEvent
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
@@ -193,4 +195,38 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
screensaverViewModel.notifyInteraction(false)
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
|
||||
// Ignore the key event that closes the screensaver
|
||||
if (!event.isMediaSessionKeyEvent() && screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(canCancel = event.action == KeyEvent.ACTION_UP)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("RestrictedApi") // False positive
|
||||
return super.dispatchKeyShortcutEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
// Ignore the touch event that closes the screensaver
|
||||
if (screensaverViewModel.visible.value) {
|
||||
screensaverViewModel.notifyInteraction(true)
|
||||
return true
|
||||
}
|
||||
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -334,9 +334,13 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
@Override
|
||||
public void run() {
|
||||
if (!getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) return;
|
||||
// View holder may be null when the base item is still loading - this is a rare case
|
||||
// which generally happens when the server is unresponsive
|
||||
MyDetailsOverviewRowPresenter.ViewHolder viewholder = mDorPresenter.getViewHolder();
|
||||
if (viewholder == null) return;
|
||||
|
||||
if (mBaseItem != null && ((mBaseItem.getRunTimeTicks() != null && mBaseItem.getRunTimeTicks() > 0) || mBaseItem.getOriginalRunTimeTicks() != null)) {
|
||||
mDorPresenter.getViewHolder().setInfoValue3(getEndTime());
|
||||
viewholder.setInfoValue3(getEndTime());
|
||||
mLoopHandler.postDelayed(this, 15000);
|
||||
}
|
||||
}
|
||||
@@ -412,6 +416,14 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
|
||||
setBaseItem(response);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@Nullable Exception exception) {
|
||||
Timber.w(exception, "Failed to load item, trying to navigate back.");
|
||||
super.onError(exception);
|
||||
|
||||
navigationRepository.getValue().goBack();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<>();
|
||||
@@ -138,7 +141,7 @@ public class ItemLauncher {
|
||||
}
|
||||
|
||||
// or generic handling
|
||||
if (baseItem.isFolder()) {
|
||||
if (Utils.isTrue(baseItem.isFolder())) {
|
||||
// Some items don't have a display preferences id, but it's required for StdGridFragment
|
||||
// Use the id of the item as a workaround, it's a unique key for the specific item
|
||||
// Which is exactly what we want
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import androidx.lifecycle.flowWithLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.constant.CustomMessage
|
||||
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||
|
||||
fun <T : Any> runBlocking(block: suspend CoroutineScope.() -> T) = kotlinx.coroutines.runBlocking {
|
||||
block()
|
||||
}
|
||||
fun <T : Any> runOnLifecycle(
|
||||
lifecycle: Lifecycle,
|
||||
block: suspend CoroutineScope.() -> T
|
||||
) = lifecycle.coroutineScope.launch { block() }
|
||||
|
||||
fun readCustomMessagesOnLifecycle(
|
||||
lifecycle: Lifecycle,
|
||||
|
||||
@@ -107,7 +107,7 @@ public class InfoLayoutHelper {
|
||||
if (includeRuntime) addRuntime(context, item, layout, includeEndTime);
|
||||
addSeriesStatus(context, item, layout);
|
||||
addRatingAndRes(context, item, mediaSourceIndex, layout);
|
||||
addMediaDetails(context, audioStream, layout);
|
||||
addMediaDetails(context, item, mediaSourceIndex, layout);
|
||||
}
|
||||
|
||||
private static void addText(Context context, String text, LinearLayout layout, int maxWidth) {
|
||||
@@ -392,25 +392,27 @@ public class InfoLayoutHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private static void addMediaDetails(Context context, MediaStream stream, LinearLayout layout) {
|
||||
private static void addMediaDetails(Context context, BaseItemDto item, int mediaSourceIndex, LinearLayout layout) {
|
||||
|
||||
if (stream != null) {
|
||||
if (stream.getProfile() != null && stream.getProfile().contains("Dolby Atmos")) {
|
||||
MediaStream audioStream = StreamHelper.getFirstAudioStream(item, mediaSourceIndex);
|
||||
|
||||
if (audioStream != null) {
|
||||
if (audioStream.getProfile() != null && audioStream.getProfile().contains("Dolby Atmos")) {
|
||||
addBlockText(context, layout, "ATMOS");
|
||||
addSpacer(context, layout, " ");
|
||||
} else if (stream.getProfile() != null && stream.getProfile().contains("DTS:X")) {
|
||||
} else if (audioStream.getProfile() != null && audioStream.getProfile().contains("DTS:X")) {
|
||||
addBlockText(context, layout, "DTS:X");
|
||||
addSpacer(context, layout, " ");
|
||||
} else {
|
||||
String codec = null;
|
||||
if (stream.getProfile() != null && stream.getProfile().contains("DTS-HD")) {
|
||||
if (audioStream.getProfile() != null && audioStream.getProfile().contains("DTS-HD")) {
|
||||
codec = "DTS-HD";
|
||||
} else if (stream.getCodec() != null && stream.getCodec().trim().length() > 0) {
|
||||
switch (stream.getCodec().toLowerCase()) {
|
||||
} else if (audioStream.getCodec() != null &audioStream.getCodec().trim().length() > 0) {
|
||||
switch (audioStream.getCodec().toLowerCase()) {
|
||||
case "dca": codec = "DTS"; break;
|
||||
case "eac3": codec = "DD+"; break;
|
||||
case "ac3": codec = "DD"; break;
|
||||
default: codec = stream.getCodec().toUpperCase();
|
||||
default: codec = audioStream.getCodec().toUpperCase();
|
||||
}
|
||||
}
|
||||
if (codec != null) {
|
||||
@@ -418,8 +420,8 @@ public class InfoLayoutHelper {
|
||||
addSpacer(context, layout, " ");
|
||||
}
|
||||
}
|
||||
if (stream.getChannelLayout() != null && stream.getChannelLayout().trim().length() > 0) {
|
||||
addBlockText(context, layout, stream.getChannelLayout().toUpperCase());
|
||||
if (audioStream.getChannelLayout() != null && audioStream.getChannelLayout().trim().length() > 0) {
|
||||
addBlockText(context, layout, audioStream.getChannelLayout().toUpperCase());
|
||||
addSpacer(context, layout, " ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.jellyfin.androidtv.util
|
||||
|
||||
import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
|
||||
/**
|
||||
* Returns whether this key event is a media key event or not.
|
||||
*/
|
||||
fun KeyEvent.isMediaSessionKeyEvent(): Boolean = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> KeyEvent.isMediaSessionKey(keyCode)
|
||||
|
||||
else -> when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
KeyEvent.KEYCODE_HEADSETHOOK,
|
||||
KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND,
|
||||
KeyEvent.KEYCODE_MEDIA_RECORD,
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.jellyfin.androidtv.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MenuItem;
|
||||
import android.widget.PopupMenu;
|
||||
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.constant.CustomMessage;
|
||||
import org.jellyfin.androidtv.data.querying.StdItemQuery;
|
||||
@@ -55,11 +57,11 @@ public class KeyProcessor {
|
||||
|
||||
private static String mCurrentItemId;
|
||||
private static BaseItemDto mCurrentItem;
|
||||
private static Activity mCurrentActivity;
|
||||
private static FragmentActivity mCurrentActivity;
|
||||
private static int mCurrentRowItemNdx;
|
||||
private static boolean isMusic;
|
||||
|
||||
public static boolean HandleKey(int key, BaseRowItem rowItem, Activity activity) {
|
||||
public static boolean HandleKey(int key, BaseRowItem rowItem, FragmentActivity activity) {
|
||||
if (rowItem == null) return false;
|
||||
MediaManager mediaManager = KoinJavaComponent.<MediaManager>get(MediaManager.class);
|
||||
switch (key) {
|
||||
@@ -188,7 +190,7 @@ public class KeyProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static PopupMenu createItemMenu(BaseRowItem rowItem, UserItemDataDto userData, Activity activity) {
|
||||
public static PopupMenu createItemMenu(BaseRowItem rowItem, UserItemDataDto userData, FragmentActivity activity) {
|
||||
BaseItemDto item = rowItem.getBaseItem();
|
||||
PopupMenu menu = new PopupMenu(activity, activity.getCurrentFocus(), Gravity.END);
|
||||
int order = 0;
|
||||
@@ -278,7 +280,7 @@ public class KeyProcessor {
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static void createPlayMenu(BaseItemDto item, boolean isMusic, Activity activity) {
|
||||
private static void createPlayMenu(BaseItemDto item, boolean isMusic, FragmentActivity activity) {
|
||||
PopupMenu menu = new PopupMenu(activity, activity.getCurrentFocus(), Gravity.END);
|
||||
int order = 0;
|
||||
if (!isMusic && item.getType() != BaseItemKind.PLAYLIST) {
|
||||
@@ -360,16 +362,16 @@ public class KeyProcessor {
|
||||
});
|
||||
return true;
|
||||
case MENU_MARK_FAVORITE:
|
||||
toggleFavorite(true);
|
||||
toggleFavorite(mCurrentActivity, true);
|
||||
return true;
|
||||
case MENU_UNMARK_FAVORITE:
|
||||
toggleFavorite(false);
|
||||
toggleFavorite(mCurrentActivity, false);
|
||||
return true;
|
||||
case MENU_MARK_PLAYED:
|
||||
togglePlayed(true);
|
||||
togglePlayed(mCurrentActivity, true);
|
||||
return true;
|
||||
case MENU_UNMARK_PLAYED:
|
||||
togglePlayed(false);
|
||||
togglePlayed(mCurrentActivity, false);
|
||||
return true;
|
||||
case MENU_GOTO_NOW_PLAYING:
|
||||
NavigationRepository navigationRepository = KoinJavaComponent.get(NavigationRepository.class);
|
||||
@@ -396,10 +398,10 @@ public class KeyProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
private static void togglePlayed(boolean played) {
|
||||
private static void togglePlayed(LifecycleOwner lifecycleOwner, boolean played) {
|
||||
ItemMutationRepository itemMutationRepository = KoinJavaComponent.<ItemMutationRepository>get(ItemMutationRepository.class);
|
||||
|
||||
CoroutineUtils.runBlocking((scope, continuation) ->
|
||||
CoroutineUtils.runOnLifecycle(lifecycleOwner.getLifecycle(), (scope, continuation) ->
|
||||
itemMutationRepository.setPlayed(mCurrentItem.getId(), played, continuation)
|
||||
);
|
||||
|
||||
@@ -407,10 +409,10 @@ public class KeyProcessor {
|
||||
customMessageRepository.pushMessage(CustomMessage.RefreshCurrentItem.INSTANCE);
|
||||
}
|
||||
|
||||
private static void toggleFavorite(boolean favorite) {
|
||||
private static void toggleFavorite(LifecycleOwner lifecycleOwner, boolean favorite) {
|
||||
ItemMutationRepository itemMutationRepository = KoinJavaComponent.<ItemMutationRepository>get(ItemMutationRepository.class);
|
||||
|
||||
CoroutineUtils.runBlocking((scope, continuation) ->
|
||||
CoroutineUtils.runOnLifecycle(lifecycleOwner.getLifecycle(), (scope, continuation) ->
|
||||
itemMutationRepository.setFavorite(mCurrentItem.getId(), favorite, continuation)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jellyfin.androidtv.util.apiclient;
|
||||
|
||||
import android.content.Context;
|
||||
import android.provider.MediaStore;
|
||||
|
||||
import org.jellyfin.androidtv.R;
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository;
|
||||
@@ -8,6 +9,8 @@ import org.jellyfin.androidtv.preference.UserPreferences;
|
||||
import org.jellyfin.androidtv.ui.navigation.Destination;
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationRepository;
|
||||
import org.jellyfin.androidtv.ui.playback.MediaManager;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackController;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer;
|
||||
import org.jellyfin.androidtv.ui.playback.PlaybackLauncher;
|
||||
import org.jellyfin.androidtv.ui.playback.VideoQueueManager;
|
||||
import org.jellyfin.androidtv.util.TimeUtils;
|
||||
@@ -298,7 +301,12 @@ public class PlaybackHelper {
|
||||
default:
|
||||
KoinJavaComponent.<VideoQueueManager>get(VideoQueueManager.class).setCurrentVideoQueue(response);
|
||||
Destination destination = playbackLauncher.getPlaybackDestination(item.getType(), pos);
|
||||
navigationRepository.navigate(destination);
|
||||
|
||||
PlaybackController playbackController = KoinJavaComponent.<PlaybackControllerContainer>get(PlaybackControllerContainer.class).getPlaybackController();
|
||||
navigationRepository.navigate(
|
||||
destination,
|
||||
playbackController != null && playbackController.hasFragment()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.jellyfin.androidtv.util.Utils
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.audioDirectPlayProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceAV1CodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceHevcCodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceHevcLevelCodecProfiles
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.h264VideoLevelProfileCondition
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.h264VideoProfileCondition
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.max1080pProfileConditions
|
||||
@@ -191,8 +192,9 @@ class ExoPlayerProfile(
|
||||
)
|
||||
)
|
||||
})
|
||||
// HEVC profile
|
||||
// HEVC profiles
|
||||
add(deviceHevcCodecProfile)
|
||||
addAll(deviceHevcLevelCodecProfiles)
|
||||
// AV1 profile
|
||||
add(deviceAV1CodecProfile)
|
||||
// Limit video resolution support for older devices
|
||||
@@ -203,7 +205,8 @@ class ExoPlayerProfile(
|
||||
})
|
||||
}
|
||||
// Audio channel profile
|
||||
add(maxAudioChannelsCodecProfile(channels = 8))
|
||||
if (!Utils.downMixAudio(context)) add(maxAudioChannelsCodecProfile(channels = 8))
|
||||
else add(maxAudioChannelsCodecProfile(channels = 2))
|
||||
}.toTypedArray()
|
||||
|
||||
subtitleProfiles = arrayOf(
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.jellyfin.androidtv.constant.Codec
|
||||
import org.jellyfin.androidtv.util.Utils
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.audioDirectPlayProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceHevcCodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceHevcLevelCodecProfiles
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.h264VideoLevelProfileCondition
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.h264VideoProfileCondition
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.maxAudioChannelsCodecProfile
|
||||
@@ -116,21 +117,22 @@ class LibVlcProfile(
|
||||
photoDirectPlayProfile
|
||||
)
|
||||
|
||||
codecProfiles = arrayOf(
|
||||
codecProfiles = buildList {
|
||||
// HEVC profile
|
||||
deviceHevcCodecProfile,
|
||||
add(deviceHevcCodecProfile)
|
||||
addAll(deviceHevcLevelCodecProfiles)
|
||||
// H264 profile
|
||||
CodecProfile().apply {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.H264
|
||||
conditions = arrayOf(
|
||||
h264VideoProfileCondition,
|
||||
h264VideoLevelProfileCondition
|
||||
)
|
||||
},
|
||||
})
|
||||
// Audio channel profile
|
||||
maxAudioChannelsCodecProfile(channels = 8)
|
||||
)
|
||||
add(maxAudioChannelsCodecProfile(channels = 8))
|
||||
}.toTypedArray()
|
||||
|
||||
containerProfiles = arrayOf(
|
||||
ContainerProfile().apply {
|
||||
|
||||
@@ -9,6 +9,23 @@ import timber.log.Timber
|
||||
class MediaCodecCapabilitiesTest {
|
||||
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
|
||||
|
||||
// HEVC levels as reported by ffprobe are multiplied by 30, e.g. level 4.1 is 123
|
||||
private val hevcLevelStrings = listOf(
|
||||
CodecProfileLevel.HEVCMainTierLevel1 to "30",
|
||||
CodecProfileLevel.HEVCMainTierLevel2 to "60",
|
||||
CodecProfileLevel.HEVCMainTierLevel21 to "63",
|
||||
CodecProfileLevel.HEVCMainTierLevel3 to "90",
|
||||
CodecProfileLevel.HEVCMainTierLevel31 to "93",
|
||||
CodecProfileLevel.HEVCMainTierLevel4 to "120",
|
||||
CodecProfileLevel.HEVCMainTierLevel41 to "123",
|
||||
CodecProfileLevel.HEVCMainTierLevel5 to "150",
|
||||
CodecProfileLevel.HEVCMainTierLevel51 to "153",
|
||||
CodecProfileLevel.HEVCMainTierLevel52 to "156",
|
||||
CodecProfileLevel.HEVCMainTierLevel6 to "180",
|
||||
CodecProfileLevel.HEVCMainTierLevel61 to "183",
|
||||
CodecProfileLevel.HEVCMainTierLevel62 to "186",
|
||||
)
|
||||
|
||||
fun supportsAV1(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AV1)
|
||||
|
||||
@@ -24,7 +41,15 @@ class MediaCodecCapabilitiesTest {
|
||||
fun supportsHevcMain10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC,
|
||||
CodecProfileLevel.HEVCProfileMain10,
|
||||
CodecProfileLevel.HEVCMainTierLevel5
|
||||
CodecProfileLevel.HEVCMainTierLevel4
|
||||
)
|
||||
|
||||
fun getHevcMainLevel(): String = getHevcLevelString(
|
||||
CodecProfileLevel.HEVCProfileMain
|
||||
)
|
||||
|
||||
fun getHevcMain10Level(): String = getHevcLevelString(
|
||||
CodecProfileLevel.HEVCProfileMain10
|
||||
)
|
||||
|
||||
fun supportsAVCHigh10(): Boolean = hasDecoder(
|
||||
@@ -33,6 +58,35 @@ class MediaCodecCapabilitiesTest {
|
||||
CodecProfileLevel.AVCLevel4
|
||||
)
|
||||
|
||||
private fun getHevcLevelString(profile: Int): String {
|
||||
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
|
||||
|
||||
return hevcLevelStrings.asReversed().find { item: Pair<Int, String> ->
|
||||
level >= item.first
|
||||
}?.second ?: "0"
|
||||
}
|
||||
|
||||
private fun getDecoderLevel(mime: String, profile: Int): Int {
|
||||
var maxLevel = 0
|
||||
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
try {
|
||||
val capabilities = info.getCapabilitiesForType(mime)
|
||||
for (profileLevel in capabilities.profileLevels) {
|
||||
if (profileLevel.profile == profile) {
|
||||
maxLevel = maxOf(maxLevel, profileLevel.level)
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.d(e, "Decoder %s does not support %s", info.name, mime)
|
||||
}
|
||||
}
|
||||
|
||||
return maxLevel
|
||||
}
|
||||
|
||||
private fun hasDecoder(mime: String, profile: Int, level: Int): Boolean {
|
||||
for (info in mediaCodecList.codecInfos) {
|
||||
if (info.isEncoder) continue
|
||||
|
||||
@@ -105,6 +105,56 @@ object ProfileHelper {
|
||||
}
|
||||
}
|
||||
|
||||
val deviceHevcLevelCodecProfiles by lazy {
|
||||
buildList {
|
||||
if (MediaTest.supportsHevc()) {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.HEVC
|
||||
|
||||
applyConditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main"
|
||||
)
|
||||
)
|
||||
|
||||
conditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
ProfileConditionValue.VideoLevel,
|
||||
MediaTest.getHevcMainLevel()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
if (MediaTest.supportsHevcMain10()) {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.HEVC
|
||||
|
||||
applyConditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main 10"
|
||||
)
|
||||
)
|
||||
|
||||
conditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
ProfileConditionValue.VideoLevel,
|
||||
MediaTest.getHevcMain10Level()
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val h264VideoLevelProfileCondition by lazy {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
|
||||
@@ -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"
|
||||
@@ -55,6 +55,7 @@
|
||||
android:gravity="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="28sp"
|
||||
android:textDirection="ltr"
|
||||
app:strokeWidth="5.0"
|
||||
tools:text="Subtitles" />
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ androidx-core = "1.12.0"
|
||||
androidx-fragment = "1.6.2"
|
||||
androidx-leanback = "1.1.0-rc01"
|
||||
androidx-lifecycle = "2.6.2"
|
||||
androidx-media3 = "1.2.0"
|
||||
androidx-media3 = "1.2.1"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-recyclerview = "1.3.2"
|
||||
androidx-startup = "1.1.1"
|
||||
@@ -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.1+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