First launch for a new user, on Emby (from 0.108.35)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m53s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m39s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:37:52 +00:00
co-authored by Claude Opus 5.5
parent e98e04baaa
commit 3b9188b3ea
7 changed files with 449 additions and 6 deletions
@@ -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))
@@ -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)
@@ -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
@@ -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)
}
}
}
@@ -65,9 +65,16 @@ internal object Profiles {
/** Who's watching, or null before anyone's chosen. */
var current by mutableStateOf<Profile?>(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
@@ -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)
}
@@ -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())
}
}