From 3b9188b3ea92bfe6512d4befed5ceea55fb57c0e Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 23:37:52 +0000 Subject: [PATCH] First launch for a new user, on Emby (from 0.108.35) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/ArcadeOrbMan.kt | 9 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 30 ++ .../java/tv/orbit/classic/MainActivity.kt | 21 +- .../main/java/tv/orbit/classic/Onboarding.kt | 353 ++++++++++++++++++ .../main/java/tv/orbit/classic/Profiles.kt | 9 +- .../main/java/tv/orbit/classic/Settings.kt | 3 + .../java/tv/orbit/classic/OnboardingTest.kt | 30 ++ 7 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/Onboarding.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/OnboardingTest.kt diff --git a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt index cdc6bd8..ccc104a 100644 --- a/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt +++ b/android/app/src/main/java/tv/orbit/classic/ArcadeOrbMan.kt @@ -237,7 +237,7 @@ private val Mascot = Color(0xFFF7A928) private val Outline = Color(0xFF14213D) /** Ollie, the Tots mascot: a smiling orange planet in its ring, looking where it's going. [chomp] opens its smile, 0 to 1. */ -internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = OrbDir.Right, chomp: Float = 0f) { +internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = OrbDir.Right, chomp: Float = 0f, blink: Boolean = false) { // The ring behind, then the planet, then the ring's front. val ringW = r * 2.7f; val ringH = r * .9f val ringTop = Offset(centre.x - ringW / 2, centre.y - ringH / 2 + r * .15f) @@ -246,8 +246,11 @@ internal fun DrawScope.drawOrbMascot(centre: Offset, r: Float, dir: OrbDir = Orb drawCircle(Mascot, r, centre) drawCircle(Color.White.copy(alpha = .25f), r * .32f, centre + Offset(-r * .4f, -r * .45f)) val look = Offset(dir.dx * r * .16f, dir.dy * r * .14f) - drawCircle(Outline, r * .13f, centre + Offset(-r * .33f, -r * .2f) + look) - drawCircle(Outline, r * .13f, centre + Offset(r * .33f, -r * .2f) + look) + // Eyes, or a blink: the same place, closed to a line. + for (side in listOf(-1f, 1f)) { + val eye = centre + Offset(side * r * .33f, -r * .2f) + look + if (blink) drawLine(Outline, eye - Offset(r * .13f, 0f), eye + Offset(r * .13f, 0f), r * .07f, StrokeCap.Round) else drawCircle(Outline, r * .13f, eye) + } val mouth = r * (.42f + .2f * chomp) drawArc(Outline, 15f, 150f, chomp > .5f, Offset(centre.x - mouth + look.x, centre.y - mouth * .55f + look.y), Size(mouth * 2, mouth * (1.1f + chomp * .5f)), style = Stroke(r * .14f, cap = StrokeCap.Round)) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 098c871..8c31bf2 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -56,6 +56,8 @@ private const val RESTORE_QUIET_TRIES = 4 @Composable internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean, + /** The first-launch flow is showing: it draws everything once signed in, so this screen draws nothing. */ + onboarding: Boolean = false, onSignedIn: () -> Unit = {}, mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, mainWhatsOn: Boolean = false, mainCatchUp: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}) { val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext @@ -244,6 +246,33 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } LaunchedEffect(scheduledChannels) { windingDown = scheduledChannels[TOTS_NUMBER]?.stepIn?.let { it.endMs > System.currentTimeMillis() } == true } fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(channel, afterDarkChannel, toonamiChannel)).firstOrNull { it.number == programming.number } + LaunchedEffect(session != null) { if (session != null) onSignedIn() } + // The first-launch flow's channels, one after another, as switching each on in Settings does. In the tour, it only counts. + LaunchedEffect(ChannelSetup.wanted, session) { + val active = session ?: return@LaunchedEffect + for (number in ChannelSetup.wanted) { + if (number in ChannelSetup.found) continue + val programming = SmartChannels.firstOrNull { it.number == number } ?: continue + ChannelSetup.current = number + val titles = try { + withContext(Dispatchers.IO) { + if (Onboarding.tour) scheduleStore.loadPool(active.accountId, number)?.series?.size ?: 0 + else { + val context = coroutineContext + scheduler.setEnabled(active.accountId, number, true) + val loop = smartLoop(programming) ?: scheduler.seedLoop(active, active.accountId, programming) { context.ensureActive() }.also { extraChannels = extraChannels + it } + scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also { + scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (number to on) } + }.shows.size + } + } + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { if (!Onboarding.tour) withContext(Dispatchers.IO) { runCatching { scheduler.setEnabled(active.accountId, number, false) } }; -1 } + ChannelSetup.found = ChannelSetup.found + (number to titles) + if (titles > 0 && !Onboarding.tour) onChannelAvailable(true) + } + ChannelSetup.current = null + } // Keeps smart channels topped up in the background. A failure changes nothing on air. LaunchedEffect(session, extraChannels, channel, afterDarkChannel, toonamiChannel) { val active = session ?: return@LaunchedEffect @@ -421,6 +450,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea return } if (!visible) return + if (onboarding && session != null) return fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) { val active = session ?: return val existing = smartLoop(programming) diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 2903b08..74b0082 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -123,7 +123,19 @@ private fun OrbitApp() { // Who's watching? comes first, once there's something to watch. val profile = Profiles.current val chosen = profile != null - val picking = realChannelAvailable && restoreChecked && !chosen + // A box that has never been signed in gets the first-launch flow, once. + val context = androidx.compose.ui.platform.LocalContext.current + LaunchedEffect(restoreChecked, realChannelAvailable) { + if (realChannelAvailable) { if (Onboarding.step == null) Onboarding.markDone(context) } + else if (restoreChecked && Onboarding.step == null && !Onboarding.done(context) && + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { RememberedConnectionStore.get(context).load() } == null) Onboarding.start(tour = false) + } + val onboarding = Onboarding.step != null + // With no Kids profile there's no one to ask: Grown-ups goes straight in. + LaunchedEffect(realChannelAvailable, profile, Profiles.kidsOn, onboarding) { + if (realChannelAvailable && profile == null && !Profiles.kidsOn && !onboarding) Profiles.current = Profiles.all.first { it.kind == ProfileKind.Grown } + } + val picking = realChannelAvailable && restoreChecked && !chosen && Profiles.kidsOn && !onboarding val kidsDriveIn = profile?.kind != ProfileKind.Kids || profile.driveIn val kidsArcade = profile?.kind != ProfileKind.Kids || profile.arcade // Kids never reaches Settings, and only the parts of Orbit chosen for it. @@ -148,6 +160,7 @@ private fun OrbitApp() { if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp) if (section == "Arcade" && realChannelAvailable && chosen) OrbitArcade(Modifier.weight(1f), onPlaying = { playingGame = it }) EmbyConnectionScreen(if (section == "Arcade") Modifier else Modifier.weight(1f), visible = section == "Connect Emby", + onboarding = onboarding, onSignedIn = { if (Onboarding.step == OnboardStep.SignIn) Onboarding.go(OnboardStep.Channels) }, mainGuide = section == "TV Guide" && realChannelAvailable && chosen, mainHome = section == "Home" && realChannelAvailable && chosen, mainDriveIn = section == "Drive-In" && realChannelAvailable && chosen, @@ -158,7 +171,11 @@ private fun OrbitApp() { } // The sections sit centred on the screen, over the centred page below; the logo keeps the left, the clock the right. if (picking) WhosWatching(onClose = { activity?.finish() }) - if (!playingGame && !picking) Box(Modifier.fillMaxWidth().padding(start = 36.dp, end = 36.dp, top = 12.dp)) { + // At the end of the flow the one who set Orbit up starts watching as Grown-ups; the tour goes back to Settings. + if (onboarding) OnboardingScreen(signedIn = realChannelAvailable) { tour -> + if (!tour) { section = if (realChannelAvailable) "Home" else "Connect Emby"; Profiles.current = Profiles.all.first { it.kind == ProfileKind.Grown } } + } + if (!playingGame && !picking && !onboarding) Box(Modifier.fillMaxWidth().padding(start = 36.dp, end = 36.dp, top = 12.dp)) { if (realChannelAvailable) Row(Modifier.align(Alignment.Center), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { // The whole-screen Drive-In has nothing for down to find by itself, so it's told. val below = if (section == "Drive-In") driveInFocus else null diff --git a/android/app/src/main/java/tv/orbit/classic/Onboarding.kt b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt new file mode 100644 index 0000000..c73b150 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Onboarding.kt @@ -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(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>(emptyList()) + var current by mutableStateOf(null) + /** Titles each channel found, by number; -1 when it couldn't be set up. */ + var found by mutableStateOf>(emptyMap()) + var startedAt by mutableLongStateOf(0L) + val finished get() = wanted.isNotEmpty() && wanted.all { it in found } + fun begin(numbers: List) { 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(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(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) + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/Profiles.kt b/android/app/src/main/java/tv/orbit/classic/Profiles.kt index 2e1db41..61a1a34 100644 --- a/android/app/src/main/java/tv/orbit/classic/Profiles.kt +++ b/android/app/src/main/java/tv/orbit/classic/Profiles.kt @@ -65,9 +65,16 @@ internal object Profiles { /** Who's watching, or null before anyone's chosen. */ var current by mutableStateOf(null) val kids get() = current?.kind == ProfileKind.Kids + /** A Kids profile is in use, so Orbit asks who's watching; without one, Grown-ups goes straight in. */ + var kidsOn by mutableStateOf(true) + private set + fun setKidsOn(context: Context, on: Boolean) { kidsOn = on; prefs(context).edit().putBoolean("kids_on", on).apply() } private fun prefs(context: Context) = context.getSharedPreferences("orbit_profiles", Context.MODE_PRIVATE) - fun load(context: Context) { all = runCatching { decode(prefs(context).getString("profiles", null)!!) }.getOrDefault(DefaultProfiles) } + fun load(context: Context) { + all = runCatching { decode(prefs(context).getString("profiles", null)!!) }.getOrDefault(DefaultProfiles) + kidsOn = prefs(context).getBoolean("kids_on", true) + } fun update(context: Context, changed: Profile) { all = all.map { if (it.id == changed.id) changed else it } if (current?.id == changed.id) current = changed diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index e689391..07ff27e 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -253,6 +253,9 @@ private fun AccountDetail(server: String, titles: Long?, onSignOut: () -> Unit, } } } else SettingLine("Sign out", "Disconnect this box from Emby.") { OrbitButton("Sign out", Modifier.focusRequester(firstSwitch), onClick = { confirming = true }) } + SettingLine("Welcome tour", "See the first-launch steps a new box goes through, from Ollie's welcome to You're on air. Nothing is changed by it.") { + OrbitButton("Show it", onClick = { Onboarding.start(tour = true) }) + } Text("Orbit TV $version", color = Muted, fontSize = 12.sp) Text("Snake graphics by Bas de Reuver, bdrgames.nl", color = Muted, fontSize = 12.sp) } diff --git a/android/app/src/test/java/tv/orbit/classic/OnboardingTest.kt b/android/app/src/test/java/tv/orbit/classic/OnboardingTest.kt new file mode 100644 index 0000000..90a7528 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/OnboardingTest.kt @@ -0,0 +1,30 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Test + +class OnboardingTest { + @Test fun theStepsRunInTheDesignedOrder() { + assertEquals(listOf("Welcome", "Server", "Sign in", "Channels", "Who watches", "All set"), OnboardStep.entries.map { it.label }) + } + + @Test fun theStartingChannelsAreRealAndNeverAfterDark() { + val numbers = SmartChannels.map { it.number }.toSet() + assertTrue(OnboardStartChannels.all { it in numbers }) + assertTrue(OnboardStartChannels.none { it in LockedChannels }) + } + + @Test fun theTimeLeftComesFromHowLongTheChannelsSoFarTook() { + ChannelSetup.begin(listOf(101, 106, 201, 107)) + val start = ChannelSetup.startedAt + // Before any are done, a minute each. + assertEquals(4, ChannelSetup.minutesLeft(start)) + // Two done in four minutes: two left at two minutes each. + ChannelSetup.found = mapOf(101 to 180, 106 to 90) + assertEquals(4, ChannelSetup.minutesLeft(start + 4 * 60_000)) + assertFalse(ChannelSetup.finished) + ChannelSetup.found = ChannelSetup.found + mapOf(201 to 40, 107 to -1) + assertTrue(ChannelSetup.finished); assertEquals(0, ChannelSetup.minutesLeft(start + 9 * 60_000)) + ChannelSetup.begin(emptyList()) + } +}