Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b882040f | ||
|
|
255215ed12 | ||
|
|
0a4413e799 | ||
|
|
8c25ae7ef9 | ||
|
|
41ec243b27 | ||
|
|
2d40f262b2 | ||
|
|
fe7f0c62d3 | ||
|
|
16a382c6b5 | ||
|
|
bb42fd4801 | ||
|
|
ea16d8a6a4 | ||
|
|
eb869d291a | ||
|
|
8d2f928ad1 | ||
|
|
1c43f27bf7 | ||
|
|
3f8c4bf662 | ||
|
|
c3890f7ac0 | ||
|
|
ab165301d8 | ||
|
|
16dfd24849 | ||
|
|
9a189c57b7 | ||
|
|
d7d774ffbf | ||
|
|
271e0cf320 | ||
|
|
8b4275dea2 | ||
|
|
5b09d3b113 | ||
|
|
62f98901b0 | ||
|
|
19381684ae | ||
|
|
176a7e3391 | ||
|
|
2dbdbe8c28 | ||
|
|
1502dc805b | ||
|
|
29fc35f215 | ||
|
|
af0255440c | ||
|
|
389c48a1fd |
2
.github/workflows/app-build.yaml
vendored
2
.github/workflows/app-build.yaml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
- name: Assemble debug APKs
|
||||
|
||||
2
.github/workflows/app-lint.yaml
vendored
2
.github/workflows/app-lint.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
- name: Run detekt and lint tasks
|
||||
|
||||
2
.github/workflows/app-publish.yaml
vendored
2
.github/workflows/app-publish.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
- name: Set JELLYFIN_VERSION
|
||||
|
||||
2
.github/workflows/app-test.yaml
vendored
2
.github/workflows/app-test.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
|
||||
- name: Run test task
|
||||
|
||||
@@ -108,8 +108,8 @@
|
||||
<activity
|
||||
android:name=".ui.startup.StartupActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
@@ -132,6 +132,7 @@
|
||||
<!-- Main application -->
|
||||
<activity
|
||||
android:name=".ui.browsing.MainActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="landscape"
|
||||
android:windowSoftInputMode="adjustNothing" />
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface ServerRepository {
|
||||
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
|
||||
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 9, 0)
|
||||
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package org.jellyfin.androidtv.ui.browsing
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.ParcelCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentTransaction
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.ui.navigation.NavigationAction
|
||||
import java.util.Stack
|
||||
|
||||
private class HistoryEntry(
|
||||
val name: Class<out Fragment>,
|
||||
val arguments: Bundle = bundleOf(),
|
||||
|
||||
var fragment: Fragment? = null,
|
||||
var savedState: Fragment.SavedState? = null,
|
||||
) : Parcelable {
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeString(name.name)
|
||||
dest.writeBundle(arguments)
|
||||
dest.writeParcelable(savedState, 0)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<HistoryEntry> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun createFromParcel(parcel: Parcel): HistoryEntry = HistoryEntry(
|
||||
name = Class.forName(parcel.readString()!!) as Class<out Fragment>,
|
||||
arguments = parcel.readBundle(this::class.java.classLoader)!!,
|
||||
fragment = null,
|
||||
savedState = ParcelCompat.readParcelable(parcel, this::class.java.classLoader, Fragment.SavedState::class.java)!!,
|
||||
)
|
||||
|
||||
override fun newArray(size: Int): Array<HistoryEntry?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
class DestinationFragmentView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs) {
|
||||
private companion object {
|
||||
private const val FRAGMENT_TAG_CONTENT = "content"
|
||||
private const val BUNDLE_SUPER = "super"
|
||||
private const val BUNDLE_HISTORY = "history"
|
||||
}
|
||||
|
||||
private val fragmentManager by lazy {
|
||||
FragmentManager.findFragmentManager(this)
|
||||
}
|
||||
|
||||
private val container by lazy {
|
||||
FragmentContainerView(context).also { view ->
|
||||
view.id = R.id.container
|
||||
addView(view)
|
||||
}
|
||||
}
|
||||
|
||||
private val history = Stack<HistoryEntry>()
|
||||
|
||||
fun navigate(action: NavigationAction.NavigateFragment) {
|
||||
val entry = HistoryEntry(action.destination.fragment.java, action.destination.arguments)
|
||||
|
||||
// Create the base transaction so we can mutate everything at once
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
|
||||
if (action.clear) {
|
||||
// Clear all current fragments from the history before adding the new entry
|
||||
history.mapNotNull { it.fragment }.forEach { transaction.remove(it) }
|
||||
history.clear()
|
||||
history.push(entry)
|
||||
} else if (action.replace && action.addToBackStack && history.isNotEmpty()) {
|
||||
// Remove the top-most entry before replacing it with the next
|
||||
val currentFragment = history[history.size - 1].fragment
|
||||
if (currentFragment != null) transaction.remove(currentFragment)
|
||||
history[history.size - 1] = entry
|
||||
} else {
|
||||
// Add to the end of the history
|
||||
saveCurrentFragmentState()
|
||||
history.push(entry)
|
||||
}
|
||||
|
||||
activateHistoryEntry(entry, transaction)
|
||||
}
|
||||
|
||||
fun goBack(): Boolean {
|
||||
// Require at least 2 items (current & previous) to go back
|
||||
if (history.size < 2) return false
|
||||
|
||||
// Create the base transaction so we can mutate everything at once
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
|
||||
// Remove current entry
|
||||
val currentEntry = history.pop()
|
||||
|
||||
// Make sure to remove the associated fragment
|
||||
val currentFragment = currentEntry.fragment
|
||||
if (currentFragment != null) transaction.remove(currentFragment)
|
||||
|
||||
// Read & set previous entry
|
||||
val entry = history.last()
|
||||
activateHistoryEntry(entry, transaction)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveCurrentFragmentState() {
|
||||
if (history.isEmpty()) return
|
||||
|
||||
// Update the top-most history entry with state from current fragment
|
||||
val fragment = requireNotNull(fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT))
|
||||
history[history.size - 1].savedState = fragmentManager.saveFragmentInstanceState(fragment)
|
||||
}
|
||||
|
||||
@SuppressLint("CommitTransaction")
|
||||
private fun activateHistoryEntry(
|
||||
entry: HistoryEntry,
|
||||
transaction: FragmentTransaction,
|
||||
) {
|
||||
var fragment = entry.fragment
|
||||
|
||||
// Create if there is no existing fragment
|
||||
if (fragment == null) {
|
||||
fragment = fragmentManager.fragmentFactory.instantiate(context.classLoader, entry.name.name).apply {
|
||||
setInitialSavedState(entry.savedState)
|
||||
}
|
||||
entry.fragment = fragment
|
||||
}
|
||||
|
||||
// Update arguments
|
||||
fragment.arguments = entry.arguments
|
||||
|
||||
transaction.apply {
|
||||
// Set options
|
||||
setReorderingAllowed(true)
|
||||
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
|
||||
|
||||
// Detach current fragment
|
||||
fragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)?.let(::detach)
|
||||
|
||||
// Attach or add next fragment
|
||||
if (fragment.isDetached) attach(fragment)
|
||||
else add(container.id, fragment, FRAGMENT_TAG_CONTENT)
|
||||
}
|
||||
|
||||
// Commit
|
||||
if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss()
|
||||
else transaction.commitNow()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
// Always retrieve current state before writing
|
||||
saveCurrentFragmentState()
|
||||
|
||||
// Save state
|
||||
return bundleOf(
|
||||
BUNDLE_SUPER to super.onSaveInstanceState(),
|
||||
BUNDLE_HISTORY to history.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
// Ignore if not a bundle
|
||||
if (state !is Bundle) return super.onRestoreInstanceState(state)
|
||||
|
||||
// Call parent
|
||||
@Suppress("DEPRECATION")
|
||||
val parent = state.getParcelable<Parcelable>(BUNDLE_SUPER)
|
||||
super.onRestoreInstanceState(parent)
|
||||
|
||||
// Restore history
|
||||
val savedHistory = BundleCompat.getParcelableArrayList(state, BUNDLE_HISTORY, HistoryEntry::class.java)
|
||||
if (savedHistory != null) {
|
||||
history.clear()
|
||||
history.addAll(savedHistory)
|
||||
if (history.isNotEmpty()) activateHistoryEntry(history.last(), fragmentManager.beginTransaction())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,12 @@ import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.commit
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ActivityMainBinding
|
||||
@@ -34,15 +31,10 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import timber.log.Timber
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
companion object {
|
||||
private const val FRAGMENT_TAG_CONTENT = "content"
|
||||
}
|
||||
|
||||
private val navigationRepository by inject<NavigationRepository>()
|
||||
private val sessionRepository by inject<SessionRepository>()
|
||||
private val userRepository by inject<UserRepository>()
|
||||
private val screensaverViewModel by viewModel<ScreensaverViewModel>()
|
||||
private var inTransaction = false
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
@@ -66,16 +58,10 @@ class MainActivity : FragmentActivity() {
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, backPressedCallback)
|
||||
|
||||
supportFragmentManager.addOnBackStackChangedListener {
|
||||
if (!inTransaction && supportFragmentManager.backStackEntryCount == 0)
|
||||
navigationRepository.reset()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset()
|
||||
if (savedInstanceState == null && navigationRepository.canGoBack) navigationRepository.reset(clearHistory = true)
|
||||
|
||||
navigationRepository.currentAction
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
|
||||
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
|
||||
.onEach { action ->
|
||||
handleNavigationAction(action)
|
||||
backPressedCallback.isEnabled = navigationRepository.canGoBack
|
||||
@@ -124,48 +110,23 @@ class MainActivity : FragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigationAction(action: NavigationAction) = when (action) {
|
||||
is NavigationAction.NavigateFragment -> {
|
||||
if (action.clear) {
|
||||
// Clear the current back stack
|
||||
val firstBackStackEntry = supportFragmentManager.getBackStackEntryAt(0)
|
||||
supportFragmentManager.popBackStack(firstBackStackEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
} else if (action.replace) {
|
||||
// Prevent back stack changed listener from resetting when popping to
|
||||
// the initial destination
|
||||
inTransaction = true
|
||||
supportFragmentManager.popBackStack()
|
||||
inTransaction = false
|
||||
}
|
||||
private fun handleNavigationAction(action: NavigationAction) {
|
||||
when (action) {
|
||||
// DestinationFragmentView actions
|
||||
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
|
||||
NavigationAction.GoBack -> binding.contentView.goBack()
|
||||
|
||||
supportFragmentManager.commit {
|
||||
// Others
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val currentFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG_CONTENT)
|
||||
val isSameFragment = currentFragment != null &&
|
||||
destination.fragment.isInstance(currentFragment) &&
|
||||
currentFragment.arguments == destination.arguments
|
||||
|
||||
if (!isSameFragment) {
|
||||
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
|
||||
|
||||
replace(R.id.content_view, destination.fragment.java, destination.arguments, FRAGMENT_TAG_CONTENT)
|
||||
}
|
||||
|
||||
if (action.addToBackStack) addToBackStack(null)
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
intent.putExtras(destination.extras)
|
||||
startActivity(intent)
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
intent.putExtras(destination.extras)
|
||||
startActivity(intent)
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.GoBack -> supportFragmentManager.popBackStack()
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
|
||||
// Forward key events to fragments
|
||||
|
||||
@@ -139,8 +139,8 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
|
||||
|
||||
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
|
||||
if (defaultIndex != null) {
|
||||
val byIndex = get(defaultIndex)
|
||||
if (byIndex.type == type) return byIndex
|
||||
val byIndex = getOrNull(defaultIndex)
|
||||
if (byIndex?.type == type) return byIndex
|
||||
}
|
||||
|
||||
return firstOrNull { it.type == type }
|
||||
|
||||
@@ -1186,6 +1186,10 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
@Override
|
||||
public void onResponse(List<BaseItemDto> response) {
|
||||
if (!getActive()) return;
|
||||
if (response.isEmpty()) {
|
||||
Timber.e("No items to play - ignoring play request.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.getType() == BaseItemKind.MUSIC_ARTIST) {
|
||||
mediaManager.getValue().playNow(requireContext(), response, 0, shuffle);
|
||||
@@ -1196,7 +1200,6 @@ public class FullDetailsFragment extends Fragment implements RecordingIndicatorV
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
void play(final List<BaseItemDto> items, final int pos, final boolean shuffle) {
|
||||
|
||||
@@ -89,7 +89,7 @@ fun ItemListFragment.toggleFavorite(item: BaseItemDto, callback: (item: BaseItem
|
||||
lifecycleScope.launch {
|
||||
val userData = itemMutationRepository.setFavorite(
|
||||
item = item.id,
|
||||
favorite = item.userData?.isFavorite ?: true
|
||||
favorite = !(item.userData?.isFavorite ?: false)
|
||||
)
|
||||
callback(item.copy(userData = userData))
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ open class BaseItemDtoBaseRowItem @JvmOverloads constructor(
|
||||
val preferSeriesPoster: Boolean = false,
|
||||
) : BaseRowItem(
|
||||
baseRowType = when (item.type) {
|
||||
BaseItemKind.CHANNEL,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.LIVE_TV_CHANNEL -> BaseRowType.LiveTvChannel
|
||||
|
||||
|
||||
@@ -60,20 +60,13 @@ interface NavigationRepository {
|
||||
}
|
||||
|
||||
class NavigationRepositoryImpl(
|
||||
private val initialDestination: Destination.Fragment,
|
||||
private val defaultDestination: Destination.Fragment,
|
||||
) : NavigationRepository {
|
||||
private val fragmentHistory = Stack<Destination.Fragment>()
|
||||
|
||||
private val _currentAction = MutableSharedFlow<NavigationAction>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val currentAction = _currentAction.asSharedFlow()
|
||||
|
||||
init {
|
||||
// Never add the initial destination to the history to prevent an empty screen when the user
|
||||
// uses the "back" button to close the app
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(initialDestination, false, false, false))
|
||||
Timber.d("Navigating to $initialDestination (via init)")
|
||||
}
|
||||
|
||||
override fun navigate(destination: Destination, replace: Boolean) {
|
||||
Timber.d("Navigating to $destination (via navigate function)")
|
||||
val action = when (destination) {
|
||||
@@ -83,11 +76,11 @@ class NavigationRepositoryImpl(
|
||||
_currentAction.tryEmit(NavigationAction.Nothing)
|
||||
}
|
||||
}
|
||||
_currentAction.tryEmit(action)
|
||||
if (destination is Destination.Fragment) {
|
||||
if (replace && fragmentHistory.isNotEmpty()) fragmentHistory[fragmentHistory.lastIndex] = destination
|
||||
else fragmentHistory.push(destination)
|
||||
}
|
||||
_currentAction.tryEmit(action)
|
||||
}
|
||||
|
||||
override val canGoBack: Boolean get() = fragmentHistory.isNotEmpty()
|
||||
@@ -103,8 +96,8 @@ class NavigationRepositoryImpl(
|
||||
|
||||
override fun reset(destination: Destination.Fragment?, clearHistory: Boolean) {
|
||||
fragmentHistory.clear()
|
||||
val actualDestination = destination ?: initialDestination
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, false, false, clearHistory))
|
||||
val actualDestination = destination ?: defaultDestination
|
||||
_currentAction.tryEmit(NavigationAction.NavigateFragment(actualDestination, true, false, clearHistory))
|
||||
Timber.d("Navigating to $actualDestination (via reset, clearHistory=$clearHistory)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
private boolean mFadeEnabled = false;
|
||||
private boolean mIsVisible = false;
|
||||
private boolean mPopupPanelVisible = false;
|
||||
private boolean navigating = false;
|
||||
|
||||
private LeanbackOverlayFragment leanbackOverlayFragment;
|
||||
|
||||
@@ -175,11 +176,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
requireActivity().setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
|
||||
mItemsToPlay = videoQueueManager.getValue().getCurrentVideoQueue();
|
||||
if (mItemsToPlay == null || mItemsToPlay.size() == 0) {
|
||||
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
|
||||
closePlayer();
|
||||
return;
|
||||
}
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
int mediaPosition = videoQueueManager.getValue().getCurrentMediaPosition();
|
||||
|
||||
@@ -240,6 +237,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) {
|
||||
Utils.showToast(requireContext(), getString(R.string.msg_no_playable_items));
|
||||
closePlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackControllerContainer.getValue().getPlaybackController() != null) {
|
||||
playbackControllerContainer.getValue().getPlaybackController().init(new VideoManager((requireActivity()), view, helper), this);
|
||||
}
|
||||
@@ -257,7 +260,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
@Override
|
||||
public void onActivityCreated(Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
if (mItemsToPlay == null || mItemsToPlay.size() == 0) return;
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
prepareOverlayFragment();
|
||||
|
||||
@@ -333,8 +336,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
return null;
|
||||
});
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(backPressedCallback);
|
||||
|
||||
int startPos = getArguments().getInt("Position", 0);
|
||||
|
||||
// start playing
|
||||
@@ -343,6 +344,13 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(this, backPressedCallback);
|
||||
}
|
||||
|
||||
private void prepareOverlayFragment() {
|
||||
leanbackOverlayFragment = (LeanbackOverlayFragment) getChildFragmentManager().findFragmentById(R.id.leanback_fragment);
|
||||
if (leanbackOverlayFragment != null) {
|
||||
@@ -420,7 +428,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
};
|
||||
|
||||
private OnBackPressedCallback backPressedCallback = new OnBackPressedCallback(true) {
|
||||
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (mPopupPanelVisible) {
|
||||
@@ -679,12 +686,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
// Close player when resuming without a valid playback contoller
|
||||
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
|
||||
if (playbackController == null || !playbackController.hasFragment()) {
|
||||
if (navigationRepository.getValue().getCanGoBack()) {
|
||||
navigationRepository.getValue().goBack();
|
||||
} else {
|
||||
navigationRepository.getValue().reset(Destinations.INSTANCE.getHome());
|
||||
}
|
||||
|
||||
closePlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -702,6 +704,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (mItemsToPlay == null || mItemsToPlay.isEmpty()) return;
|
||||
|
||||
setPlayPauseActionState(0);
|
||||
|
||||
@@ -716,10 +719,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
|
||||
if (leanbackOverlayFragment != null)
|
||||
leanbackOverlayFragment.setOnKeyInterceptListener(null);
|
||||
if (backPressedCallback != null) {
|
||||
backPressedCallback.remove();
|
||||
backPressedCallback = null;
|
||||
}
|
||||
|
||||
// end playback from here if this fragment belongs to the current session.
|
||||
// if it doesn't, playback has already been stopped elsewhere, and the references to this have been replaced
|
||||
@@ -727,6 +726,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
Timber.d("this fragment belongs to the current session, ending it");
|
||||
playbackControllerContainer.getValue().getPlaybackController().endPlayback();
|
||||
}
|
||||
|
||||
closePlayer();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
@@ -1306,6 +1307,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void closePlayer() {
|
||||
if (navigating) return;
|
||||
navigating = true;
|
||||
|
||||
if (navigationRepository.getValue().getCanGoBack()) {
|
||||
navigationRepository.getValue().goBack();
|
||||
} else {
|
||||
@@ -1314,6 +1318,9 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
|
||||
}
|
||||
|
||||
public void showNextUp(@NonNull UUID id) {
|
||||
if (navigating) return;
|
||||
navigating = true;
|
||||
|
||||
navigationRepository.getValue().navigate(Destinations.INSTANCE.nextUp(id), true);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
private long mCurrentTranscodeStartTime;
|
||||
private boolean isLiveTv = false;
|
||||
private boolean directStreamLiveTv;
|
||||
private boolean exoErrorEncountered;
|
||||
private int playbackRetries = 0;
|
||||
private long lastPlaybackError = 0;
|
||||
|
||||
@@ -249,8 +248,6 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
public void playerErrorEncountered() {
|
||||
exoErrorEncountered = true;
|
||||
|
||||
// reset the retry count if it's been more than 30s since previous error
|
||||
if (playbackRetries > 0 && Instant.now().toEpochMilli() - lastPlaybackError > 30000) {
|
||||
Timber.d("playback stabilized - retry count reset to 0 from %s", playbackRetries);
|
||||
@@ -518,8 +515,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
internalOptions.setItemId(item.getId());
|
||||
internalOptions.setMediaSources(item.getMediaSources());
|
||||
internalOptions.setMaxBitrate(maxBitrate);
|
||||
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
|
||||
internalOptions.setEnableDirectStream(false);
|
||||
if (playbackRetries > 0 || (isLiveTv && !directStreamLiveTv)) internalOptions.setEnableDirectStream(false);
|
||||
if (playbackRetries > 1) internalOptions.setEnableDirectPlay(false);
|
||||
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
|
||||
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
|
||||
if (!isLiveTv && currentMediaSource != null) {
|
||||
@@ -928,7 +925,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
|
||||
}
|
||||
|
||||
private void resetPlayerErrors() {
|
||||
exoErrorEncountered = false;
|
||||
playbackRetries = 0;
|
||||
}
|
||||
|
||||
private void clearPlaybackSessionOptions() {
|
||||
|
||||
@@ -185,6 +185,8 @@ public class VideoManager {
|
||||
httpDataSourceFactory.setReadTimeoutMs(30 * 1000);
|
||||
DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, httpDataSourceFactory);
|
||||
exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory));
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true);
|
||||
extractorsFactory.setConstantBitrateSeekingAlwaysEnabled(true);
|
||||
|
||||
return exoPlayerBuilder;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public class CardPresenter extends Presenter {
|
||||
} else if (imageType.equals(ImageType.THUMB)) {
|
||||
aspect = ImageHelper.ASPECT_RATIO_16_9;
|
||||
} else {
|
||||
aspect = Utils.getSafeValue(imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb()), ImageHelper.ASPECT_RATIO_7_9);
|
||||
aspect = imageHelper.getValue().getImageAspectRatio(itemDto, m.getPreferParentThumb());
|
||||
}
|
||||
switch (itemDto.getType()) {
|
||||
case AUDIO:
|
||||
@@ -183,9 +183,9 @@ public class CardPresenter extends Presenter {
|
||||
case MOVIE:
|
||||
case VIDEO:
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);
|
||||
showProgress = true;
|
||||
if (imageType.equals(ImageType.POSTER))
|
||||
aspect = ImageHelper.ASPECT_RATIO_2_3;
|
||||
showProgress = true;
|
||||
break;
|
||||
default:
|
||||
mDefaultCardImage = ContextCompat.getDrawable(mCardView.getContext(), R.drawable.tile_port_video);
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.jellyfin.androidtv.R
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||
import org.jellyfin.androidtv.auth.repository.SessionRepositoryState
|
||||
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||
import org.jellyfin.androidtv.databinding.ActivityMainBinding
|
||||
import org.jellyfin.androidtv.databinding.ActivityStartupBinding
|
||||
import org.jellyfin.androidtv.ui.background.AppBackground
|
||||
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||
@@ -62,7 +62,7 @@ class StartupActivity : FragmentActivity() {
|
||||
private val navigationRepository: NavigationRepository by inject()
|
||||
private val itemLauncher: ItemLauncher by inject()
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var binding: ActivityStartupBinding
|
||||
|
||||
private val networkPermissionsRequester = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
@@ -83,7 +83,7 @@ class StartupActivity : FragmentActivity() {
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
binding = ActivityStartupBinding.inflate(layoutInflater)
|
||||
binding.background.setContent { AppBackground() }
|
||||
binding.screensaver.isVisible = false
|
||||
setContentView(binding.root)
|
||||
@@ -156,7 +156,7 @@ class StartupActivity : FragmentActivity() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
navigationRepository.reset(destination)
|
||||
navigationRepository.reset(destination, true)
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
// Clear navigation history
|
||||
|
||||
@@ -29,6 +29,11 @@ object DeviceUtils {
|
||||
// Google Chromecast HD Model
|
||||
private const val GOOGLE_CHROMECAST_HD_MODEL = "Chromecast HD"
|
||||
|
||||
// Xiaomi TV Stick
|
||||
private const val MI_TV_STICK_MODEL_1 = "MDZ-24-AA"
|
||||
private const val MI_TV_STICK_MODEL_2 = "MDZ-24-AB"
|
||||
private const val MI_TV_STICK_MODEL_3 = "MiTV-AESP0"
|
||||
|
||||
@JvmStatic
|
||||
val isFireTv: Boolean = Build.MODEL.startsWith(FIRE_TV_PREFIX)
|
||||
|
||||
@@ -57,6 +62,9 @@ object DeviceUtils {
|
||||
FIRE_STICK_LITE_MODEL,
|
||||
FIRE_TV_MODEL_GEN_1,
|
||||
FIRE_TV_MODEL_GEN_2,
|
||||
GOOGLE_CHROMECAST_HD_MODEL
|
||||
GOOGLE_CHROMECAST_HD_MODEL,
|
||||
MI_TV_STICK_MODEL_1,
|
||||
MI_TV_STICK_MODEL_2,
|
||||
MI_TV_STICK_MODEL_3
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,18 +25,19 @@ class ImageHelper(
|
||||
const val MAX_PRIMARY_IMAGE_HEIGHT: Int = 370
|
||||
}
|
||||
|
||||
fun getImageAspectRatio(item: BaseItemDto, preferParentThumb: Boolean): Double? {
|
||||
fun getImageAspectRatio(item: BaseItemDto, preferParentThumb: Boolean): Double {
|
||||
if (preferParentThumb && (item.parentThumbItemId != null || item.seriesThumbImageTag != null)) {
|
||||
return ASPECT_RATIO_16_9
|
||||
}
|
||||
|
||||
val primaryAspectRatio = item.primaryImageAspectRatio;
|
||||
if (item.type == BaseItemKind.EPISODE) {
|
||||
if (item.primaryImageAspectRatio != null) return item.primaryImageAspectRatio
|
||||
if (primaryAspectRatio != null) return primaryAspectRatio
|
||||
if (item.parentThumbItemId != null || item.seriesThumbImageTag != null) return ASPECT_RATIO_16_9
|
||||
}
|
||||
|
||||
if (item.type == BaseItemKind.USER_VIEW && item.imageTags?.containsKey(ImageType.PRIMARY) == true) return ASPECT_RATIO_16_9
|
||||
return item.primaryImageAspectRatio ?: ASPECT_RATIO_7_9
|
||||
return primaryAspectRatio ?: ASPECT_RATIO_7_9
|
||||
}
|
||||
|
||||
fun getPrimaryImageUrl(item: BaseItemPerson, maxHeight: Int? = null): String? {
|
||||
|
||||
@@ -3,10 +3,10 @@ package org.jellyfin.androidtv.util.profile
|
||||
import org.jellyfin.androidtv.constant.Codec
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.audioDirectPlayProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceAV1CodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceAVCCodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.deviceAVCLevelCodecProfiles
|
||||
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
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.maxAudioChannelsCodecProfile
|
||||
import org.jellyfin.androidtv.util.profile.ProfileHelper.photoDirectPlayProfile
|
||||
@@ -154,15 +154,8 @@ class ExoPlayerProfile(
|
||||
|
||||
codecProfiles = buildList {
|
||||
// H264 profile
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.H264
|
||||
conditions = buildList {
|
||||
add(h264VideoProfileCondition)
|
||||
add(h264VideoLevelProfileCondition)
|
||||
if (disable4KVideo) addAll(max1080pProfileConditions)
|
||||
}.toTypedArray()
|
||||
})
|
||||
add(deviceAVCCodecProfile)
|
||||
addAll(deviceAVCLevelCodecProfiles)
|
||||
// H264 ref frames profile
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
|
||||
@@ -9,6 +9,27 @@ import timber.log.Timber
|
||||
class MediaCodecCapabilitiesTest {
|
||||
private val mediaCodecList by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS) }
|
||||
|
||||
// AVC levels as reported by ffprobe are multiplied by 10, e.g. level 4.1 is 41. Level 1b is set to 9
|
||||
private val avcLevelStrings = listOf(
|
||||
CodecProfileLevel.AVCLevel1b to "9",
|
||||
CodecProfileLevel.AVCLevel1 to "10",
|
||||
CodecProfileLevel.AVCLevel11 to "11",
|
||||
CodecProfileLevel.AVCLevel12 to "12",
|
||||
CodecProfileLevel.AVCLevel13 to "13",
|
||||
CodecProfileLevel.AVCLevel2 to "20",
|
||||
CodecProfileLevel.AVCLevel21 to "21",
|
||||
CodecProfileLevel.AVCLevel22 to "22",
|
||||
CodecProfileLevel.AVCLevel3 to "30",
|
||||
CodecProfileLevel.AVCLevel31 to "31",
|
||||
CodecProfileLevel.AVCLevel32 to "32",
|
||||
CodecProfileLevel.AVCLevel4 to "40",
|
||||
CodecProfileLevel.AVCLevel41 to "41",
|
||||
CodecProfileLevel.AVCLevel42 to "42",
|
||||
CodecProfileLevel.AVCLevel5 to "50",
|
||||
CodecProfileLevel.AVCLevel51 to "51",
|
||||
CodecProfileLevel.AVCLevel52 to "52",
|
||||
)
|
||||
|
||||
// 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",
|
||||
@@ -36,6 +57,30 @@ class MediaCodecCapabilitiesTest {
|
||||
CodecProfileLevel.AV1Level5
|
||||
)
|
||||
|
||||
fun supportsAVC(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
|
||||
fun supportsAVCHigh10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
CodecProfileLevel.AVCProfileHigh10,
|
||||
CodecProfileLevel.AVCLevel4
|
||||
)
|
||||
|
||||
fun getAVCMainLevel(): String = getAVCLevelString(
|
||||
CodecProfileLevel.AVCProfileMain
|
||||
)
|
||||
|
||||
fun getAVCHigh10Level(): String = getAVCLevelString(
|
||||
CodecProfileLevel.AVCProfileHigh10
|
||||
)
|
||||
|
||||
private fun getAVCLevelString(profile: Int): String {
|
||||
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_AVC, profile)
|
||||
|
||||
return avcLevelStrings.asReversed().find { item: Pair<Int, String> ->
|
||||
level >= item.first
|
||||
}?.second ?: "0"
|
||||
}
|
||||
|
||||
fun supportsHevc(): Boolean = hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_HEVC)
|
||||
|
||||
fun supportsHevcMain10(): Boolean = hasDecoder(
|
||||
@@ -52,12 +97,6 @@ class MediaCodecCapabilitiesTest {
|
||||
CodecProfileLevel.HEVCProfileMain10
|
||||
)
|
||||
|
||||
fun supportsAVCHigh10(): Boolean = hasDecoder(
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC,
|
||||
CodecProfileLevel.AVCProfileHigh10,
|
||||
CodecProfileLevel.AVCLevel4
|
||||
)
|
||||
|
||||
private fun getHevcLevelString(profile: Int): String {
|
||||
val level = getDecoderLevel(MediaFormat.MIMETYPE_VIDEO_HEVC, profile)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.androidtv.util.profile
|
||||
|
||||
import org.jellyfin.androidtv.constant.Codec
|
||||
import org.jellyfin.androidtv.util.DeviceUtils
|
||||
import org.jellyfin.apiclient.model.dlna.CodecProfile
|
||||
import org.jellyfin.apiclient.model.dlna.CodecType
|
||||
import org.jellyfin.apiclient.model.dlna.DirectPlayProfile
|
||||
@@ -14,11 +13,6 @@ import org.jellyfin.apiclient.model.dlna.SubtitleProfile
|
||||
import timber.log.Timber
|
||||
|
||||
object ProfileHelper {
|
||||
// H264 codec levels https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
|
||||
private const val H264_LEVEL_4_1 = "41"
|
||||
private const val H264_LEVEL_5_1 = "51"
|
||||
private const val H264_LEVEL_5_2 = "52"
|
||||
|
||||
private val MediaTest by lazy { MediaCodecCapabilitiesTest() }
|
||||
|
||||
val deviceAV1CodecProfile by lazy {
|
||||
@@ -44,7 +38,7 @@ object ProfileHelper {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.NotEquals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main 10"
|
||||
"main 10"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -63,10 +57,115 @@ object ProfileHelper {
|
||||
}
|
||||
}
|
||||
|
||||
val supportsAVC by lazy {
|
||||
MediaTest.supportsAVC()
|
||||
}
|
||||
|
||||
val supportsAVCHigh10 by lazy {
|
||||
MediaTest.supportsAVCHigh10()
|
||||
}
|
||||
|
||||
val deviceAVCCodecProfile by lazy {
|
||||
CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.H264
|
||||
|
||||
conditions = when {
|
||||
!supportsAVC -> {
|
||||
// If AVC is not supported, exclude all AVC profiles
|
||||
Timber.i("*** Does NOT support AVC")
|
||||
arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"none"
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// If AVC is supported, include all relevant profiles
|
||||
Timber.i("*** Supports AVC")
|
||||
arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.EqualsAny,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
if (supportsAVCHigh10) "high 10" else null
|
||||
).joinToString("|")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deviceAVCLevelCodecProfiles by lazy {
|
||||
buildList {
|
||||
if (supportsAVC) {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.H264
|
||||
|
||||
applyConditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.EqualsAny,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline"
|
||||
).joinToString("|")
|
||||
)
|
||||
)
|
||||
|
||||
conditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
ProfileConditionValue.VideoLevel,
|
||||
MediaTest.getAVCMainLevel()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
if (supportsAVCHigh10) {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.H264
|
||||
|
||||
applyConditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"high 10"
|
||||
)
|
||||
)
|
||||
|
||||
conditions = arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
ProfileConditionValue.VideoLevel,
|
||||
MediaTest.getAVCHigh10Level()
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val supportsHevc by lazy {
|
||||
MediaTest.supportsHevc()
|
||||
}
|
||||
|
||||
val supportsHevcMain10 by lazy {
|
||||
MediaTest.supportsHevcMain10()
|
||||
}
|
||||
|
||||
val deviceHevcCodecProfile by lazy {
|
||||
CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
@@ -84,24 +183,17 @@ object ProfileHelper {
|
||||
)
|
||||
)
|
||||
}
|
||||
!MediaTest.supportsHevcMain10() -> {
|
||||
Timber.i("*** Does NOT support HEVC 10 bit")
|
||||
arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.NotEquals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main 10"
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// supports all HEVC
|
||||
// If HEVC is supported, include all relevant profiles
|
||||
Timber.i("*** Supports HEVC 10 bit")
|
||||
arrayOf(
|
||||
ProfileCondition(
|
||||
ProfileConditionType.NotEquals,
|
||||
ProfileConditionType.EqualsAny,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"none"
|
||||
listOfNotNull(
|
||||
"main",
|
||||
if (supportsHevcMain10) "main 10" else null
|
||||
).joinToString("|")
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -120,7 +212,7 @@ object ProfileHelper {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main"
|
||||
"main"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -133,7 +225,7 @@ object ProfileHelper {
|
||||
)
|
||||
})
|
||||
|
||||
if (MediaTest.supportsHevcMain10()) {
|
||||
if (supportsHevcMain10) {
|
||||
add(CodecProfile().apply {
|
||||
type = CodecType.Video
|
||||
codec = Codec.Video.HEVC
|
||||
@@ -142,7 +234,7 @@ object ProfileHelper {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.Equals,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
"Main 10"
|
||||
"main 10"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -159,35 +251,6 @@ object ProfileHelper {
|
||||
}
|
||||
}
|
||||
|
||||
val h264VideoLevelProfileCondition by lazy {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.LessThanEqual,
|
||||
ProfileConditionValue.VideoLevel,
|
||||
when {
|
||||
// https://developer.amazon.com/docs/fire-tv/device-specifications.html
|
||||
DeviceUtils.isFireTvStick4k -> H264_LEVEL_5_2
|
||||
DeviceUtils.isFireTv4k -> H264_LEVEL_5_2
|
||||
DeviceUtils.isFireTv -> H264_LEVEL_4_1
|
||||
DeviceUtils.isShieldTv -> H264_LEVEL_5_2
|
||||
else -> H264_LEVEL_5_1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val h264VideoProfileCondition by lazy {
|
||||
ProfileCondition(
|
||||
ProfileConditionType.EqualsAny,
|
||||
ProfileConditionValue.VideoProfile,
|
||||
listOfNotNull(
|
||||
"high",
|
||||
"main",
|
||||
"baseline",
|
||||
"constrained baseline",
|
||||
if (MediaTest.supportsAVCHigh10()) "high 10" else null
|
||||
).joinToString("|")
|
||||
)
|
||||
}
|
||||
|
||||
val max1080pProfileConditions by lazy {
|
||||
arrayOf(
|
||||
ProfileCondition(
|
||||
|
||||
@@ -55,7 +55,9 @@ class SdkPlaybackHelper(
|
||||
) {
|
||||
getScope(context).launch {
|
||||
runCatching {
|
||||
getItems(mainItem, allowIntros, shuffle)
|
||||
val items = getItems(mainItem, allowIntros, shuffle)
|
||||
if (items.isEmpty() && !mainItem.mediaSources.isNullOrEmpty()) listOf(mainItem)
|
||||
else items
|
||||
}.fold(
|
||||
onSuccess = { items -> outerResponse.onResponse(items) },
|
||||
onFailure = { exception ->
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
<org.jellyfin.androidtv.ui.browsing.DestinationFragmentView
|
||||
android:id="@+id/content_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/screensaver"
|
||||
|
||||
26
app/src/main/res/layout/activity_startup.xml
Normal file
26
app/src/main/res/layout/activity_startup.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.startup.MainActivity">
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:descendantFocusability="blocksDescendants"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/content_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/screensaver"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</RelativeLayout>
|
||||
@@ -12,7 +12,7 @@ buildscript {
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of("17"))
|
||||
languageVersion.set(JavaLanguageVersion.of(libs.versions.java.jdk.get()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ plugins {
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of("17"))
|
||||
languageVersion.set(JavaLanguageVersion.of(libs.versions.java.jdk.get()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
buildSrc/settings.gradle.kts
Normal file
7
buildSrc/settings.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
dependencyResolutionManagement {
|
||||
versionCatalogs {
|
||||
create("libs") {
|
||||
from(files("../gradle/libs.versions.toml"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,24 @@ androidx-core = "1.13.1"
|
||||
androidx-fragment = "1.8.2"
|
||||
androidx-leanback = "1.1.0-rc01"
|
||||
androidx-lifecycle = "2.8.4"
|
||||
androidx-media3 = "1.4.0"
|
||||
androidx-media3 = "1.4.1"
|
||||
androidx-preference = "1.2.1"
|
||||
androidx-recyclerview = "1.3.2"
|
||||
androidx-startup = "1.1.1"
|
||||
androidx-tv = "1.0.0-rc02"
|
||||
androidx-tv = "1.0.0"
|
||||
androidx-tvprovider = "1.1.0-alpha01"
|
||||
androidx-window = "1.3.0"
|
||||
androidx-work = "2.9.1"
|
||||
coil = "2.7.0"
|
||||
detekt = "1.23.6"
|
||||
java-jdk = "21"
|
||||
jellyfin-androidx-media = "1.3.1+2"
|
||||
jellyfin-apiclient = "v0.7.10"
|
||||
jellyfin-sdk = "1.5.3"
|
||||
jellyfin-sdk = "1.5.5"
|
||||
koin = "3.5.6"
|
||||
koin-compose = "3.5.6"
|
||||
kotest = "5.9.1"
|
||||
kotlin = "2.0.0"
|
||||
kotlin = "2.0.20"
|
||||
kotlinx-coroutines = "1.8.1"
|
||||
kotlinx-serialization = "1.7.1"
|
||||
markwon = "4.6.2"
|
||||
|
||||
@@ -21,7 +21,8 @@ class AudioMediaStreamResolver(
|
||||
) : JellyfinStreamResolver(api, profile) {
|
||||
companion object {
|
||||
private val REMUX_CONTAINERS = arrayOf("mp3", "ogg", "mkv")
|
||||
private const val REMUX_SEGMENT_CONTAINER = "mp3"
|
||||
private const val TRANSCODE_SEGMENT_CONTAINER = "mp4"
|
||||
private const val TRANSCODE_CODEC = "aac"
|
||||
}
|
||||
|
||||
private fun MediaInfo.getDirectPlayStream() = BasicMediaStream(
|
||||
@@ -101,7 +102,8 @@ class AudioMediaStreamResolver(
|
||||
mediaSourceId = requireNotNull(mediaInfo.mediaSource.id),
|
||||
playSessionId = mediaInfo.playSessionId,
|
||||
tag = mediaInfo.mediaSource.eTag,
|
||||
segmentContainer = REMUX_SEGMENT_CONTAINER,
|
||||
segmentContainer = TRANSCODE_SEGMENT_CONTAINER,
|
||||
audioCodec = TRANSCODE_CODEC,
|
||||
).appendAccessToken()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,14 +84,15 @@ class ExoPlayerBackend(
|
||||
}
|
||||
),
|
||||
DefaultExtractorsFactory().apply {
|
||||
val isLowRamDevice =
|
||||
context.getSystemService<ActivityManager>()?.isLowRamDevice == true
|
||||
val isLowRamDevice = context.getSystemService<ActivityManager>()?.isLowRamDevice == true
|
||||
setTsExtractorTimestampSearchBytes(
|
||||
when (isLowRamDevice) {
|
||||
true -> TS_SEARCH_BYTES_LM
|
||||
false -> TS_SEARCH_BYTES_HM
|
||||
}
|
||||
)
|
||||
setConstantBitrateSeekingEnabled(true)
|
||||
setConstantBitrateSeekingAlwaysEnabled(true)
|
||||
}
|
||||
))
|
||||
.setPauseAtEndOfMediaItems(true)
|
||||
|
||||
Reference in New Issue
Block a user