Improved lifecycle handling & code clean up (#1653)
## Description Add some improvements related to life cycle handling whenever the app is resumed: - Refreshing the server-side user config - Refreshing the home page automatically Also 401/403 errors accessing recording folders will be skipped since this typically means the user lost access while the app was running. Refreshing the server-side user config (above) will also help with this. Finally, there's some general code clean up in `MainActivity` to move non-UI related code out of Compose. ### Related issues Closes #1647 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
ad25a1eb7c
commit
0e62b9582c
4 changed files with 161 additions and 104 deletions
|
|
@ -27,6 +27,7 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
|
@ -50,6 +51,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
|
|
@ -72,7 +74,8 @@ import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -80,6 +83,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
@ -138,6 +142,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
lateinit var screensaverService: ScreensaverService
|
lateinit var screensaverService: ScreensaverService
|
||||||
|
|
||||||
private var signInAuto = true
|
private var signInAuto = true
|
||||||
|
private var playerBackend: PlayerBackend? = null
|
||||||
|
|
||||||
private val json =
|
private val json =
|
||||||
Json {
|
Json {
|
||||||
|
|
@ -172,7 +177,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (!playExternalViewModel.launched.value) {
|
if (!playExternalViewModel.launched.value) {
|
||||||
val lastDest = backStack.lastOrNull()
|
val lastDest = backStack.lastOrNull()
|
||||||
if (lastDest.isPlayback) {
|
if (lastDest.isPlayback) {
|
||||||
Timber.v("Restoring back stack with playback")
|
Timber.v("Restoring back stack without playback")
|
||||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,55 +214,64 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.e(ex, "Error with keepScreenOn")
|
Timber.e(ex, "Error with keepScreenOn")
|
||||||
}.launchIn(lifecycleScope)
|
}.launchIn(lifecycleScope)
|
||||||
|
|
||||||
|
userPreferencesDataStore.data.collectLatestIn(lifecycleScope) { prefs ->
|
||||||
|
signInAuto = prefs.signInAutomatically
|
||||||
|
playerBackend = prefs.playbackPreferences.playerBackend
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.appStart()
|
viewModel.appStart()
|
||||||
setContent {
|
setContent {
|
||||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
Surface(
|
||||||
if (appPreferences == null) {
|
Modifier
|
||||||
// Show loading page if it is taking a while to get app preferences
|
.fillMaxSize()
|
||||||
var showLoading by remember { mutableStateOf(false) }
|
.background(Color.Black),
|
||||||
LaunchedEffect(Unit) {
|
) {
|
||||||
delay(500)
|
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||||
Timber.i("Showing loading page")
|
if (appPreferences == null) {
|
||||||
showLoading = true
|
// Show loading page if it is taking a while to get app preferences
|
||||||
}
|
var showLoading by remember { mutableStateOf(false) }
|
||||||
if (showLoading) {
|
LaunchedEffect(Unit) {
|
||||||
Box(
|
delay(500.milliseconds)
|
||||||
modifier =
|
Timber.i("Showing loading page")
|
||||||
Modifier
|
showLoading = true
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black),
|
|
||||||
) {
|
|
||||||
LoadingPage()
|
|
||||||
}
|
}
|
||||||
}
|
if (showLoading) {
|
||||||
}
|
Box(
|
||||||
appPreferences?.let { appPreferences ->
|
modifier =
|
||||||
LaunchedEffect(appPreferences.signInAutomatically) {
|
Modifier
|
||||||
signInAuto = appPreferences.signInAutomatically
|
.fillMaxSize()
|
||||||
}
|
.background(Color.Black),
|
||||||
CoilConfig(
|
) {
|
||||||
prefs = appPreferences,
|
LoadingPage()
|
||||||
okHttpClient = okHttpClient,
|
}
|
||||||
debugLogging = false,
|
}
|
||||||
enableCache = true,
|
} else {
|
||||||
)
|
appPreferences?.let { appPreferences ->
|
||||||
LaunchedEffect(appPreferences.debugLogging) {
|
CoilConfig(
|
||||||
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
prefs = appPreferences,
|
||||||
}
|
okHttpClient = okHttpClient,
|
||||||
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
debugLogging = false,
|
||||||
WholphinTheme(
|
enableCache = true,
|
||||||
true,
|
)
|
||||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
LaunchedEffect(appPreferences.debugLogging) {
|
||||||
) {
|
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
||||||
ProvideLocalClock {
|
}
|
||||||
MainContent(
|
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
||||||
backStack = setupNavigationManager.backStack,
|
WholphinTheme(
|
||||||
navigationManager = navigationManager,
|
true,
|
||||||
appPreferences = appPreferences,
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||||
backdropService = backdropService,
|
) {
|
||||||
screensaverService = screensaverService,
|
ProvideLocalClock {
|
||||||
modifier = Modifier.fillMaxSize(),
|
MainContent(
|
||||||
)
|
backStack = setupNavigationManager.backStack,
|
||||||
|
navigationManager = navigationManager,
|
||||||
|
appPreferences = appPreferences,
|
||||||
|
backdropService = backdropService,
|
||||||
|
screensaverService = screensaverService,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +293,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
Timber.d("onResume")
|
Timber.d("onResume")
|
||||||
|
viewModel.appResume()
|
||||||
lifecycleScope.launchDefault {
|
lifecycleScope.launchDefault {
|
||||||
screensaverService.pulse()
|
screensaverService.pulse()
|
||||||
}
|
}
|
||||||
|
|
@ -342,8 +357,6 @@ class MainActivity : AppCompatActivity() {
|
||||||
// the back stack rather than crashing in onSaveInstanceState.
|
// the back stack rather than crashing in onSaveInstanceState.
|
||||||
Timber.w(ex, "Could not persist back stack; skipping")
|
Timber.w(ex, "Could not persist back stack; skipping")
|
||||||
}
|
}
|
||||||
val playerBackend =
|
|
||||||
runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
|
|
||||||
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,63 +453,71 @@ class MainActivityViewModel
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val appUpgradeHandler: AppUpgradeHandler,
|
private val appUpgradeHandler: AppUpgradeHandler,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
fun appStart() {
|
fun appStart() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
try {
|
mutex.withLock {
|
||||||
val needUpgrade = appUpgradeHandler.needUpgrade()
|
try {
|
||||||
if (needUpgrade) {
|
val needUpgrade = appUpgradeHandler.needUpgrade()
|
||||||
showToast(
|
if (needUpgrade) {
|
||||||
context,
|
showToast(
|
||||||
context.getString(
|
context,
|
||||||
R.string.updated_toast,
|
context.getString(
|
||||||
appUpgradeHandler.currentVersion.toString(),
|
R.string.updated_toast,
|
||||||
),
|
appUpgradeHandler.currentVersion.toString(),
|
||||||
)
|
),
|
||||||
appUpgradeHandler.run()
|
|
||||||
}
|
|
||||||
appUpgradeHandler.copySubfont(false)
|
|
||||||
val prefs =
|
|
||||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
|
||||||
val profileProtected =
|
|
||||||
serverRepository.current.value?.user?.let {
|
|
||||||
it.hasPin || it.requireLogin
|
|
||||||
} == true
|
|
||||||
if (prefs.signInAutomatically && !profileProtected) {
|
|
||||||
val current =
|
|
||||||
serverRepository.restoreSession(
|
|
||||||
prefs.currentServerId?.toUUIDOrNull(),
|
|
||||||
prefs.currentUserId?.toUUIDOrNull(),
|
|
||||||
)
|
)
|
||||||
if (current != null) {
|
appUpgradeHandler.run()
|
||||||
if (current.user.hasPin || current.user.requireLogin) {
|
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(current.server))
|
|
||||||
} else {
|
|
||||||
// Restored
|
|
||||||
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Did not restore
|
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
||||||
}
|
}
|
||||||
} else {
|
appUpgradeHandler.copySubfont(false)
|
||||||
navigationManager.navigateTo(SetupDestination.Loading)
|
val prefs =
|
||||||
backdropService.clearBackdrop()
|
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||||
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
|
val profileProtected =
|
||||||
if (currentServerId != null) {
|
serverRepository.current.value?.user?.let {
|
||||||
val currentServer =
|
it.hasPin || it.requireLogin
|
||||||
serverRepository.serverDao.getServer(currentServerId)?.server
|
} == true
|
||||||
if (currentServer != null) {
|
if (prefs.signInAutomatically && !profileProtected) {
|
||||||
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
|
val current =
|
||||||
|
serverRepository.restoreSession(
|
||||||
|
prefs.currentServerId?.toUUIDOrNull(),
|
||||||
|
prefs.currentUserId?.toUUIDOrNull(),
|
||||||
|
)
|
||||||
|
if (current != null) {
|
||||||
|
if (current.user.hasPin || current.user.requireLogin) {
|
||||||
|
navigationManager.navigateTo(SetupDestination.UserList(current.server))
|
||||||
|
} else {
|
||||||
|
// Restored
|
||||||
|
navigationManager.navigateTo(SetupDestination.AppContent(current))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Did not restore
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
navigationManager.navigateTo(SetupDestination.ServerList)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
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)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Error during appStart")
|
|
||||||
navigationManager.navigateTo(SetupDestination.ServerList)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
|
|
@ -504,6 +525,19 @@ class MainActivityViewModel
|
||||||
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun appResume() {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
Timber.v("Updating userDto")
|
||||||
|
serverRepository.updateUserDto()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error during appResume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val Destination?.isPlayback: Boolean
|
private val Destination?.isPlayback: Boolean
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.Jellyfin
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
|
@ -298,6 +299,16 @@ class ServerRepository
|
||||||
response.content
|
response.content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update [currentUserDto] by querying the server
|
||||||
|
*/
|
||||||
|
suspend fun updateUserDto() {
|
||||||
|
val userDto by apiClient.userApi.getCurrentUser()
|
||||||
|
_currentUserDto.update {
|
||||||
|
if (it?.id == userDto.id && currentUser?.id == userDto.id) userDto else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
fun getServerSharedPreferences(context: Context): SharedPreferences =
|
||||||
context.getSharedPreferences(
|
context.getSharedPreferences(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
@ -132,13 +133,22 @@ class NavDrawerService
|
||||||
.content.items
|
.content.items
|
||||||
val recordingFolders =
|
val recordingFolders =
|
||||||
if (tvAccess) {
|
if (tvAccess) {
|
||||||
api.liveTvApi
|
try {
|
||||||
.getRecordingFolders(userId = userId)
|
api.liveTvApi
|
||||||
.content.items
|
.getRecordingFolders(userId = userId)
|
||||||
.map { it.id }
|
.content.items
|
||||||
.toSet()
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
} catch (ex: InvalidStatusException) {
|
||||||
|
if (ex.status == 401 || ex.status == 403) {
|
||||||
|
Timber.w("Got HTTP %s querying for recording folders", ex.status)
|
||||||
|
emptySet()
|
||||||
|
} else {
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setOf()
|
emptySet()
|
||||||
}
|
}
|
||||||
val libraries =
|
val libraries =
|
||||||
userViews
|
userViews
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -103,8 +104,9 @@ fun HomePage(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LifecycleStartEffect(Unit) {
|
||||||
viewModel.init()
|
viewModel.init()
|
||||||
|
onStopOrDispose { }
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val loading = state.loadingState
|
val loading = state.loadingState
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue