diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index de597b3a3a..2773f61f68 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1235,6 +1235,7 @@ fun PluviaMain( BootingSplash( visible = state.showBootingSplash, text = state.bootingSplashText, + heroImageUrl = state.bootingSplashHeroImageUrl, ) } diff --git a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt index 6fed8666a0..9e829be114 100644 --- a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt +++ b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt @@ -27,16 +27,20 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.theme.BrandGradient -import androidx.compose.ui.platform.LocalContext import app.gamenative.R -import kotlin.math.sin +import app.gamenative.ui.theme.BrandGradient +import app.gamenative.ui.theme.PluviaTheme +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.coil.CoilImage import kotlin.random.Random import kotlinx.coroutines.delay @@ -45,6 +49,7 @@ fun BootingSplash( visible: Boolean = true, text: String = "Initializing...", progress: Float = -1f, // -1 for indeterminate, 0-1 for determinate + heroImageUrl: String = "", ) { // Tips rotation (no animation cost, safe outside visibility check) val context = LocalContext.current @@ -124,31 +129,70 @@ fun BootingSplash( label = "shimmer", ) - val particlePhase by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(20000, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "particlePhase", - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background, - PluviaTheme.colors.surfacePanel, - MaterialTheme.colorScheme.background, - ), - ), - ), + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - AmbientParticles(phase = particlePhase) + if (heroImageUrl.isNotEmpty()) { + CoilImage( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = 1.06f + scaleY = 1.06f + } + .blur(3.dp), + imageModel = { heroImageUrl }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = null, + ), + loading = {}, + failure = {}, + previewPlaceholder = painterResource(R.drawable.ic_logo_color), + ) + } + + // Dark overlay — heavier than the carousel to keep text readable + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = if (heroImageUrl.isNotEmpty()) 0.55f else 0.85f)), + ) + + // Vertical vignette (mirrors carousel backdrop) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Black.copy(alpha = 0.74f), + 0.16f to Color.Black.copy(alpha = 0.52f), + 0.38f to Color.Black.copy(alpha = 0.24f), + 0.62f to Color.Black.copy(alpha = 0.34f), + 1.0f to Color.Black.copy(alpha = 0.72f), + ), + ), + ), + ) + + // Horizontal vignette (mirrors carousel backdrop) + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.horizontalGradient( + colorStops = arrayOf( + 0.0f to Color.Black.copy(alpha = 0.34f), + 0.14f to Color.Black.copy(alpha = 0.16f), + 0.5f to Color.Transparent, + 0.86f to Color.Black.copy(alpha = 0.16f), + 1.0f to Color.Black.copy(alpha = 0.34f), + ), + ), + ), + ) // Main content Column( @@ -213,6 +257,11 @@ fun BootingSplash( style = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium, letterSpacing = 1.sp, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = Offset(0f, 1f), + blurRadius = 6f, + ), ), color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, @@ -237,6 +286,11 @@ fun BootingSplash( text = tips[idx], style = MaterialTheme.typography.bodySmall.copy( lineHeight = 20.sp, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.8f), + offset = Offset(0f, 1f), + blurRadius = 6f, + ), ), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -306,55 +360,6 @@ private fun ProgressBar( } } -@Composable -private fun AmbientParticles( - phase: Float, - modifier: Modifier = Modifier, -) { - val particleColor = PluviaTheme.colors.accentCyan - - val particles = remember { - List(12) { - ParticleData( - baseX = Random.nextFloat(), - baseY = Random.nextFloat(), - size = Random.nextFloat() * 3f + 1f, - speed = Random.nextFloat() * 0.5f + 0.5f, - phaseOffset = Random.nextFloat() * 360f, - ) - } - } - - Canvas(modifier = modifier.fillMaxSize()) { - particles.forEach { particle -> - val animatedPhase = (phase + particle.phaseOffset) * particle.speed - val radians = Math.toRadians(animatedPhase.toDouble()) - - val offsetX = (sin(radians) * 30).toFloat() - val offsetY = (sin(radians * 0.7) * 20).toFloat() - - val x = particle.baseX * size.width + offsetX - val y = particle.baseY * size.height + offsetY - - // Pulsing alpha based on phase - val alpha = (0.15f + 0.15f * sin(radians * 2).toFloat()).coerceIn(0f, 0.3f) - - drawCircle( - color = particleColor.copy(alpha = alpha), - radius = particle.size.dp.toPx(), - center = Offset(x, y), - ) - } - } -} - -private data class ParticleData( - val baseX: Float, - val baseY: Float, - val size: Float, - val speed: Float, - val phaseOffset: Float, -) @Preview(name = "BootingSplash - Indeterminate") @Composable diff --git a/app/src/main/java/app/gamenative/ui/data/MainState.kt b/app/src/main/java/app/gamenative/ui/data/MainState.kt index d95efc565e..539f1e9438 100644 --- a/app/src/main/java/app/gamenative/ui/data/MainState.kt +++ b/app/src/main/java/app/gamenative/ui/data/MainState.kt @@ -22,6 +22,7 @@ data class MainState( val testGraphics: Boolean = false, val showBootingSplash: Boolean = false, val bootingSplashText: String = "Booting...", + val bootingSplashHeroImageUrl: String = "", // Connection state for background reconnection // Default to DISCONNECTED - service will start and set to CONNECTING diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index e48fa65075..750c559533 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -18,7 +18,11 @@ import app.gamenative.events.SteamEvent import app.gamenative.ui.enums.Orientation import java.util.EnumSet import app.gamenative.service.SteamService +import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicCloudSavesManager +import app.gamenative.service.epic.EpicService +import app.gamenative.service.gog.GOGService +import app.gamenative.utils.CustomGameScanner import app.gamenative.ui.data.MainState import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen @@ -318,6 +322,10 @@ class MainViewModel @Inject constructor( _state.update { it.copy(bootingSplashText = value) } } + fun setBootingSplashHeroImageUrl(url: String) { + _state.update { it.copy(bootingSplashHeroImageUrl = url) } + } + // Connection state management /** @@ -448,6 +456,43 @@ class MainViewModel @Inject constructor( fun launchApp(context: Context, appId: String) { // Show booting splash before launching the app viewModelScope.launch { + // Resolve hero image URL for the booting splash background + val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val heroUrl = when (gameSource) { + GameSource.STEAM -> { + val steamApp = SteamService.getAppInfoOf(gameId) + steamApp?.getHeroUrl()?.ifEmpty { steamApp.headerUrl } ?: "" + } + GameSource.GOG -> { + val game = GOGService.getGOGGameOf(gameId.toString()) + game?.backgroundUrl?.ifEmpty { game.imageUrl } ?: "" + } + GameSource.EPIC -> { + val game = EpicService.getEpicGameOf(gameId) + game?.artPortrait?.ifEmpty { game.artCover.ifEmpty { game.artSquare } } ?: "" + } + GameSource.AMAZON -> { + val game = AmazonService.getAmazonGameByAppId(gameId) + game?.heroUrl?.ifEmpty { game.artUrl } ?: "" + } + GameSource.CUSTOM_GAME -> { + val folderPath = CustomGameScanner.getFolderPathFromAppId(appId) + if (folderPath != null) { + val folder = java.io.File(folderPath) + val heroFile = folder.listFiles()?.firstOrNull { file -> + file.isFile && + file.name.startsWith("steamgriddb_hero", ignoreCase = true) && + !file.name.contains("grid_", ignoreCase = true) && + (file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true)) + } + heroFile?.let { android.net.Uri.fromFile(it).toString() } ?: "" + } else "" + } + } + setBootingSplashHeroImageUrl(heroUrl) setShowBootingSplash(true) PluviaApp.events.emit(AndroidEvent.SetAllowedOrientation(PrefManager.allowedOrientation))