Compare commits

...

44 Commits

Author SHA1 Message Date
Niels van Velzen
fe7f0c62d3 Restore forced aspect ratio for poster types on video items
(cherry picked from commit bf4198a8b8)
2024-10-13 13:57:35 +02:00
Niels van Velzen
16a382c6b5 Fix video player not closing when fragment is stopped
(cherry picked from commit c522f09cc2)
2024-10-13 13:57:29 +02:00
Niels van Velzen
bb42fd4801 Fix incorrectly navigating on resume
(cherry picked from commit 2c7bd27525)
2024-09-28 12:53:11 +02:00
Niels van Velzen
ea16d8a6a4 Change upcomingMinimumServerVersion to 10.9.11 to encourage updating
Profile related fixes in 10.9.10 improve playback, however users may not be aware of that. Use the upcoming minimum server function to notify users about this version. We'll still support 10.9.0>=.

(cherry picked from commit fdde857be6)
2024-09-28 12:53:11 +02:00
Bond_009
eb869d291a Fix aspect ratio specials
Don't overwrite the aspect ratio for videos
Change return value of ImageHelper.getImageAspectRatio to not be nullable

Fixes #1192
Possibly a regression from #830

(cherry picked from commit 90573f610f)
2024-09-28 12:53:10 +02:00
Niels van Velzen
8d2f928ad1 Fix back press callback in CustomPlaybackOverlayFragment
(cherry picked from commit 933fdbcca4)
2024-09-28 12:53:10 +02:00
renovate[bot]
1c43f27bf7 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.5
(cherry picked from commit 8d6d3b3a35)
2024-09-08 11:24:54 +02:00
Niels van Velzen
3f8c4bf662 Fix current fragment not removed on back press
(cherry picked from commit 13f6a70fa5)
2024-09-08 11:24:43 +02:00
Niels van Velzen
c3890f7ac0 Use custom logic for fragment back stack
(cherry picked from commit e460c6e403)
2024-09-08 11:24:42 +02:00
MichaelRUSF
ab165301d8 Update DeviceUtils with Xiaomi TV Stick (#3951)
* Add Mi Stick to DeviceUtils

* Fix comment

(cherry picked from commit 86d6195a06)
2024-09-08 11:24:42 +02:00
renovate[bot]
16dfd24849 Update androidx.media3 to v1.4.1
(cherry picked from commit be43311978)
2024-09-08 11:24:16 +02:00
renovate[bot]
9a189c57b7 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.4
(cherry picked from commit 3916c2a117)
2024-09-08 11:24:16 +02:00
Niels van Velzen
d7d774ffbf Fix capitalization in video profiles causing invalid transcodes
(cherry picked from commit b116db0d49)
2024-09-08 11:24:06 +02:00
MichaelRUSF
271e0cf320 Fix for devices with no HEVC Main 10 support
(cherry picked from commit fe05b82b95)
2024-09-08 11:24:06 +02:00
MichaelRUSF
8b4275dea2 AVC level check profiles
(cherry picked from commit 50ef4a7718)
2024-09-08 11:24:05 +02:00
MichaelRUSF
5b09d3b113 AVC media codec levels
(cherry picked from commit 49bcd6fe2c)
2024-09-08 11:24:05 +02:00
renovate[bot]
62f98901b0 Update Kotlin to v2.0.20
(cherry picked from commit 09027c2bc5)
2024-08-22 17:47:50 +02:00
renovate[bot]
19381684ae Update dependency androidx.tv:tv-material to v1.0.0
(cherry picked from commit f2a47d1c9a)
2024-08-22 17:47:11 +02:00
Niels van Velzen
176a7e3391 Improve behavior when no playable items found
(cherry picked from commit 1f789ac00f)
2024-08-22 17:47:07 +02:00
Niels van Velzen
2dbdbe8c28 Disable direct play when encountering playback error twice
(cherry picked from commit 1199fa5253)
2024-08-22 17:47:06 +02:00
Niels van Velzen
1502dc805b Fix toggling favorites in ItemListFragment
(cherry picked from commit a4ec607543)
2024-08-22 17:47:06 +02:00
Niels van Velzen
29fc35f215 Fix channels interpreted as Live TV channels
(cherry picked from commit ae2d74b263)
2024-08-22 17:47:06 +02:00
Niels van Velzen
af0255440c Enable constant bitrate seeking
(cherry picked from commit e65492c254)
2024-08-22 17:47:05 +02:00
Niels van Velzen
389c48a1fd Fix crash when item doesn't have a default audio/subtitle stream
(cherry picked from commit 9577e11e41)
2024-08-12 08:44:31 +02:00
Niels van Velzen
7d84011d81 Re-use supportsHevc in one additional place
(cherry picked from commit 6111cbec5a)
2024-08-11 09:59:40 +02:00
Joseph Price
36a307cd7f Really don't request HEVC if unsupported
(cherry picked from commit c7a4c89b74)
2024-08-11 09:59:39 +02:00
Niels van Velzen
2a122803c2 Fix InfoRow not using requested media source
(cherry picked from commit b0cd0c5b37)
2024-08-11 09:59:39 +02:00
Niels van Velzen
dbf6dc0f9a Reset display mode when exiting video player
(cherry picked from commit aa5fb63b71)
2024-08-11 09:59:38 +02:00
Niels van Velzen
fd663aff39 Improve InfoRow colors/font readability
(cherry picked from commit d661583a86)
2024-08-11 09:59:38 +02:00
Niels van Velzen
30e34568c1 Fix intros showing next up screen
(cherry picked from commit fe31c19f15)
2024-08-11 09:59:37 +02:00
Niels van Velzen
5fb77314c4 Fix crash when starting next episode
(cherry picked from commit fcd9751d82)
2024-08-11 09:59:37 +02:00
Niels van Velzen
d436c5de3d Graduate reactive homepage to stable
(cherry picked from commit f8c5345871)
2024-08-11 09:59:36 +02:00
renovate[bot]
8157e3e5c1 Update dependency androidx.tv:tv-material to v1.0.0-rc02
(cherry picked from commit 0385f63fbe)
2024-08-11 09:59:20 +02:00
renovate[bot]
3656c18e8f Update dependency androidx.work:work-runtime to v2.9.1
(cherry picked from commit 6cc1031b8a)
2024-08-11 09:59:19 +02:00
renovate[bot]
9f48b7da39 Update dependency org.jellyfin.sdk:jellyfin-core to v1.5.3
(cherry picked from commit 587f0fb35a)
2024-08-11 09:59:19 +02:00
Niels van Velzen
ef899f98bc Fix SelectQualityAction crashing when opening menu
(cherry picked from commit 044ce67110)
2024-08-06 15:20:34 +02:00
Niels van Velzen
de6c7904f0 Use "direct play" as default audio behavior
(cherry picked from commit f6c536880d)
2024-08-06 15:20:34 +02:00
Marc Vollmer
0a79d5b60f Fix NullPointerException in LeanbackOverlayFragment.onPause
(cherry picked from commit 3b852a031e)
2024-08-06 15:20:33 +02:00
Niels van Velzen
36e0bc1edc Fix NullPointerException in CustomPlaybackOverlayFragment.onResume
(cherry picked from commit 7bc99d2e66)
2024-08-06 15:20:33 +02:00
Niels van Velzen
8faeb3aac1 Remove context requirement from ExoPlayerProfile
(cherry picked from commit 9ecf952351)
2024-08-06 15:20:11 +02:00
majmongoose
160ef4914f Add Google Chromecast HD limitation (#3820)
* Add identification for Google Chromecast HD

Possibly an upstream bug is preventing this from working, as the chromecast is being idenitified correctly in debugging.

* Update DeviceUtils.kt

Added a note for the reason of the change.

* Removed unintentional imports

* Update app/src/main/java/org/jellyfin/androidtv/util/DeviceUtils.kt

Make changes suggested by maintainer.

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>

* Update DeviceUtils.kt

Removed unneeded isChromecastHD

---------

Co-authored-by: Niels van Velzen <nielsvanvelzen@users.noreply.github.com>
(cherry picked from commit a8c9f054ab)
2024-08-06 15:20:10 +02:00
Niels van Velzen
474c0774f1 Fix AudioNowPlayingFragment showing wrong item in extremely rare cases
(cherry picked from commit 61b4178dec)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c9ba1ccec Fix race condition in PictureViewerViewModel
Using the showNext/showPrevious functions while the album is still loading can cause an IndexOutOfBoundsException. Check if the album is empty (default value) first before trying to read from it to avoid these crashes.

(cherry picked from commit 9aa1759ae7)
2024-08-06 15:20:10 +02:00
Niels van Velzen
0c667ab615 Strip HTML from descriptions in leanback integration
(cherry picked from commit 2e55987939)
2024-08-06 15:20:09 +02:00
40 changed files with 641 additions and 315 deletions

View File

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

View File

@@ -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" />

View File

@@ -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, 9, 11)
}
}

View File

@@ -28,6 +28,7 @@ import org.jellyfin.androidtv.ui.startup.StartupActivity
import org.jellyfin.androidtv.util.ImageHelper
import org.jellyfin.androidtv.util.dp
import org.jellyfin.androidtv.util.sdk.isUsable
import org.jellyfin.androidtv.util.stripHtml
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.exception.TimeoutException
@@ -363,7 +364,7 @@ class LeanbackChannelWorker(
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
.setDescription(item.overview)
.setDescription(item.overview?.stripHtml())
.setReleaseDate(
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
else null

View File

@@ -96,7 +96,7 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
/**
* Preferred behavior for audio streaming.
*/
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DOWNMIX_TO_STEREO)
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
/**
* Preferred behavior for audio streaming.
@@ -199,11 +199,6 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Whether items shown in the screensaver are required to have an age rating set.
*/
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
/**
* Enable reactive homepage
*/
var homeReactive = booleanPreference("home_reactive", false)
}
init {

View File

@@ -0,0 +1,191 @@
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.toTypedArray()
)
}
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)
if (parent != null) super.onRestoreInstanceState(parent)
// Restore history
@Suppress("UNCHECKED_CAST")
val savedHistory = BundleCompat.getParcelableArray(state, BUNDLE_HISTORY, HistoryEntry::class.java) as Array<HistoryEntry>?
if (savedHistory != null) {
history.clear()
history.addAll(savedHistory)
if (history.isNotEmpty()) activateHistoryEntry(history.last(), fragmentManager.beginTransaction())
}
}
}

View File

@@ -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

View File

@@ -26,6 +26,8 @@ import org.jellyfin.androidtv.util.sdk.getSeasonEpisodeName
import org.jellyfin.androidtv.util.sdk.isNew
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SeriesStatus
import org.jellyfin.sdk.model.api.VideoRangeType
@@ -135,13 +137,23 @@ fun InfoRowSeasonEpisode(item: BaseItemDto) {
}
}
private fun List<MediaStream>.getDefault(type: MediaStreamType, defaultIndex: Int? = null): MediaStream? {
if (defaultIndex != null) {
val byIndex = getOrNull(defaultIndex)
if (byIndex?.type == type) return byIndex
}
return firstOrNull { it.type == type }
}
@Composable
fun InfoRowMediaDetails(item: BaseItemDto) {
val videoStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }
val audioStream = item.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO }
fun InfoRowMediaDetails(mediaSource: MediaSourceInfo) {
val videoStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.VIDEO)
val audioStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.AUDIO, mediaSource.defaultAudioStreamIndex)
val subtitleStream = mediaSource.mediaStreams?.getDefault(MediaStreamType.SUBTITLE, mediaSource.defaultSubtitleStreamIndex)
// Subtitles
if (item.hasSubtitles == true) {
if (subtitleStream != null) {
InfoRowItem(
contentDescription = null,
colors = InfoRowColors.Default,
@@ -219,6 +231,7 @@ fun InfoRowMediaDetails(item: BaseItemDto) {
@Composable
fun BaseItemInfoRow(
item: BaseItemDto,
mediaSource: MediaSourceInfo?,
includeRuntime: Boolean,
) {
val userPreferences = koinInject<UserPreferences>()
@@ -239,7 +252,7 @@ fun BaseItemInfoRow(
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.BOX_SET -> {
@@ -257,7 +270,7 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.SERIES -> {
@@ -265,7 +278,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
InfoRowSeriesStatus(item)
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.PROGRAM -> {
@@ -303,7 +316,7 @@ fun BaseItemInfoRow(
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
BaseItemKind.MUSIC_ARTIST -> {
@@ -343,14 +356,14 @@ fun BaseItemInfoRow(
val runtime = item.cumulativeRunTimeTicks ?: item.runTimeTicks
if (includeRuntime) runtime?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
else -> {
InfoRowDate(item)
if (includeRuntime) item.runTimeTicks?.ticks?.let { BaseItemInfoRowRuntime(it) }
item.officialRating?.let { InfoRowParentalRating(it) }
InfoRowMediaDetails(item)
mediaSource?.let { InfoRowMediaDetails(it) }
}
}
}
@@ -364,6 +377,7 @@ class BaseItemInfoRowView @JvmOverloads constructor(
attrs: AttributeSet? = null,
) : AbstractComposeView(context, attrs) {
private val _item = MutableStateFlow<BaseItemDto?>(null)
private val _mediaSource = MutableStateFlow<MediaSourceInfo?>(null)
private val _includeRuntime = MutableStateFlow(false)
var item: BaseItemDto?
@@ -372,6 +386,12 @@ class BaseItemInfoRowView @JvmOverloads constructor(
_item.value = value
}
var mediaSource: MediaSourceInfo?
get() = _mediaSource.value
set(value) {
_mediaSource.value = value
}
var includeRuntime: Boolean
get() = _includeRuntime.value
set(value) {
@@ -386,8 +406,9 @@ class BaseItemInfoRowView @JvmOverloads constructor(
@Composable
override fun Content() {
val item by _item.collectAsState()
val mediaSource by _mediaSource.collectAsState()
val includeRuntime by _includeRuntime.collectAsState()
item?.let { BaseItemInfoRow(it, includeRuntime) }
item?.let { BaseItemInfoRow(it, mediaSource, includeRuntime) }
}
}

View File

@@ -7,7 +7,7 @@ import androidx.compose.ui.graphics.Color
*/
object InfoRowColors {
val Transparent = Color.Transparent to Color.White
val Default = Color(0x80FFFFFF) to Color.Black
val Green = Color(0x8034D97C) to Color.Black
val Red = Color(0x80F2364D) to Color.Black
val Default = Color(0xB3FFFFFF) to Color.Black
val Green = Color(0xB3089562) to Color.White
val Red = Color(0xB3F2364D) to Color.White
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.ProvideTextStyle
@@ -46,6 +47,7 @@ fun InfoRowItem(
value = TextStyle(
color = foregroundColor,
fontSize = if (backgroundColor.alpha > 0f) 12.sp else 16.sp,
fontWeight = if (backgroundColor.alpha > 0f) FontWeight.W600 else FontWeight.W500,
)
) {
Row(

View File

@@ -166,15 +166,13 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (userPreferences[UserPreferences.homeReactive]) {
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<UserDataChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
api.webSocket.subscribe<LibraryChangedMessage>()
.onEach { refreshRows(force = true, delayed = false) }
.launchIn(this)
}
}

View File

@@ -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) {

View File

@@ -1,6 +1,7 @@
package org.jellyfin.androidtv.ui.itemdetail
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
import org.jellyfin.sdk.api.client.ApiClient
@@ -29,7 +30,7 @@ fun ItemListFragment.loadItem(itemId: UUID) {
lifecycleScope.launch {
val item by api.userLibraryApi.getItem(itemId)
setBaseItem(item)
if (isActive) setBaseItem(item)
}
}
@@ -88,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))
}

View File

@@ -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

View File

@@ -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)")
}
}

View File

@@ -53,6 +53,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
// Album actions
fun showNext() {
if (album.isEmpty()) return
albumIndex++
if (albumIndex == album.size) albumIndex = 0
@@ -61,6 +63,8 @@ class PictureViewerViewModel(private val api: ApiClient) : ViewModel() {
}
fun showPrevious() {
if (album.isEmpty()) return
albumIndex--
if (albumIndex == -1) albumIndex = album.size - 1

View File

@@ -144,7 +144,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().toggleRepeat();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mRepeatButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -155,7 +155,7 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onClick(View v) {
mediaManager.getValue().shuffleAudioQueue();
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
});
mShuffleButton.setOnFocusChangeListener(mainAreaFocusListener);
@@ -175,7 +175,7 @@ public class AudioNowPlayingFragment extends Fragment {
mArtistButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0) {
if (mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty()) {
navigationRepository.getValue().navigate(Destinations.INSTANCE.itemDetails(mBaseItem.getAlbumArtists().get(0).getId()));
}
}
@@ -217,10 +217,10 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onResume() {
super.onResume();
loadItem();
//link events
mediaManager.getValue().addAudioEventListener(audioEventListener);
updateButtons(mediaManager.getValue().isPlayingAudio());
loadItem();
updateButtons();
// load the item duration and set the position to 0 since it won't be set elsewhere until playback is initialized
if (!mediaManager.getValue().isAudioPlayerInitialized())
@@ -238,13 +238,8 @@ public class AudioNowPlayingFragment extends Fragment {
@Override
public void onPlaybackStateChange(@NonNull PlaybackController.PlaybackState newState, @Nullable org.jellyfin.sdk.model.api.BaseItemDto currentItem) {
Timber.d("**** Got playstate change: %s", newState.toString());
if (newState == PlaybackController.PlaybackState.PLAYING && currentItem != mBaseItem) {
// new item started
loadItem();
updateButtons(true);
} else {
updateButtons(newState == PlaybackController.PlaybackState.PLAYING);
}
if (currentItem != mBaseItem) loadItem();
updateButtons();
}
@Override
@@ -261,7 +256,7 @@ public class AudioNowPlayingFragment extends Fragment {
if (hasQueue) {
loadItem();
if (mediaManager.getValue().isAudioPlayerInitialized()) {
updateButtons(mediaManager.getValue().isPlayingAudio());
updateButtons();
}
} else {
if (navigationRepository.getValue().getCanGoBack()) navigationRepository.getValue().goBack();
@@ -313,8 +308,9 @@ public class AudioNowPlayingFragment extends Fragment {
}
}
private void updateButtons(final boolean playing) {
private void updateButtons() {
Timber.d("Updating buttons");
boolean playing = mediaManager.getValue().isPlayingAudio();
requireActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
@@ -334,14 +330,14 @@ public class AudioNowPlayingFragment extends Fragment {
mShuffleButton.setActivated(mediaManager.getValue().isShuffleMode());
if (mBaseItem != null) {
mAlbumButton.setEnabled(mBaseItem.getAlbumId() != null);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && mBaseItem.getAlbumArtists().size() > 0);
mArtistButton.setEnabled(mBaseItem.getAlbumArtists() != null && !mBaseItem.getAlbumArtists().isEmpty());
}
}
});
}
private String getArtistName(org.jellyfin.sdk.model.api.BaseItemDto item) {
String artistName = item.getArtists() != null && item.getArtists().size() > 0 ? item.getArtists().get(0) : item.getAlbumArtist();
String artistName = item.getArtists() != null && !item.getArtists().isEmpty() ? item.getArtists().get(0) : item.getAlbumArtist();
return artistName != null ? artistName : "";
}
@@ -365,7 +361,7 @@ public class AudioNowPlayingFragment extends Fragment {
public void setCurrentTime(long time) {
// Round the current time as otherwise the time played and time remaining will not be in sync
time = Math.round(time / 1000) * 1000;
time = Math.round(time / 1000L) * 1000L;
mCurrentProgress.setProgress(((Long) time).intValue());
mCurrentPos.setText(TimeUtils.formatMillis(time));
mRemainingTime.setText(mCurrentDuration > 0 ? "-" + TimeUtils.formatMillis(mCurrentDuration - time) : "");

View File

@@ -7,6 +7,7 @@ import android.content.Context;
import android.graphics.Color;
import android.media.AudioManager;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.Spannable;
@@ -16,6 +17,7 @@ import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.LinearLayout;
@@ -331,8 +333,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
return null;
});
requireActivity().getOnBackPressedDispatcher().addCallback(backPressedCallback);
int startPos = getArguments().getInt("Position", 0);
// start playing
@@ -341,6 +341,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) {
@@ -418,7 +425,6 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
};
private OnBackPressedCallback backPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (mPopupPanelVisible) {
@@ -575,7 +581,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// up or down should close panel
if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN || keyCode == KeyEvent.KEYCODE_DPAD_UP) {
hidePopupPanel();
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv()) hide(); //also close this if live tv
if (playbackControllerContainer.getValue().getPlaybackController().isLiveTv())
hide(); //also close this if live tv
return true;
} else {
return false;
@@ -674,7 +681,8 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
super.onResume();
// Close player when resuming without a valid playback contoller
if (!playbackControllerContainer.getValue().getPlaybackController().hasFragment()) {
PlaybackController playbackController = playbackControllerContainer.getValue().getPlaybackController();
if (playbackController == null || !playbackController.hasFragment()) {
if (navigationRepository.getValue().getCanGoBack()) {
navigationRepository.getValue().goBack();
} else {
@@ -712,10 +720,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
@@ -723,6 +727,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() {
@@ -1451,5 +1457,12 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
// Show system bars
WindowCompat.setDecorFitsSystemWindows(requireActivity().getWindow(), true);
WindowCompat.getInsetsController(requireActivity().getWindow(), requireActivity().getWindow().getDecorView()).show(WindowInsetsCompat.Type.systemBars());
// Reset display mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.preferredDisplayModeId = 0;
getActivity().getWindow().setAttributes(params);
}
}
}

View File

@@ -20,9 +20,11 @@ import org.jellyfin.androidtv.data.compat.VideoOptions;
import org.jellyfin.androidtv.data.model.DataRefreshService;
import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.preference.UserSettingPreferences;
import org.jellyfin.androidtv.preference.constant.AudioBehavior;
import org.jellyfin.androidtv.preference.constant.NextUpBehavior;
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior;
import org.jellyfin.androidtv.ui.livetv.TvManager;
import org.jellyfin.androidtv.util.DeviceUtils;
import org.jellyfin.androidtv.util.TimeUtils;
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.ReportingHelper;
@@ -101,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;
@@ -247,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);
@@ -516,18 +515,18 @@ public class PlaybackController implements PlaybackControllerNotifiable {
internalOptions.setItemId(item.getId());
internalOptions.setMediaSources(item.getMediaSources());
internalOptions.setMaxBitrate(maxBitrate);
if (exoErrorEncountered || (isLiveTv && !directStreamLiveTv))
internalOptions.setEnableDirectStream(false);
internalOptions.setMaxAudioChannels(Utils.downMixAudio(mFragment.getContext()) ? 2 : null); //have to downmix at server
if (playbackRetries > 0 || (isLiveTv && !directStreamLiveTv)) internalOptions.setEnableDirectStream(false);
if (playbackRetries > 1) internalOptions.setEnableDirectPlay(false);
internalOptions.setSubtitleStreamIndex(forcedSubtitleIndex);
MediaSourceInfo currentMediaSource = getCurrentMediaSource();
if (!isLiveTv && currentMediaSource != null) {
internalOptions.setMediaSourceId(currentMediaSource.getId());
}
DeviceProfile internalProfile = new ExoPlayerProfile(
mFragment.getContext(),
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled())
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO,
!DeviceUtils.has4kVideoSupport()
);
internalOptions.setProfile(internalProfile);
return internalOptions;
@@ -926,7 +925,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private void resetPlayerErrors() {
exoErrorEncountered = false;
playbackRetries = 0;
}
private void clearPlaybackSessionOptions() {

View File

@@ -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;
}

View File

@@ -88,7 +88,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
@Override
public void onPause() {
super.onPause();
playerAdapter.getMasterOverlayFragment().onPause();
if (playerAdapter != null) {
playerAdapter.getMasterOverlayFragment().onPause();
}
}
public CustomPlaybackTransportControlGlue getPlayerGlue() {

View File

@@ -10,7 +10,6 @@ import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.VideoQualityController
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
class SelectQualityAction(
@@ -39,8 +38,11 @@ class SelectQualityAction(
}
menu.setGroupCheckable(0, true, true)
menu.getItem(qualityProfiles.keys.indexOf(qualityController.currentQuality))?.let { item ->
item.isChecked = true
val currentQualityIndex = qualityProfiles.keys.indexOf(qualityController.currentQuality)
if (currentQualityIndex != -1) {
menu.getItem(currentQualityIndex)?.let { item ->
item.isChecked = true
}
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }

View File

@@ -39,12 +39,6 @@ class DeveloperPreferencesScreen : OptionsFragment() {
}
}
checkbox {
setTitle(R.string.enable_reactive_homepage)
setContent(R.string.enable_playback_module_description)
bind(userPreferences, UserPreferences.homeReactive)
}
// Only show in debug mode
// some strings are hardcoded because these options don't show in beta/release builds
if (BuildConfig.DEVELOPMENT) {

View File

@@ -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);

View File

@@ -21,7 +21,7 @@ class MyDetailsOverviewRowPresenter(
fun setItem(row: MyDetailsOverviewRow) {
setTitle(row.item.name)
InfoLayoutHelper.addInfoRow(view.context, row.item, binding.fdMainInfoRow, false)
InfoLayoutHelper.addInfoRow(view.context, row.item, row.item.mediaSources?.getOrNull(row.selectedMediaSourceIndex), binding.fdMainInfoRow, false)
binding.fdGenreRow.text = row.item.genres?.joinToString(" / ")
binding.infoTitle1.text = row.infoItem1?.label

View File

@@ -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

View File

@@ -26,6 +26,14 @@ object DeviceUtils {
// Nvidia Shield TV Model
private const val SHIELD_TV_MODEL = "SHIELD Android TV"
// 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)
@@ -53,6 +61,10 @@ object DeviceUtils {
FIRE_STICK_MODEL_GEN_3,
FIRE_STICK_LITE_MODEL,
FIRE_TV_MODEL_GEN_1,
FIRE_TV_MODEL_GEN_2
FIRE_TV_MODEL_GEN_2,
GOOGLE_CHROMECAST_HD_MODEL,
MI_TV_STICK_MODEL_1,
MI_TV_STICK_MODEL_2,
MI_TV_STICK_MODEL_3
)
}

View File

@@ -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? {

View File

@@ -1,33 +0,0 @@
package org.jellyfin.androidtv.util;
import android.content.Context;
import android.view.View;
import android.widget.LinearLayout;
import org.jellyfin.androidtv.ui.browsing.composable.inforow.BaseItemInfoRowView;
import org.jellyfin.sdk.model.api.BaseItemDto;
public class InfoLayoutHelper {
public static void addInfoRow(Context context, BaseItemDto item, LinearLayout layout, boolean includeRuntime) {
// Find existing BaseItemInfoRowView or create a new one
BaseItemInfoRowView baseItemInfoRowView = null;
for (int i = 0; i < layout.getChildCount(); i++) {
View child = layout.getChildAt(i);
if (child instanceof BaseItemInfoRowView) {
baseItemInfoRowView = (BaseItemInfoRowView) child;
break;
}
}
if (baseItemInfoRowView == null) {
baseItemInfoRowView = new BaseItemInfoRowView(context);
layout.addView(baseItemInfoRowView);
}
// Update item info
baseItemInfoRowView.setItem(item);
baseItemInfoRowView.setIncludeRuntime(includeRuntime);
}
}

View File

@@ -0,0 +1,48 @@
package org.jellyfin.androidtv.util
import android.content.Context
import android.widget.LinearLayout
import org.jellyfin.androidtv.ui.browsing.composable.inforow.BaseItemInfoRowView
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
object InfoLayoutHelper {
@JvmStatic
fun addInfoRow(
context: Context,
item: BaseItemDto?,
mediaSource: MediaSourceInfo?,
layout: LinearLayout,
includeRuntime: Boolean
) {
// Find existing BaseItemInfoRowView or create a new one
var baseItemInfoRowView: BaseItemInfoRowView? = null
for (i in 0 until layout.childCount) {
val child = layout.getChildAt(i)
if (child is BaseItemInfoRowView) {
baseItemInfoRowView = child
break
}
}
if (baseItemInfoRowView == null) {
baseItemInfoRowView = BaseItemInfoRowView(context)
layout.addView(baseItemInfoRowView)
}
// Update item info
baseItemInfoRowView.item = item
baseItemInfoRowView.mediaSource = mediaSource ?: item?.mediaSources?.firstOrNull()
baseItemInfoRowView.includeRuntime = includeRuntime
}
@JvmStatic
fun addInfoRow(
context: Context,
item: BaseItemDto?,
layout: LinearLayout,
includeRuntime: Boolean
) = addInfoRow(context, item, null, layout, includeRuntime)
}

View File

@@ -10,6 +10,11 @@ import org.jellyfin.androidtv.R
*/
fun String.toHtmlSpanned(): Spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
/**
* Remove HTML tags from string and return the plain representation.
*/
fun String.stripHtml(): String = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT).toString()
/**
* Utility to get the string for the "Load channels" button in the Live TV guide.
*/

View File

@@ -1,16 +1,11 @@
package org.jellyfin.androidtv.util
import android.content.Context
import android.media.AudioManager
import android.widget.Toast
import org.jellyfin.androidtv.preference.UserPreferences
import org.jellyfin.androidtv.preference.UserPreferences.Companion.audioBehaviour
import org.jellyfin.androidtv.preference.constant.AudioBehavior
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import timber.log.Timber
import java.util.UUID
import kotlin.math.roundToInt
@@ -98,17 +93,6 @@ object Utils : KoinComponent {
return themeColor
}
@JvmStatic
fun downMixAudio(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (am.isBluetoothA2dpOn) {
Timber.i("Downmixing audio due to wired headset")
return true
}
return get<UserPreferences>()[audioBehaviour] === AudioBehavior.DOWNMIX_TO_STEREO
}
@JvmStatic
fun getSafeSeekPosition(position: Long, duration: Long): Long = when {
position >= duration -> (duration - 1000).coerceAtLeast(0)

View File

@@ -1,19 +1,17 @@
package org.jellyfin.androidtv.util.profile
import android.content.Context
import org.jellyfin.androidtv.constant.Codec
import org.jellyfin.androidtv.util.DeviceUtils
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.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
import org.jellyfin.androidtv.util.profile.ProfileHelper.subtitleProfile
import org.jellyfin.androidtv.util.profile.ProfileHelper.supportsHevc
import org.jellyfin.apiclient.model.dlna.CodecProfile
import org.jellyfin.apiclient.model.dlna.CodecType
import org.jellyfin.apiclient.model.dlna.DeviceProfile
@@ -27,9 +25,10 @@ import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
import org.jellyfin.apiclient.model.dlna.TranscodingProfile
class ExoPlayerProfile(
context: Context,
disableVideoDirectPlay: Boolean = false,
isAC3Enabled: Boolean = false,
disableVideoDirectPlay: Boolean,
isAC3Enabled: Boolean,
downMixAudio: Boolean,
disable4KVideo: Boolean
) : DeviceProfile() {
private val downmixSupportedAudioCodecs = arrayOf(
Codec.Audio.AAC,
@@ -78,12 +77,12 @@ class ExoPlayerProfile(
this.context = EncodingContext.Streaming
container = Codec.Container.TS
videoCodec = buildList {
if (deviceHevcCodecProfile.ContainsCodec(Codec.Video.HEVC, Codec.Container.TS)) add(Codec.Video.HEVC)
if (supportsHevc) add(Codec.Video.HEVC)
add(Codec.Video.H264)
}.joinToString(",")
audioCodec = when {
Utils.downMixAudio(context) -> downmixSupportedAudioCodecs
else -> allSupportedAudioCodecsWithoutFFmpegExperimental
audioCodec = when (downMixAudio) {
true -> downmixSupportedAudioCodecs
false -> allSupportedAudioCodecsWithoutFFmpegExperimental
}.joinToString(",")
protocol = "hls"
copyTimestamps = false
@@ -129,37 +128,34 @@ class ExoPlayerProfile(
Codec.Video.AV1
).joinToString(",")
audioCodec = when {
Utils.downMixAudio(context) -> downmixSupportedAudioCodecs
else -> allSupportedAudioCodecs
audioCodec = when (downMixAudio) {
true -> downmixSupportedAudioCodecs
false -> allSupportedAudioCodecs
}.joinToString(",")
})
}
// Audio direct play
add(audioDirectPlayProfile(allSupportedAudioCodecs + arrayOf(
Codec.Audio.MPA,
Codec.Audio.WAV,
Codec.Audio.WMA,
Codec.Audio.OGG,
Codec.Audio.OGA,
Codec.Audio.WEBMA,
Codec.Audio.APE,
)))
add(
audioDirectPlayProfile(
allSupportedAudioCodecs + arrayOf(
Codec.Audio.MPA,
Codec.Audio.WAV,
Codec.Audio.WMA,
Codec.Audio.OGG,
Codec.Audio.OGA,
Codec.Audio.WEBMA,
Codec.Audio.APE,
)
)
)
// Photo direct play
add(photoDirectPlayProfile)
}.toTypedArray()
codecProfiles = buildList {
// H264 profile
add(CodecProfile().apply {
type = CodecType.Video
codec = Codec.Video.H264
conditions = buildList {
add(h264VideoProfileCondition)
add(h264VideoLevelProfileCondition)
if (!DeviceUtils.has4kVideoSupport()) addAll(max1080pProfileConditions)
}.toTypedArray()
})
add(deviceAVCCodecProfile)
addAll(deviceAVCLevelCodecProfiles)
// H264 ref frames profile
add(CodecProfile().apply {
type = CodecType.Video
@@ -204,15 +200,14 @@ class ExoPlayerProfile(
// AV1 profile
add(deviceAV1CodecProfile)
// Limit video resolution support for older devices
if (!DeviceUtils.has4kVideoSupport()) {
if (disable4KVideo) {
add(CodecProfile().apply {
type = CodecType.Video
conditions = max1080pProfileConditions
})
}
// Audio channel profile
if (!Utils.downMixAudio(context)) add(maxAudioChannelsCodecProfile(channels = 8))
else add(maxAudioChannelsCodecProfile(channels = 2))
add(maxAudioChannelsCodecProfile(channels = if (downMixAudio) 2 else 8))
}.toTypedArray()
subtitleProfiles = arrayOf(

View File

@@ -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)

View File

@@ -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,13 +57,122 @@ 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
codec = Codec.Video.HEVC
conditions = when {
!MediaTest.supportsHevc() -> {
!supportsHevc -> {
// The following condition is a method to exclude all HEVC
Timber.i("*** Does NOT support HEVC")
arrayOf(
@@ -80,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("|")
)
)
}
@@ -107,7 +203,7 @@ object ProfileHelper {
val deviceHevcLevelCodecProfiles by lazy {
buildList {
if (MediaTest.supportsHevc()) {
if (supportsHevc) {
add(CodecProfile().apply {
type = CodecType.Video
codec = Codec.Video.HEVC
@@ -116,7 +212,7 @@ object ProfileHelper {
ProfileCondition(
ProfileConditionType.Equals,
ProfileConditionValue.VideoProfile,
"Main"
"main"
)
)
@@ -129,7 +225,7 @@ object ProfileHelper {
)
})
if (MediaTest.supportsHevcMain10()) {
if (supportsHevcMain10) {
add(CodecProfile().apply {
type = CodecType.Video
codec = Codec.Video.HEVC
@@ -138,7 +234,7 @@ object ProfileHelper {
ProfileCondition(
ProfileConditionType.Equals,
ProfileConditionValue.VideoProfile,
"Main 10"
"main 10"
)
)
@@ -155,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(

View File

@@ -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 ->
@@ -217,9 +219,10 @@ class SdkPlaybackHelper(
val addIntros = allowIntros && userPreferences[UserPreferences.cinemaModeEnabled]
if (addIntros) {
val intros = runCatching {
api.userLibraryApi.getIntros(mainItem.id).content.items
}.getOrNull().orEmpty()
val intros = runCatching { api.userLibraryApi.getIntros(mainItem.id).content.items }.getOrNull()
.orEmpty()
// Force the type to be trailer as the legacy playback UI uses it to determine if it should show the next up screen
.map { it.copy(type = BaseItemKind.TRAILER) }
intros + parts
} else {

View File

@@ -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"

View 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>

View File

@@ -16,23 +16,23 @@ 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-rc01"
androidx-tv = "1.0.0"
androidx-tvprovider = "1.1.0-alpha01"
androidx-window = "1.3.0"
androidx-work = "2.9.0"
androidx-work = "2.9.1"
coil = "2.7.0"
detekt = "1.23.6"
jellyfin-androidx-media = "1.3.1+2"
jellyfin-apiclient = "v0.7.10"
jellyfin-sdk = "1.5.2"
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"

View File

@@ -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)