mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Migrate all Flow state collection to collectAsStateWithLifecycle for proper lifecycle awareness, ensuring collection stops when the composable is not in the STARTED state. - Added lifecycle-runtime-compose dependency to app and app-tv modules - Replaced all collectAsState() calls with collectAsStateWithLifecycle() - Updated initial parameter to initialValue for the lifecycle-aware variant
275 lines
10 KiB
Kotlin
275 lines
10 KiB
Kotlin
package hu.bbara.purefin.tv
|
|
|
|
import android.content.pm.ApplicationInfo
|
|
import android.os.Bundle
|
|
import android.widget.Toast
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.togetherWith
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.CompositionLocalProvider
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
|
import androidx.navigation3.runtime.EntryProviderScope
|
|
import androidx.navigation3.runtime.NavBackStack
|
|
import androidx.navigation3.runtime.entryProvider
|
|
import androidx.navigation3.runtime.rememberNavBackStack
|
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|
import androidx.navigation3.ui.NavDisplay
|
|
import coil3.ImageLoader
|
|
import coil3.SingletonImageLoader
|
|
import coil3.disk.DiskCache
|
|
import coil3.memory.MemoryCache
|
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
|
import coil3.request.crossfade
|
|
import coil3.util.DebugLogger
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import hu.bbara.purefin.core.data.SessionBootstrapper
|
|
import hu.bbara.purefin.core.data.UserSessionRepository
|
|
import hu.bbara.purefin.core.feature.update.AppUpdateInfo
|
|
import hu.bbara.purefin.core.feature.update.AppUpdateViewModel
|
|
import hu.bbara.purefin.core.jellyfin.JellyfinAuthInterceptor
|
|
import hu.bbara.purefin.core.navigation.NavigationCommand
|
|
import hu.bbara.purefin.core.navigation.NavigationManager
|
|
import hu.bbara.purefin.core.navigation.Route
|
|
import hu.bbara.purefin.navigation.LocalNavigationBackStack
|
|
import hu.bbara.purefin.navigation.LocalNavigationManager
|
|
import hu.bbara.purefin.ui.screen.login.TvLoginScreen
|
|
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
|
|
import hu.bbara.purefin.ui.theme.AppTheme
|
|
import hu.bbara.purefin.ui.theme.backgroundDark
|
|
import kotlinx.coroutines.launch
|
|
import okhttp3.OkHttpClient
|
|
import okio.Path.Companion.toPath
|
|
import javax.inject.Inject
|
|
|
|
@AndroidEntryPoint
|
|
class TvActivity : ComponentActivity() {
|
|
|
|
@Inject
|
|
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>
|
|
|
|
@Inject
|
|
lateinit var userSessionRepository: UserSessionRepository
|
|
|
|
@Inject
|
|
lateinit var navigationManager: NavigationManager
|
|
|
|
@Inject
|
|
lateinit var sessionBootstrapper: SessionBootstrapper
|
|
|
|
@Inject
|
|
lateinit var authInterceptor: JellyfinAuthInterceptor
|
|
|
|
private val imageOkHttpClient: OkHttpClient by lazy {
|
|
OkHttpClient.Builder()
|
|
.addNetworkInterceptor(authInterceptor)
|
|
.build()
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
lifecycleScope.launch { init() }
|
|
configureImageLoader()
|
|
enableEdgeToEdge()
|
|
setContent {
|
|
AppTheme {
|
|
MainApp(
|
|
userSessionRepository = userSessionRepository,
|
|
entryBuilders = entryBuilders,
|
|
navigationManager = navigationManager
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun init() {
|
|
sessionBootstrapper.initialize()
|
|
}
|
|
|
|
private fun configureImageLoader() {
|
|
SingletonImageLoader.setSafe { context ->
|
|
val builder = ImageLoader.Builder(context)
|
|
.components {
|
|
add(
|
|
OkHttpNetworkFetcherFactory(
|
|
callFactory = { imageOkHttpClient }
|
|
)
|
|
)
|
|
}
|
|
.memoryCache {
|
|
MemoryCache.Builder()
|
|
.maxSizePercent(context, 0.08)
|
|
.build()
|
|
}
|
|
.diskCache {
|
|
DiskCache.Builder()
|
|
.directory(context.cacheDir.resolve("image_cache").absolutePath.toPath())
|
|
.maxSizeBytes(30_000_000)
|
|
.build()
|
|
}
|
|
.crossfade(true)
|
|
|
|
if (isDebuggable()) {
|
|
builder.logger(DebugLogger())
|
|
}
|
|
|
|
builder.build()
|
|
}
|
|
}
|
|
|
|
private fun isDebuggable(): Boolean =
|
|
(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
|
|
@Composable
|
|
fun MainApp(
|
|
userSessionRepository: UserSessionRepository,
|
|
entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<Route>.() -> Unit>,
|
|
navigationManager: NavigationManager,
|
|
updateViewModel: AppUpdateViewModel = hiltViewModel()
|
|
) {
|
|
var sessionLoaded by remember { mutableStateOf(false) }
|
|
val isLoggedIn by userSessionRepository.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
|
|
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
|
|
val context = LocalContext.current
|
|
|
|
LaunchedEffect(Unit) {
|
|
userSessionRepository.isLoggedIn.collect {
|
|
sessionLoaded = true
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(updateViewModel) {
|
|
updateViewModel.checkForUpdatesOnAppOpen()
|
|
}
|
|
|
|
LaunchedEffect(updateViewModel, context) {
|
|
updateViewModel.snackbarMessages.collect { message ->
|
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
|
}
|
|
}
|
|
|
|
if (!sessionLoaded) {
|
|
PurefinWaitingScreen(modifier = Modifier.fillMaxSize())
|
|
} else if (isLoggedIn) {
|
|
@Suppress("UNCHECKED_CAST")
|
|
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
|
|
val appEntryProvider = entryProvider {
|
|
entryBuilders.forEach { builder -> builder() }
|
|
}
|
|
|
|
LaunchedEffect(navigationManager, backStack) {
|
|
navigationManager.commands.collect { command ->
|
|
when (command) {
|
|
NavigationCommand.Pop -> if (backStack.size > 1) backStack.removeLastOrNull()
|
|
is NavigationCommand.Navigate -> backStack.add(command.route)
|
|
is NavigationCommand.ReplaceAll -> {
|
|
backStack.clear()
|
|
backStack.add(command.route)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CompositionLocalProvider(
|
|
LocalNavigationManager provides navigationManager,
|
|
LocalNavigationBackStack provides backStack.toList()
|
|
) {
|
|
NavDisplay(
|
|
backStack = backStack,
|
|
onBack = { navigationManager.pop() },
|
|
modifier = Modifier.fillMaxSize().background(backgroundDark),
|
|
transitionSpec = {
|
|
fadeIn(
|
|
animationSpec = tween(
|
|
durationMillis = 140
|
|
)
|
|
) togetherWith
|
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
|
},
|
|
popTransitionSpec = {
|
|
fadeIn(
|
|
animationSpec = tween(
|
|
durationMillis = 140
|
|
)
|
|
) togetherWith
|
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
|
},
|
|
predictivePopTransitionSpec = { _ ->
|
|
fadeIn(
|
|
animationSpec = tween(
|
|
durationMillis = 140
|
|
)
|
|
) togetherWith
|
|
fadeOut(animationSpec = tween(durationMillis = 140))
|
|
},
|
|
entryDecorators = listOf(
|
|
rememberSaveableStateHolderNavEntryDecorator(),
|
|
rememberViewModelStoreNavEntryDecorator(),
|
|
),
|
|
entryProvider = appEntryProvider
|
|
)
|
|
}
|
|
} else {
|
|
TvLoginScreen()
|
|
}
|
|
|
|
availableUpdate?.let { update ->
|
|
TvUpdateAvailableDialog(
|
|
update = update,
|
|
onAccept = updateViewModel::acceptUpdate,
|
|
onDecline = updateViewModel::declineUpdate
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TvUpdateAvailableDialog(
|
|
update: AppUpdateInfo,
|
|
onAccept: () -> Unit,
|
|
onDecline: () -> Unit
|
|
) {
|
|
val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString()
|
|
val releaseNotes = update.releaseNotes?.takeIf { it.isNotBlank() }
|
|
val updateText = if (releaseNotes == null) {
|
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}"
|
|
} else {
|
|
"Purefin TV $versionLabel is available.\n\nVersion code: ${update.versionCode}\n\n$releaseNotes"
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDecline,
|
|
title = { Text("Update available") },
|
|
text = { Text(updateText) },
|
|
confirmButton = {
|
|
TextButton(onClick = onAccept) {
|
|
Text("Install")
|
|
}
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDecline) {
|
|
Text("Not now")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|