@@ -0,0 +1,353 @@
package tv.orbit.classic
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.withTransform
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.sin
/**
* First launch for a new user (Paul, 5 November 2026, from the approved design; Emby first, the other servers later). Welcome
* with Ollie, choose the server, sign in (Orbit's own sign-in), pick the channels while Drift sets them up and shows what went
* where, who watches (the Grown-ups PIN and Kids), and You're on air. It runs once, for a box that has never been signed in.
* Settings › Emby account can show it again as a tour, which changes nothing. Ollie rolls in and blinks; Drift bobs and blinks;
* at the end the cast floats right through the stars. All still with Reduce motion on.
*/
internal enum class OnboardStep ( val label : String ) { Welcome ( " Welcome " ) , Server ( " Server " ) , SignIn ( " Sign in " ) , Channels ( " Channels " ) , WhoWatches ( " Who watches " ) , AllSet ( " All set " ) }
/** The channels ticked to start with, as in the design: the ones most libraries suit. */
internal val OnboardStartChannels = setOf ( 101 , 106 , 201 , 107 , 302 , TOTS _NUMBER , 605 , 104 )
internal object Onboarding {
var step by mutableStateOf < OnboardStep ? > ( null )
private set
/** Shown again from Settings: nothing is changed by it. */
var tour by mutableStateOf ( false )
private set
private fun prefs ( context : Context ) = context . getSharedPreferences ( " orbit_onboarding " , Context . MODE _PRIVATE )
fun done ( context : Context ) = prefs ( context ) . getBoolean ( " done " , false )
fun markDone ( context : Context ) = prefs ( context ) . edit ( ) . putBoolean ( " done " , true ) . apply ( )
fun start ( tour : Boolean ) { this . tour = tour ; ChannelSetup . begin ( emptyList ( ) ) ; step = OnboardStep . Welcome }
fun go ( to : OnboardStep ) { step = to }
fun finish ( context : Context ) { if ( ! tour ) markDone ( context ) ; step = null ; tour = false }
}
/**
* Setting the chosen channels up, one after another, by the same reading and planning as switching one on in Settings. Asked
* for here, done by the screen that holds the Emby session. In the tour it only reads how many titles each channel already has.
*/
internal object ChannelSetup {
var wanted by mutableStateOf < List < Int > > ( emptyList ( ) )
var current by mutableStateOf < Int ? > ( null )
/** Titles each channel found, by number; -1 when it couldn't be set up. */
var found by mutableStateOf < Map < Int , Int > > ( emptyMap ( ) )
var startedAt by mutableLongStateOf ( 0L )
val finished get ( ) = wanted . isNotEmpty ( ) && wanted . all { it in found }
fun begin ( numbers : List < Int > ) { found = emptyMap ( ) ; current = null ; startedAt = System . currentTimeMillis ( ) ; wanted = numbers }
/** Minutes left, from how long the channels done so far took (a minute each to start with). */
fun minutesLeft ( nowMs : Long ) : Int {
val done = found . size
val each = if ( done == 0 ) 60_000L else ( nowMs - startedAt ) / done
return ( ( ( wanted . size - done ) * each + 59_999 ) / 60_000 ) . toInt ( ) . coerceAtLeast ( if ( finished ) 0 else 1 )
}
}
@Composable
private fun Steps ( at : OnboardStep , modifier : Modifier = Modifier ) {
Row ( modifier , verticalAlignment = Alignment . CenterVertically ) {
OnboardStep . entries . forEachIndexed { i , s ->
Column ( horizontalAlignment = Alignment . CenterHorizontally , modifier = Modifier . width ( 96 . dp ) ) {
Box ( Modifier . size ( if ( s == at ) 12 . dp else 9 . dp ) . background ( if ( s == at ) Yellow else if ( s . ordinal < at . ordinal ) Color ( 0xFF6EDCAA ) else Color ( 0xFF3C5078 ) , RoundedCornerShape ( 50 ) ) )
Text ( s . label , color = if ( s == at ) Color . White else Muted , fontSize = 11 . sp , modifier = Modifier . padding ( top = 4 . dp ) )
}
if ( i < OnboardStep . entries . lastIndex ) Box ( Modifier . width ( 24 . dp ) . height ( 2 . dp ) . padding ( bottom = 14 . dp ) . background ( if ( s . ordinal < at . ordinal ) Color ( 0xFF6EDCAA ) else Color ( 0xFF283C64 ) ) )
}
}
}
@Composable
private fun reduceMotion ( ) : Boolean { val context = LocalContext . current ; return remember { OrbitSettings ( context ) . reduceMotion } }
/** Time since this was first drawn, frame by frame; zero with Reduce motion. */
@Composable
private fun clock ( calm : Boolean ) : Long {
var t by remember { mutableLongStateOf ( 0L ) }
LaunchedEffect ( calm ) { if ( ! calm ) { val start = withFrameMillis { it } ; while ( true ) withFrameMillis { t = it - start } } }
return t
}
/** The whole flow, over the starry sky. [onDone] goes on into Orbit. During sign-in only the steps show, over Orbit's own form. */
@Composable
internal fun OnboardingScreen ( signedIn : Boolean , onDone : ( tour : Boolean ) -> Unit ) {
val step = Onboarding . step ?: return
val context = LocalContext . current
// A step back with Back, except from the first.
BackHandler ( step != OnboardStep . Welcome && step != OnboardStep . AllSet ) { Onboarding . go ( OnboardStep . entries [ step . ordinal - 1 ] . let { if ( it == OnboardStep . SignIn && signedIn && ! Onboarding . tour ) OnboardStep . Server else it } ) }
Box ( Modifier . fillMaxSize ( ) . then ( if ( step == OnboardStep . SignIn && ! Onboarding . tour ) Modifier else Modifier . background ( Navy ) ) ) {
if ( step != OnboardStep . SignIn || Onboarding . tour ) StarrySky ( )
Steps ( step , Modifier . align ( Alignment . TopCenter ) . padding ( top = 16 . dp ) )
Box ( Modifier . fillMaxSize ( ) . padding ( top = 70 . dp , bottom = 24 . dp , start = 40 . dp , end = 40 . dp ) ) {
when ( step ) {
OnboardStep . Welcome -> WelcomeStep { Onboarding . go ( OnboardStep . Server ) }
OnboardStep . Server -> ServerStep { Onboarding . go ( OnboardStep . SignIn ) }
OnboardStep . SignIn -> if ( Onboarding . tour ) TourSignedIn { Onboarding . go ( OnboardStep . Channels ) }
else Text ( " Sign in with your Emby account. Your password is never saved. " , Modifier . align ( Alignment . BottomCenter ) , color = Muted , fontSize = 13 . sp )
OnboardStep . Channels -> ChannelsStep { Onboarding . go ( OnboardStep . WhoWatches ) }
OnboardStep . WhoWatches -> WhoWatchesStep { Onboarding . go ( OnboardStep . AllSet ) }
OnboardStep . AllSet -> AllSetStep { val tour = Onboarding . tour ; Onboarding . finish ( context ) ; onDone ( tour ) }
}
}
if ( Onboarding . tour ) Text ( " A tour: nothing you choose here changes Orbit " , Modifier . align ( Alignment . BottomEnd ) . padding ( 16 . dp ) , color = Color ( 0xFFFF7AC6 ) , fontSize = 12 . sp )
}
}
@Composable
private fun WelcomeStep ( onNext : ( ) -> Unit ) {
val calm = reduceMotion ( ) ; val t = clock ( calm )
val go = remember { FocusRequester ( ) }
LaunchedEffect ( Unit ) { go . requestFocus ( ) }
Column ( Modifier . fillMaxSize ( ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . Center ) {
// Ollie rolls in from the left, settles with a bounce, then blinks every few seconds.
Canvas ( Modifier . size ( 260 . dp ) ) {
val roll = if ( calm ) 1f else ( t / 1_300f ) . coerceIn ( 0f , 1f )
val eased = 1 - ( 1 - roll ) * ( 1 - roll )
val bounce = if ( calm || t < 1_300 ) 0f else abs ( sin ( ( t - 1_300 ) / 180.0 ) ) . toFloat ( ) * ( 1f - ( ( t - 1_300 ) / 700f ) . coerceIn ( 0f , 1f ) ) * size . height * .06f
val x = center . x - ( 1 - eased ) * size . width * 2.2f
val blink = ! calm && t > 1_800 && ( t % 3_600 ) < 160
withTransform ( { rotate ( - ( 1 - eased ) * 540f , Offset ( x , center . y ) ) } ) {
drawCircle ( Color ( 0xFF285AA0 ) , size . minDimension * .42f , Offset ( x , center . y - bounce ) )
drawOrbMascot ( Offset ( x , center . y - bounce ) , size . minDimension * .2f , blink = blink )
}
}
Text ( " Welcome to Orbit " , color = Color . White , fontFamily = Anton , fontSize = 40 . sp )
Text ( " Proper TV channels, made from your own films and series. " , color = Muted , fontSize = 18 . sp , modifier = Modifier . padding ( top = 8 . dp ) )
Text ( " It takes about five minutes. I'm Ollie, and I'll show you round. " , color = Muted , fontSize = 16 . sp )
OrbitButton ( " Let's go " , Modifier . padding ( top = 24 . dp ) . width ( 200 . dp ) . focusRequester ( go ) , onClick = onNext )
}
}
@Composable
private fun ServerStep ( onNext : ( ) -> Unit ) {
val first = remember { FocusRequester ( ) }
LaunchedEffect ( Unit ) { first . requestFocus ( ) }
var note by remember { mutableStateOf < String ? > ( null ) }
Column ( Modifier . fillMaxSize ( ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 14 . dp ) ) {
Spacer ( Modifier . height ( 20 . dp ) )
Text ( " Where are your films and series? " , color = Color . White , fontFamily = Anton , fontSize = 32 . sp )
Text ( " Choose the media server they live on. Orbit only reads it, and never changes or copies anything. " , color = Muted , fontSize = 15 . sp )
Row ( horizontalArrangement = Arrangement . spacedBy ( 16 . dp ) , modifier = Modifier . padding ( top = 16 . dp ) ) {
listOf ( Triple ( " Emby " , Color ( 0xFF52B54B ) , true ) , Triple ( " Plex " , Color ( 0xFFE5A00D ) , false ) , Triple ( " Jellyfin " , Color ( 0xFFAA5CC3 ) , false ) , Triple ( " Silo " , Color ( 0xFF45B8F5 ) , false ) )
. forEachIndexed { i , ( name , colour , ready ) ->
var focused by remember { mutableStateOf ( false ) }
val corner = RoundedCornerShape ( 12 . dp )
Column ( Modifier . size ( 180 . dp , 200 . dp ) . then ( if ( i == 0 ) Modifier . focusRequester ( first ) else Modifier ) . onFocusChanged { focused = it . isFocused }
. background ( if ( focused ) Navy else Panel , corner ) . border ( if ( focused ) 3 . dp else 0 . dp , if ( focused ) Yellow else Color . Transparent , corner )
. clickable { if ( ready ) onNext ( ) else note = " $name is coming soon. For now, Orbit works with Emby. " } . padding ( 14 . dp ) ,
horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 8 . dp ) ) {
// A plain server mark in its colour: our own drawing, not the product's logo.
Canvas ( Modifier . size ( 70 . dp , 64 . dp ) ) {
for ( k in 0 .. 2 ) {
drawRoundRect ( colour . copy ( alpha = if ( ready ) 1f else .45f ) , Offset ( 0f , k * size . height / 3f ) , androidx . compose . ui . geometry . Size ( size . width , size . height / 3f - 4f ) , androidx . compose . ui . geometry . CornerRadius ( 6f ) )
drawCircle ( Navy , 4f , Offset ( size . width - 12f , k * size . height / 3f + size . height / 6f - 2f ) )
}
}
Text ( name , color = if ( ready ) Color . White else Muted , fontFamily = Anton , fontSize = 24 . sp )
Text ( if ( ready ) " Sign in with your Emby account " else " Coming soon " , color = Muted , fontSize = 11 . sp , textAlign = TextAlign . Center )
}
}
}
Text ( note ?: " Not sure? It's whichever app you watch your own films on. " , color = if ( note != null ) Yellow else Muted , fontSize = 14 . sp , modifier = Modifier . padding ( top = 10 . dp ) )
}
}
@Composable
private fun TourSignedIn ( onNext : ( ) -> Unit ) {
val go = remember { FocusRequester ( ) }
LaunchedEffect ( Unit ) { go . requestFocus ( ) }
Column ( Modifier . fillMaxSize ( ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . Center ) {
Text ( " Sign in to Emby " , color = Color . White , fontFamily = Anton , fontSize = 32 . sp )
Text ( " A new box signs in here with an Emby account. This box is already signed in, so the tour moves on. " , color = Muted , fontSize = 15 . sp , modifier = Modifier . padding ( 8 . dp ) )
OrbitButton ( " Next " , Modifier . width ( 200 . dp ) . focusRequester ( go ) , onClick = onNext )
}
}
@Composable
private fun ChannelsStep ( onNext : ( ) -> Unit ) {
var ticked by remember { mutableStateOf ( OnboardStartChannels ) }
val setting = ChannelSetup . wanted . isNotEmpty ( )
if ( ! setting ) {
val first = remember { FocusRequester ( ) }
LaunchedEffect ( Unit ) { first . requestFocus ( ) }
Column ( Modifier . fillMaxSize ( ) , verticalArrangement = Arrangement . spacedBy ( 10 . dp ) ) {
Text ( " Your channels " , color = Color . White , fontFamily = Anton , fontSize = 30 . sp )
Text ( " Orbit's ready-made channels, each matched to your library. The ticked ones suit most libraries; tick any you like. " +
" Making your own channels comes next, in Settings. " , color = Muted , fontSize = 14 . sp )
LazyVerticalGrid ( GridCells . Fixed ( 5 ) , Modifier . weight ( 1f ) , verticalArrangement = Arrangement . spacedBy ( 6 . dp ) , horizontalArrangement = Arrangement . spacedBy ( 6 . dp ) ) {
items ( SmartChannels . sortedBy { it . number } . filter { it . number !in LockedChannels } , key = { it . number } ) { programming ->
val on = programming . number in ticked
var focused by remember { mutableStateOf ( false ) }
val corner = RoundedCornerShape ( 8 . dp )
Row ( Modifier . height ( 46 . dp ) . then ( if ( programming . number == 101 ) Modifier . focusRequester ( first ) else Modifier ) . onFocusChanged { focused = it . isFocused }
. background ( if ( focused ) Navy else Panel , corner ) . border ( if ( focused ) 3 . dp else 0 . dp , if ( focused ) Yellow else Color . Transparent , corner )
. clickable { ticked = if ( on ) ticked - programming . number else ticked + programming . number } . padding ( horizontal = 8 . dp ) , verticalAlignment = Alignment . CenterVertically ) {
val brand = ChannelBrands . forNumber ( programming . number )
if ( brand != null ) Image ( painterResource ( brand . logo ) , brand . displayName , Modifier . width ( 84 . dp ) . height ( 26 . dp ) ) else Text ( programming . name , color = Color . White , fontSize = 13 . sp )
Spacer ( Modifier . weight ( 1f ) )
Box ( Modifier . size ( 20 . dp ) . background ( if ( on ) Color ( 0xFF6EDCAA ) else Color ( 0xFF283C64 ) , RoundedCornerShape ( 5 . dp ) ) , contentAlignment = Alignment . Center ) {
if ( on ) Text ( " ✓ " , color = Navy , fontSize = 13 . sp , fontWeight = FontWeight . Bold )
}
}
}
}
Row ( verticalAlignment = Alignment . CenterVertically , horizontalArrangement = Arrangement . spacedBy ( 16 . dp ) ) {
OrbitButton ( " Set up ${ticked.size} channels " , enabled = ticked . isNotEmpty ( ) , onClick = { ChannelSetup . begin ( SmartChannels . map { it . number } . filter { it in ticked } ) } )
Text ( " Change them any time in Settings › Channels. " , color = Muted , fontSize = 13 . sp )
}
}
return
}
// Drift sets them up one by one and shows what went where.
val calm = reduceMotion ( ) ; val t = clock ( calm )
val go = remember { FocusRequester ( ) }
LaunchedEffect ( ChannelSetup . finished ) { if ( ChannelSetup . finished ) go . requestFocus ( ) }
Row ( Modifier . fillMaxSize ( ) , horizontalArrangement = Arrangement . spacedBy ( 24 . dp ) ) {
Column ( Modifier . width ( 300 . dp ) . fillMaxHeight ( ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . Center ) {
Canvas ( Modifier . size ( 180 . dp ) ) {
val bob = if ( calm ) 0f else sin ( t / 600.0 ) . toFloat ( ) * size . height * .04f
val blink = ! calm && ( t % 3_200 ) < 150
drawOrbChaser ( center + Offset ( 0f , bob ) , size . minDimension * .34f , OrbCharacter . Drift , OrbDir . Right , false , false , false , t / 500f )
if ( blink ) for ( side in listOf ( - 1f , 1f ) ) {
val r = size . minDimension * .34f ; val eye = center + Offset ( side * r * .34f , - r * .1f + bob )
drawCircle ( OrbCharacter . Drift . colour , r * .28f , eye )
drawLine ( Color ( 0xFF14213D ) , eye - Offset ( r * .2f , 0f ) , eye + Offset ( r * .2f , 0f ) , r * .07f )
}
}
Text ( if ( ChannelSetup . finished ) " All matched " else " Drift's matching your library " , color = Color . White , fontSize = 20 . sp , fontWeight = FontWeight . SemiBold , textAlign = TextAlign . Center )
val now = System . currentTimeMillis ( )
Text ( if ( ChannelSetup . finished ) " ${ChannelSetup.found.values.filter { it > 0 } .sum()} titles across ${ChannelSetup.found.count { it.value > 0 } } channels "
else " ${ChannelSetup.found.size} of ${ChannelSetup.wanted.size} channels · about ${ChannelSetup.minutesLeft(now + t * 0)} min left " , color = Yellow , fontSize = 14 . sp , textAlign = TextAlign . Center )
if ( ChannelSetup . finished ) OrbitButton ( " Next " , Modifier . padding ( top = 16 . dp ) . width ( 180 . dp ) . focusRequester ( go ) , onClick = onNext )
}
Column ( Modifier . weight ( 1f ) . fillMaxHeight ( ) , verticalArrangement = Arrangement . spacedBy ( 5 . dp ) ) {
Text ( " Here's where everything goes " , color = Color . White , fontFamily = Anton , fontSize = 26 . sp )
ChannelSetup . wanted . forEach { number ->
val count = ChannelSetup . found [ number ]
val busy = ChannelSetup . current == number
Row ( Modifier . fillMaxWidth ( ) . height ( 40 . dp ) . background ( if ( busy ) Navy else Panel , RoundedCornerShape ( 8 . dp ) ) . border ( if ( busy ) 2 . dp else 0 . dp , if ( busy ) Yellow else Color . Transparent , RoundedCornerShape ( 8 . dp ) )
. padding ( horizontal = 10 . dp ) , verticalAlignment = Alignment . CenterVertically , horizontalArrangement = Arrangement . spacedBy ( 12 . dp ) ) {
val brand = ChannelBrands . forNumber ( number )
if ( brand != null ) Image ( painterResource ( brand . logo ) , brand . displayName , Modifier . width ( 80 . dp ) . height ( 24 . dp ) )
Text ( brand ?. displayName ?: " $number " , color = Color . White , fontSize = 14 . sp , modifier = Modifier . weight ( 1f ) )
Text ( when {
count == null && busy -> " Matching… "
count == null -> " Waiting "
count < 0 -> " Couldn't set up: try in Settings "
count < 10 -> " $count titles: a short loop "
else -> " $count titles "
} , color = if ( count != null && count < 0 ) Yellow else Muted , fontSize = 13 . sp )
}
}
}
}
}
@Composable
private fun WhoWatchesStep ( onNext : ( ) -> Unit ) {
val context = LocalContext . current
var pinStep by remember { mutableStateOf ( 0 ) }
var first by remember { mutableStateOf ( " " ) }
var note by remember { mutableStateOf < String ? > ( null ) }
val go = remember { FocusRequester ( ) }
LaunchedEffect ( pinStep ) { if ( pinStep == 0 ) runCatching { go . requestFocus ( ) } }
Column ( Modifier . fillMaxSize ( ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 12 . dp ) ) {
Text ( " Who watches? " , color = Color . White , fontFamily = Anton , fontSize = 32 . sp )
Text ( " Set a PIN for Grown-ups, and keep a Kids profile if there are little ones about. " , color = Muted , fontSize = 15 . sp )
if ( pinStep > 0 ) {
if ( pinStep == 1 ) key ( 1 ) { PinEntry ( " Choose a four-digit PIN " , null , onDone = { first = it ; pinStep = 2 } , onCancel = { pinStep = 0 } ) }
else key ( 2 ) { PinEntry ( " Enter it again " , null , onDone = { pin ->
if ( pin == first ) { if ( ! Onboarding . tour ) AfterDarkLock . set ( context , pin ) ; note = if ( Onboarding . tour ) " In the tour the PIN isn't saved. " else " PIN set. " ; pinStep = 0 }
else { note = " Those didn't match. Choose it again. " ; pinStep = 1 }
} , onCancel = { pinStep = 0 } ) }
return @Column
}
Row ( horizontalArrangement = Arrangement . spacedBy ( 28 . dp ) , modifier = Modifier . padding ( top = 10 . dp ) ) {
Column ( Modifier . width ( 280 . dp ) . background ( Panel , RoundedCornerShape ( 14 . dp ) ) . padding ( 18 . dp ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 8 . dp ) ) {
ProfileAvatar ( " sil-blue " , 90 . dp )
Text ( " Grown-ups " , color = Color . White , fontSize = 20 . sp , fontWeight = FontWeight . SemiBold )
Text ( " Everything, including After Dark " , color = Muted , fontSize = 13 . sp )
OrbitButton ( if ( AfterDarkLock . pinSet ) " PIN set: change it " else " Set a PIN " , Modifier . focusRequester ( go ) , onClick = { note = null ; pinStep = 1 } )
}
Column ( Modifier . width ( 280 . dp ) . background ( Panel , RoundedCornerShape ( 14 . dp ) ) . padding ( 18 . dp ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 8 . dp ) ) {
ProfileAvatar ( " ollie " , 90 . dp )
Text ( " Kids " , color = Color . White , fontSize = 20 . sp , fontWeight = FontWeight . SemiBold )
Text ( " Tots, cartoons and the Arcade " , color = Muted , fontSize = 13 . sp )
OrbitButton ( if ( Profiles . kidsOn ) " Kids: On " else " Kids: Off " , selected = Profiles . kidsOn , onClick = { if ( ! Onboarding . tour ) Profiles . setKidsOn ( context , ! Profiles . kidsOn ) } )
}
}
note ?. let { Text ( it , color = Yellow , fontSize = 14 . sp ) }
OrbitButton ( " Next " , Modifier . padding ( top = 8 . dp ) . width ( 200 . dp ) , onClick = onNext )
Text ( " You can skip this: with no PIN, Grown-ups opens without one. Change both in Settings › Profiles. " , color = Muted , fontSize = 12 . sp )
}
}
@Composable
private fun AllSetStep ( onStart : ( ) -> Unit ) {
val calm = reduceMotion ( ) ; val t = clock ( calm )
val go = remember { FocusRequester ( ) }
LaunchedEffect ( Unit ) { go . requestFocus ( ) }
val count = ChannelSetup . found . count { it . value > 0 }
Box ( Modifier . fillMaxSize ( ) ) {
// Stars streaming past to the left, while the cast float gently right: a journey through the cosmos.
Canvas ( Modifier . fillMaxSize ( ) ) {
val stars = java . util . Random ( 9 )
repeat ( 40 ) {
val speed = .05f + stars . nextFloat ( ) * .12f
val x = ( ( stars . nextFloat ( ) * size . width - ( if ( calm ) 0f else t * speed ) ) % size . width + size . width ) % size . width
val y = stars . nextFloat ( ) * size . height * .7f
drawLine ( Color ( 0xFF46608C ) , Offset ( x , y ) , Offset ( x + 20f + speed * 300f , y ) , 2f )
}
val drift = if ( calm ) 0f else ( ( t / 40f ) % ( size . width * .1f ) ) - size . width * .05f
val float = if ( calm ) 0f else sin ( t / 900.0 ) . toFloat ( ) * size . height * .015f
val c = Offset ( size . width / 2 + drift * .3f , size . height * .36f + float )
val r = size . minDimension * .1f
drawOrbChaser ( c + Offset ( - r * 2.9f , r * .5f ) , r * .6f , OrbCharacter . Blaze , OrbDir . Right , false , false , false , t / 140f )
drawOrbChaser ( c + Offset ( - r * 1.6f , - r * .9f ) , r * .5f , OrbCharacter . Zip , OrbDir . Right , false , false , false , 0f )
drawOrbChaser ( c + Offset ( r * 1.6f , - r * .9f ) , r * .52f , OrbCharacter . Drift , OrbDir . Right , false , false , false , t / 500f )
drawOrbChaser ( c + Offset ( r * 2.9f , r * .5f ) , r * .55f , OrbCharacter . Luna , OrbDir . Right , false , false , false , 0f )
drawOrbBee ( c + Offset ( r * 1.8f , r * 1.6f ) , r * .45f )
drawOrbMascot ( c , r , blink = ! calm && ( t % 3_400 ) < 150 )
}
Column ( Modifier . align ( Alignment . BottomCenter ) . padding ( bottom = 30 . dp ) , horizontalAlignment = Alignment . CenterHorizontally , verticalArrangement = Arrangement . spacedBy ( 10 . dp ) ) {
Text ( " You're on air " , color = Color . White , fontFamily = Anton , fontSize = 44 . sp )
Text ( if ( count > 0 ) " $count channels are planning their first week. The Drive-In opens on Friday. " else " Your channels are ready. " , color = Muted , fontSize = 16 . sp )
OrbitButton ( " Start watching " , Modifier . width ( 240 . dp ) . focusRequester ( go ) , onClick = onStart )
}
}
}