## Description Adds a settings for automatic resolution switching. When enabled, the app tries to output content at the video stream's resolution. For example, if playing 1080p content on a 4K TV, the actual output by the Android TV device will be 1080p. This works in hand-in-hand with automatic refresh rate switching. So enabling both allows for the output to match both refresh rate and content resolution. Outputting the best refresh rate is preferred over best resolution. Also, shows the current display mode in the playback debug info. ### Related issues Closes #428 Fixes #531
430 lines
20 KiB
Kotlin
430 lines
20 KiB
Kotlin
package com.github.damontecres.wholphin
|
|
|
|
import android.content.Intent
|
|
import android.content.res.Configuration
|
|
import android.os.Bundle
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.viewModels
|
|
import androidx.appcompat.app.AppCompatActivity
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.runtime.CompositionLocalProvider
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.RectangleShape
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.datastore.core.DataStore
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.lifecycle.viewModelScope
|
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
|
import androidx.navigation3.runtime.NavEntry
|
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|
import androidx.navigation3.ui.NavDisplay
|
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
|
import androidx.tv.material3.MaterialTheme
|
|
import androidx.tv.material3.Surface
|
|
import com.github.damontecres.wholphin.data.ServerRepository
|
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
|
import com.github.damontecres.wholphin.services.BackdropService
|
|
import com.github.damontecres.wholphin.services.DeviceProfileService
|
|
import com.github.damontecres.wholphin.services.ImageUrlService
|
|
import com.github.damontecres.wholphin.services.NavigationManager
|
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
|
import com.github.damontecres.wholphin.services.SetupDestination
|
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
|
import com.github.damontecres.wholphin.services.UpdateChecker
|
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
|
import com.github.damontecres.wholphin.ui.launchIO
|
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
import kotlinx.coroutines.flow.firstOrNull
|
|
import okhttp3.OkHttpClient
|
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
|
import timber.log.Timber
|
|
import javax.inject.Inject
|
|
|
|
@AndroidEntryPoint
|
|
class MainActivity : AppCompatActivity() {
|
|
private val viewModel: MainActivityViewModel by viewModels()
|
|
|
|
@Inject
|
|
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
|
|
|
@AuthOkHttpClient
|
|
@Inject
|
|
lateinit var okHttpClient: OkHttpClient
|
|
|
|
@Inject
|
|
lateinit var navigationManager: NavigationManager
|
|
|
|
@Inject
|
|
lateinit var setupNavigationManager: SetupNavigationManager
|
|
|
|
@Inject
|
|
lateinit var updateChecker: UpdateChecker
|
|
|
|
@Inject
|
|
lateinit var appUpgradeHandler: AppUpgradeHandler
|
|
|
|
@Inject
|
|
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
|
|
|
|
@Inject
|
|
lateinit var imageUrlService: ImageUrlService
|
|
|
|
@Inject
|
|
lateinit var refreshRateService: RefreshRateService
|
|
|
|
@Inject
|
|
lateinit var tvProviderSchedulerService: TvProviderSchedulerService
|
|
|
|
private var signInAuto = true
|
|
|
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
|
lifecycle.addObserver(playbackLifecycleObserver)
|
|
if (savedInstanceState == null) {
|
|
appUpgradeHandler.copySubfont(false)
|
|
}
|
|
refreshRateService.refreshRateMode.observe(this) { modeId ->
|
|
// Listen for refresh rate changes
|
|
val attrs = window.attributes
|
|
if (attrs.preferredDisplayModeId != modeId) {
|
|
Timber.d("Switch preferredDisplayModeId to %s", modeId)
|
|
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
|
|
}
|
|
}
|
|
viewModel.appStart()
|
|
val requestedDestination = this.intent?.let(::extractDestination)
|
|
setContent {
|
|
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
|
appPreferences?.let { appPreferences ->
|
|
LaunchedEffect(appPreferences.signInAutomatically) {
|
|
signInAuto = appPreferences.signInAutomatically
|
|
}
|
|
CoilConfig(
|
|
diskCacheSizeBytes =
|
|
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
|
|
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
|
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
|
} else {
|
|
it
|
|
}
|
|
},
|
|
okHttpClient = okHttpClient,
|
|
debugLogging = false,
|
|
enableCache = true,
|
|
)
|
|
LaunchedEffect(appPreferences.debugLogging) {
|
|
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
|
}
|
|
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
|
WholphinTheme(
|
|
true,
|
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
|
) {
|
|
Surface(
|
|
modifier =
|
|
Modifier
|
|
.fillMaxSize()
|
|
.background(MaterialTheme.colorScheme.background),
|
|
shape = RectangleShape,
|
|
) {
|
|
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
|
// setupNavigationManager.backStack = backStack
|
|
val backStack = setupNavigationManager.backStack
|
|
NavDisplay(
|
|
backStack = backStack,
|
|
onBack = { backStack.removeLastOrNull() },
|
|
entryDecorators =
|
|
listOf(
|
|
rememberSaveableStateHolderNavEntryDecorator(),
|
|
rememberViewModelStoreNavEntryDecorator(),
|
|
),
|
|
entryProvider = { key ->
|
|
key as SetupDestination
|
|
NavEntry(key) {
|
|
when (key) {
|
|
SetupDestination.Loading -> {
|
|
Box(
|
|
modifier = Modifier.size(200.dp),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
CircularProgressIndicator(
|
|
color = MaterialTheme.colorScheme.border,
|
|
modifier = Modifier.align(Alignment.Center),
|
|
)
|
|
}
|
|
}
|
|
|
|
SetupDestination.ServerList -> {
|
|
SwitchServerContent(Modifier.fillMaxSize())
|
|
}
|
|
|
|
is SetupDestination.UserList -> {
|
|
SwitchUserContent(
|
|
currentServer = key.server,
|
|
Modifier.fillMaxSize(),
|
|
)
|
|
}
|
|
|
|
is SetupDestination.AppContent -> {
|
|
val current = key.current
|
|
ProvideLocalClock {
|
|
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
|
LaunchedEffect(Unit) {
|
|
try {
|
|
updateChecker.maybeShowUpdateToast(
|
|
appPreferences.updateUrl,
|
|
)
|
|
} catch (ex: Exception) {
|
|
Timber.w(
|
|
ex,
|
|
"Exception during update check",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
val appPreferences by userPreferencesDataStore.data.collectAsState(
|
|
appPreferences,
|
|
)
|
|
val preferences =
|
|
remember(appPreferences) {
|
|
UserPreferences(appPreferences)
|
|
}
|
|
var showContent by remember {
|
|
mutableStateOf(true)
|
|
}
|
|
if (!preferences.appPreferences.signInAutomatically) {
|
|
LifecycleStartEffect(Unit) {
|
|
onStopOrDispose {
|
|
showContent = false
|
|
}
|
|
}
|
|
}
|
|
if (showContent) {
|
|
ApplicationContent(
|
|
user = current.user,
|
|
server = current.server,
|
|
startDestination =
|
|
requestedDestination
|
|
?: Destination.Home(),
|
|
navigationManager = navigationManager,
|
|
preferences = preferences,
|
|
modifier = Modifier.fillMaxSize(),
|
|
)
|
|
} else {
|
|
Box(
|
|
modifier = Modifier.size(200.dp),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
CircularProgressIndicator(
|
|
color = MaterialTheme.colorScheme.border,
|
|
modifier = Modifier.align(Alignment.Center),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
Timber.d("onResume")
|
|
lifecycleScope.launchIO {
|
|
appUpgradeHandler.run()
|
|
}
|
|
}
|
|
|
|
override fun onRestart() {
|
|
super.onRestart()
|
|
Timber.d("onRestart")
|
|
viewModel.appStart()
|
|
// val signInAutomatically =
|
|
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
|
|
|
|
// // TODO PIN-related
|
|
// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) {
|
|
// if (!signInAutomatically) {
|
|
// serverRepository.closeSession()
|
|
// }
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
Timber.d("onStop")
|
|
tvProviderSchedulerService.launchOneTimeRefresh()
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
Timber.d("onPause")
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
Timber.d("onStart")
|
|
}
|
|
|
|
override fun onSaveInstanceState(outState: Bundle) {
|
|
super.onSaveInstanceState(outState)
|
|
Timber.d("onSaveInstanceState")
|
|
}
|
|
|
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
super.onRestoreInstanceState(savedInstanceState)
|
|
Timber.d("onRestoreInstanceState")
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
Timber.d("onDestroy")
|
|
}
|
|
|
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
super.onConfigurationChanged(newConfig)
|
|
Timber.d("onConfigurationChanged")
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent) {
|
|
super.onNewIntent(intent)
|
|
Timber.v("onNewIntent")
|
|
extractDestination(intent)?.let {
|
|
navigationManager.replace(it)
|
|
}
|
|
}
|
|
|
|
private fun extractDestination(intent: Intent): Destination? =
|
|
intent.let {
|
|
val itemId =
|
|
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
|
|
val type =
|
|
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
|
|
if (itemId != null && type != null) {
|
|
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
|
|
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
|
|
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
|
|
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
|
|
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
|
|
Destination.SeriesOverview(
|
|
itemId = seriesId,
|
|
type = BaseItemKind.SERIES,
|
|
seasonEpisode =
|
|
SeasonEpisodeIds(
|
|
seasonId = seasonId,
|
|
seasonNumber = seasonNumber,
|
|
episodeId = itemId,
|
|
episodeNumber = episodeNumber,
|
|
),
|
|
)
|
|
} else {
|
|
Destination.MediaItem(itemId, type)
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
const val INTENT_ITEM_ID = "itemId"
|
|
const val INTENT_ITEM_TYPE = "itemType"
|
|
const val INTENT_SERIES_ID = "seriesId"
|
|
const val INTENT_EPISODE_NUMBER = "epNum"
|
|
const val INTENT_SEASON_NUMBER = "seaNum"
|
|
const val INTENT_SEASON_ID = "seaId"
|
|
}
|
|
}
|
|
|
|
@HiltViewModel
|
|
class MainActivityViewModel
|
|
@Inject
|
|
constructor(
|
|
private val preferences: DataStore<AppPreferences>,
|
|
private val serverRepository: ServerRepository,
|
|
private val navigationManager: SetupNavigationManager,
|
|
private val deviceProfileService: DeviceProfileService,
|
|
private val backdropService: BackdropService,
|
|
) : ViewModel() {
|
|
fun appStart() {
|
|
viewModelScope.launchIO {
|
|
try {
|
|
val prefs =
|
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
|
if (prefs.signInAutomatically) {
|
|
val current =
|
|
serverRepository.restoreSession(
|
|
prefs.currentServerId?.toUUIDOrNull(),
|
|
prefs.currentUserId?.toUUIDOrNull(),
|
|
)
|
|
if (current != null) {
|
|
// Restored
|
|
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
|
} else {
|
|
// Did not restore
|
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
}
|
|
} else {
|
|
navigationManager.navigateTo(SetupDestination.Loading)
|
|
backdropService.clearBackdrop()
|
|
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
|
if (currentServerId != null) {
|
|
val currentServer =
|
|
serverRepository.serverDao.getServer(currentServerId)?.server
|
|
if (currentServer != null) {
|
|
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
|
} else {
|
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
}
|
|
} else {
|
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
}
|
|
}
|
|
} catch (ex: Exception) {
|
|
Timber.e(ex, "Error during appStart")
|
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
}
|
|
}
|
|
viewModelScope.launchIO {
|
|
// Create the mediaCodecCapabilitiesTest if needed
|
|
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
|
}
|
|
}
|
|
}
|