Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c43f27bf7 | ||
|
|
3f8c4bf662 | ||
|
|
c3890f7ac0 | ||
|
|
ab165301d8 | ||
|
|
16dfd24849 | ||
|
|
9a189c57b7 | ||
|
|
d7d774ffbf | ||
|
|
271e0cf320 | ||
|
|
8b4275dea2 | ||
|
|
5b09d3b113 | ||
|
|
62f98901b0 | ||
|
|
19381684ae | ||
|
|
176a7e3391 | ||
|
|
2dbdbe8c28 | ||
|
|
1502dc805b | ||
|
|
29fc35f215 | ||
|
|
af0255440c | ||
|
|
389c48a1fd | ||
|
|
7d84011d81 | ||
|
|
36a307cd7f | ||
|
|
2a122803c2 | ||
|
|
dbf6dc0f9a | ||
|
|
fd663aff39 | ||
|
|
30e34568c1 | ||
|
|
5fb77314c4 | ||
|
|
d436c5de3d | ||
|
|
8157e3e5c1 | ||
|
|
3656c18e8f | ||
|
|
9f48b7da39 | ||
|
|
ef899f98bc | ||
|
|
de6c7904f0 | ||
|
|
0a79d5b60f | ||
|
|
36e0bc1edc | ||
|
|
8faeb3aac1 | ||
|
|
160ef4914f | ||
|
|
474c0774f1 | ||
|
|
0c9ba1ccec | ||
|
|
0c667ab615 |
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,13 +58,7 @@ 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)
|
||||
@@ -124,37 +110,13 @@ 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
|
||||
}
|
||||
|
||||
supportFragmentManager.commit {
|
||||
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)
|
||||
}
|
||||
}
|
||||
private fun handleNavigationAction(action: NavigationAction) {
|
||||
when (action) {
|
||||
// DestinationFragmentView actions
|
||||
is NavigationAction.NavigateFragment -> binding.contentView.navigate(action)
|
||||
NavigationAction.GoBack -> binding.contentView.goBack()
|
||||
|
||||
// Others
|
||||
is NavigationAction.NavigateActivity -> {
|
||||
val destination = action.destination
|
||||
val intent = Intent(this@MainActivity, destination.activity.java)
|
||||
@@ -163,10 +125,9 @@ class MainActivity : FragmentActivity() {
|
||||
action.onOpened()
|
||||
}
|
||||
|
||||
NavigationAction.GoBack -> supportFragmentManager.popBackStack()
|
||||
|
||||
NavigationAction.Nothing -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Forward key events to fragments
|
||||
private fun Fragment.onKeyEvent(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -166,7 +166,6 @@ 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)
|
||||
@@ -176,7 +175,6 @@ class HomeRowsFragment : RowsSupportFragment(), AudioEventListener, View.OnKeyLi
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to Audio messages
|
||||
mediaManager.addAudioEventListener(this)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) : "");
|
||||
|
||||
@@ -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;
|
||||
@@ -575,7 +577,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 +677,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 {
|
||||
@@ -1451,5 +1455,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -88,8 +88,10 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
if (playerAdapter != null) {
|
||||
playerAdapter.getMasterOverlayFragment().onPause();
|
||||
}
|
||||
}
|
||||
|
||||
public CustomPlaybackTransportControlGlue getPlayerGlue() {
|
||||
return playerGlue;
|
||||
|
||||
@@ -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,9 +38,12 @@ class SelectQualityAction(
|
||||
}
|
||||
|
||||
menu.setGroupCheckable(0, true, true)
|
||||
menu.getItem(qualityProfiles.keys.indexOf(qualityController.currentQuality))?.let { item ->
|
||||
val currentQualityIndex = qualityProfiles.keys.indexOf(qualityController.currentQuality)
|
||||
if (currentQualityIndex != -1) {
|
||||
menu.getItem(currentQualityIndex)?.let { item ->
|
||||
item.isChecked = true
|
||||
}
|
||||
}
|
||||
|
||||
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
|
||||
setOnMenuItemClickListener { menuItem ->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,14 +128,16 @@ 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(
|
||||
add(
|
||||
audioDirectPlayProfile(
|
||||
allSupportedAudioCodecs + arrayOf(
|
||||
Codec.Audio.MPA,
|
||||
Codec.Audio.WAV,
|
||||
Codec.Audio.WMA,
|
||||
@@ -144,22 +145,17 @@ class ExoPlayerProfile(
|
||||
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(
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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