Profiles, Who's watching, and the starry sky (from 0.108.34); VectorCraft picture sources
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m0s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m33s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:08:52 +00:00
co-authored by Claude Opus 5.5
parent 5e6d730833
commit 8527e9daf6
25 changed files with 343 additions and 18 deletions
@@ -38,7 +38,8 @@ internal object AfterDarkLock {
/** Unlocked with the PIN, until Orbit closes. */
var unlocked by mutableStateOf(false)
private set
val locked get() = pinSet && !unlocked
/** Hidden: a PIN is set and it hasn't been entered, or Kids is watching. */
val locked get() = (pinSet && !unlocked) || Profiles.kids
fun load(context: Context) { pinSet = prefs(context).getString("after_dark_pin", null) != null }
private fun prefs(context: Context) = context.getSharedPreferences("orbit_lock", Context.MODE_PRIVATE)
@@ -333,6 +333,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
fun answered() { saveReminders(reminders.map { if (it.channel == due.channel && it.startMs == due.startMs) it.copy(stage = due.stage) else it }); programmeReminder = null }
ProgrammeReminderCard(due, System.currentTimeMillis(), onDismiss = ::answered, onWatch = {
answered()
// Not for a channel this profile can't see.
if (lockedOut(due.channel) || profileHides(due.channel)) return@ProgrammeReminderCard
if (selectedNumber != due.channel) { lastNumber = selectedNumber; selectedNumber = due.channel }
channelClockOffset = 0; watchingChannel = true
if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide()
@@ -354,10 +356,10 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
// The After Dark lock hides its channels from everything that lists or steps through channels, Catch Up included.
val availableChannels = availableSampleChannels(channel?.let(::onAir), afterDarkChannel?.let(::onAir), toonamiChannel?.let(::onAir), *extraChannels.map(::onAir).toTypedArray())
.filterNot { lockedOut(it.number) }
.filterNot { lockedOut(it.number) || profileHides(it.number) }
// Locked while on After Dark: back to the first channel, and out of full screen.
LaunchedEffect(AfterDarkLock.locked) {
if (AfterDarkLock.locked && lockedOut(selectedNumber)) { watchingChannel = false; channelClockOffset = 0; selectedNumber = availableChannels.firstOrNull()?.number ?: 101 }
LaunchedEffect(AfterDarkLock.locked, Profiles.current) {
if (lockedOut(selectedNumber) || profileHides(selectedNumber)) { watchingChannel = false; channelClockOffset = 0; selectedNumber = availableChannels.firstOrNull()?.number ?: 101 }
}
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
if (mainDriveIn && liveSession != null) {
@@ -409,7 +411,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
when {
mainHome -> HomeScreen(availableChannels, modifier, onCategory = { guideCategory = it; onGuide() })
mainCatchUp -> CatchUpScreen(availableChannels, modifier, onOpen = { catchUpOpen = it })
mainWhatsOn -> WhatsOnScreen(liveSession, scheduledChannels.filterKeys { !lockedOut(it) }.mapValues { it.value.plan }, driveInPlan, modifier, reminders,
mainWhatsOn -> WhatsOnScreen(liveSession, scheduledChannels.filterKeys { !lockedOut(it) && !profileHides(it) }.mapValues { it.value.plan },
driveInPlan.takeIf { Profiles.current?.let { it.kind != ProfileKind.Kids || it.driveIn } ?: true }, modifier, reminders,
onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, onBack = onHome,
onRemind = { item -> saveReminders(liveReminders(reminders, System.currentTimeMillis()) + ProgrammeReminder(item.channel, item.startMs, item.title)) })
else -> GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory, homeCategories(availableChannels.map { it.number }), picture,
@@ -60,6 +60,7 @@ class MainActivity : ComponentActivity() {
}
// Before anything is drawn, so a locked After Dark never shows for a moment.
AfterDarkLock.load(this)
Profiles.load(this)
// One typeface everywhere, matching the channel logos.
setContent { MaterialTheme { CompositionLocalProvider(androidx.tv.material3.LocalTextStyle provides androidx.compose.ui.text.TextStyle(fontFamily = Barlow)) { OrbitApp() } } }
}
@@ -109,7 +110,8 @@ private fun OrbitApp() {
// Opening the app always lands on the main menu, wherever it was left.
if (event == Lifecycle.Event.ON_STOP && realChannelAvailable && section != "Connect Emby") section = "Home"
// The sleep timer is for this evening's viewing only.
if (event == Lifecycle.Event.ON_STOP) { SleepTimer.clear(); AfterDarkLock.lock() }
// Orbit asks who's watching again each time it opens.
if (event == Lifecycle.Event.ON_STOP) { SleepTimer.clear(); AfterDarkLock.lock(); Profiles.current = null }
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
@@ -118,6 +120,16 @@ private fun OrbitApp() {
BackHandler(realChannelAvailable && section != "TV Guide" && section != "Home" && section != "What's On") { section = "Home" }
val navFocus = remember { FocusRequester() }
val driveInFocus = remember { FocusRequester() }
// Who's watching? comes first, once there's something to watch.
val profile = Profiles.current
val chosen = profile != null
val picking = realChannelAvailable && restoreChecked && !chosen
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.
LaunchedEffect(profile, section) {
if (Profiles.kids && realChannelAvailable && (section == "Connect Emby" || (section == "Drive-In" && !kidsDriveIn) || (section == "Arcade" && !kidsArcade))) section = "Home"
}
// The sleep timer switches Orbit off wherever you are in it; the TV then sleeps as it normally would.
val activity = androidx.activity.compose.LocalActivity.current
LaunchedEffect(Unit) { while (true) { delay(1000); if (SleepTimer.tick(System.currentTimeMillis())) { SleepTimer.clear(); activity?.finish() } } }
@@ -127,23 +139,26 @@ private fun OrbitApp() {
if (event.nativeKeyEvent.action == android.view.KeyEvent.ACTION_UP) SleepTimer.keepWatching(System.currentTimeMillis())
true
}) {
// The soft stars behind everything (Paul: "that slight starry background works across the app and identity").
StarrySky()
CompositionLocalProvider(LocalNavFocus provides navFocus, LocalDriveInFocus provides driveInFocus) {
// The Drive-In and a game in play take the whole screen; the bar sits over the Drive-In's sky.
val fullBleed = section == "Drive-In" || playingGame
Column(if (fullBleed) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 56.dp, bottom = 16.dp)) {
if (!restoreChecked) Text("Tuning in…", color = Muted, fontSize = 15.sp)
if (section == "Arcade" && realChannelAvailable) OrbitArcade(Modifier.weight(1f), onPlaying = { playingGame = it })
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",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
mainDriveIn = section == "Drive-In" && realChannelAvailable,
mainWhatsOn = section == "What's On" && realChannelAvailable,
mainCatchUp = section == "Catch Up" && realChannelAvailable,
mainGuide = section == "TV Guide" && realChannelAvailable && chosen,
mainHome = section == "Home" && realChannelAvailable && chosen,
mainDriveIn = section == "Drive-In" && realChannelAvailable && chosen,
mainWhatsOn = section == "What's On" && realChannelAvailable && chosen,
mainCatchUp = section == "Catch Up" && realChannelAvailable && chosen,
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" })
}
// The sections sit centred on the screen, over the centred page below; the logo keeps the left, the clock the right.
if (!playingGame) Box(Modifier.fillMaxWidth().padding(start = 36.dp, end = 36.dp, top = 12.dp)) {
if (picking) WhosWatching(onClose = { activity?.finish() })
if (!playingGame && !picking) 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
@@ -151,8 +166,8 @@ private fun OrbitApp() {
NavItem("What's On", section == "What's On", null, below) { section = "What's On" }
NavItem("TV Guide", section == "TV Guide", null, below) { section = "TV Guide" }
NavItem("Catch Up", section == "Catch Up", null, below) { section = "Catch Up" }
NavItem("Drive-In", section == "Drive-In", null, below) { section = "Drive-In" }
NavItem("Arcade", section == "Arcade", null, below) { section = "Arcade" }
if (kidsDriveIn) NavItem("Drive-In", section == "Drive-In", null, below) { section = "Drive-In" }
if (kidsArcade) NavItem("Arcade", section == "Arcade", null, below) { section = "Arcade" }
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(144.dp).height(36.dp))
@@ -161,7 +176,11 @@ private fun OrbitApp() {
Text(SimpleDateFormat("HH:mm", Locale.UK).format(Date(now)), color = Color.White, fontFamily = BarlowCondensed, fontSize = 20.sp)
Text(SimpleDateFormat("EEEE d MMMM", Locale.UK).format(Date(now)), color = Muted, fontSize = 10.sp)
}
if (realChannelAvailable) HomeCard(Modifier.size(38.dp, 32.dp).focusProperties { if (section == "Drive-In") down = driveInFocus }, onClick = { section = "Connect Emby" }) { focused ->
// Who's watching, as a little picture: OK goes back to Who's watching? to switch.
if (realChannelAvailable && profile != null) HomeCard(Modifier.size(38.dp, 32.dp).focusProperties { if (section == "Drive-In") down = driveInFocus }, onClick = { section = "Home"; Profiles.current = null }) { focused ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ProfileAvatar(profile.picture, 28.dp, ringed = false) }
}
if (realChannelAvailable && !Profiles.kids) HomeCard(Modifier.size(38.dp, 32.dp).focusProperties { if (section == "Drive-In") down = driveInFocus }, onClick = { section = "Connect Emby" }) { focused ->
Image(painterResource(R.drawable.orbit_icon_gear), "Settings", Modifier.fillMaxSize().padding(5.dp), colorFilter = if (focused) ColorFilter.tint(Navy) else null)
}
}
@@ -0,0 +1,262 @@
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import org.json.JSONArray
import org.json.JSONObject
/**
* Profiles (Paul, 5 November 2026, from the approved design). "Who's watching?" when Orbit opens: Grown-ups, behind the PIN
* from the After Dark lock, sees everything; Kids sees only the channels ticked for it, never After Dark or The Late Show, has
* no Settings, and leaving it for Grown-ups needs the PIN. Each has a picture: one of the cast, a favourite thing (drawn in
* VectorCraft, design/profiles/pictures) or a silhouette in a colour. Kept on this box only. Orbit asks again each time it opens.
*/
internal enum class ProfileKind { Grown, Kids }
internal data class Profile(val id: String, val name: String, val picture: String, val kind: ProfileKind,
/** Kids only: the channels it sees. */
val channels: Set<Int> = emptySet(), val arcade: Boolean = true, val driveIn: Boolean = false)
/** Kids' channels to start with, from the approved design: Tots, Junior, Wonder, PixelZ, ToonZ, Heroes, Rewind, Vortex, Terra, Wild. */
internal val KidsStartChannels = setOf(TOTS_NUMBER, 611, 612, 613, 605, 602, 604, VORTEX_NUMBER, 201, 203)
internal val DefaultProfiles = listOf(Profile("grown", "Grown-ups", "sil-blue", ProfileKind.Grown), Profile("kids", "Kids", "ollie", ProfileKind.Kids, KidsStartChannels))
/** Every picture there is to choose: the cast, favourite things, and silhouettes in Orbit's colours. */
internal val ProfilePictures = listOf("ollie", "blaze", "zip", "drift", "luna", "bumble", "football", "wand", "rocket", "crown", "rainbow", "paw",
"sil-blue", "sil-pink", "sil-green", "sil-yellow", "sil-purple", "sil-orange")
internal fun pictureName(id: String) = when (id) {
"wand" -> "Fairy wand"; "paw" -> "Paw print"
else -> id.removePrefix("sil-").replaceFirstChar { it.uppercase() }
}
internal object Profiles {
var all by mutableStateOf(DefaultProfiles)
private set
/** Who's watching, or null before anyone's chosen. */
var current by mutableStateOf<Profile?>(null)
val kids get() = current?.kind == ProfileKind.Kids
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 update(context: Context, changed: Profile) {
all = all.map { if (it.id == changed.id) changed else it }
if (current?.id == changed.id) current = changed
prefs(context).edit().putString("profiles", encode(all)).apply()
}
fun encode(list: List<Profile>) = JSONArray(list.map { p ->
JSONObject().put("id", p.id).put("name", p.name).put("picture", p.picture).put("kind", p.kind.name)
.put("channels", JSONArray(p.channels.sorted())).put("arcade", p.arcade).put("driveIn", p.driveIn)
}).toString()
fun decode(text: String): List<Profile> {
val a = JSONArray(text)
return (0 until a.length()).map { i ->
val o = a.getJSONObject(i); val ch = o.optJSONArray("channels")
Profile(o.getString("id"), o.getString("name"), o.getString("picture"), ProfileKind.valueOf(o.getString("kind")),
ch?.let { c -> (0 until c.length()).map { c.getInt(it) }.toSet() }.orEmpty(), o.optBoolean("arcade", true), o.optBoolean("driveIn", false))
}.takeIf { list -> list.any { it.kind == ProfileKind.Grown } } ?: DefaultProfiles
}
}
/** Hidden from the profile watching: for Kids, every channel not ticked for it. */
internal fun profileHides(number: Int, profile: Profile? = Profiles.current) = profile?.kind == ProfileKind.Kids && number !in profile.channels
private val SilColours = mapOf("sil-blue" to Color(0xFF45B8F5), "sil-pink" to Color(0xFFFF4FA3), "sil-green" to Color(0xFF6EDCAA),
"sil-yellow" to Color(0xFFFFD84A), "sil-purple" to Color(0xFFA77BFF), "sil-orange" to Color(0xFFFF8A3D))
private val PictureBacks = mapOf("ollie" to Color(0xFF285AA0), "blaze" to Color(0xFF3C285A), "zip" to Color(0xFF322864), "drift" to Color(0xFF1E4678),
"luna" to Color(0xFF281E5A), "bumble" to Color(0xFF285A50), "football" to Color(0xFF2E8C50), "wand" to Color(0xFF783CA0), "rocket" to Color(0xFF1E326E),
"crown" to Color(0xFF782850), "rainbow" to Color(0xFF3C8CDC), "paw" to Color(0xFFC8783C))
private val PictureImages = mapOf("football" to R.drawable.orbit_picture_football, "wand" to R.drawable.orbit_picture_wand, "rocket" to R.drawable.orbit_picture_rocket,
"crown" to R.drawable.orbit_picture_crown, "rainbow" to R.drawable.orbit_picture_rainbow, "paw" to R.drawable.orbit_picture_paw)
private fun DrawScope.silhouette(colour: Color) {
val r = size.minDimension / 2; val c = center
drawCircle(colour, r * .34f, c + Offset(0f, -r * .22f))
drawArc(colour, 180f, 180f, true, c + Offset(-r * .62f, r * .12f), Size(r * 1.24f, r * 1.08f))
}
/** A profile's picture in its circle, ringed gold when [ringed]. */
@Composable
internal fun ProfileAvatar(picture: String, diameter: Dp, modifier: Modifier = Modifier, ringed: Boolean = false) {
val sil = SilColours[picture]
Box(modifier.size(diameter).then(if (ringed) Modifier.border(4.dp, Yellow, CircleShape) else Modifier).padding(if (ringed) 6.dp else 0.dp)
.clip(CircleShape).background(sil?.copy(alpha = .35f)?.compositeOverNavy() ?: PictureBacks[picture] ?: Panel), contentAlignment = Alignment.Center) {
val image = PictureImages[picture]
if (image != null) Image(painterResource(image), pictureName(picture), Modifier.fillMaxSize())
else Canvas(Modifier.fillMaxSize()) {
val r = size.minDimension / 2
when (picture) {
"ollie" -> drawOrbMascot(center, r * .5f)
"blaze" -> drawOrbChaser(center + Offset(r * .12f, 0f), r * .55f, OrbCharacter.Blaze, OrbDir.Right, false, false, false, 0f)
"zip" -> drawOrbChaser(center, r * .55f, OrbCharacter.Zip, OrbDir.Down, false, false, false, 0f)
"drift" -> drawOrbChaser(center, r * .58f, OrbCharacter.Drift, OrbDir.Down, false, false, false, 0f)
"luna" -> drawOrbChaser(center, r * .58f, OrbCharacter.Luna, OrbDir.Down, false, false, false, 0f)
"bumble" -> drawOrbBee(center + Offset(0f, r * .12f), r * .5f)
else -> silhouette(sil ?: Color(0xFF45B8F5))
}
}
}
}
private fun Color.compositeOverNavy() = Color(red * alpha + Navy.red * (1 - alpha), green * alpha + Navy.green * (1 - alpha), blue * alpha + Navy.blue * (1 - alpha))
/** A soft field of stars, still, for behind the app (Paul: "that slight starry background works across the app and identity"). */
@Composable
internal fun StarrySky(modifier: Modifier = Modifier) {
Canvas(modifier.fillMaxSize()) {
val stars = java.util.Random(5)
repeat(140) {
drawCircle(Color(0xFF3C5078).copy(alpha = .55f + stars.nextFloat() * .45f), .8f + stars.nextFloat() * 1.6f, Offset(stars.nextFloat() * size.width, stars.nextFloat() * size.height))
}
}
}
/** "Who's watching?" Grown-ups asks for the PIN, when one's set; Kids goes straight in. */
@Composable
internal fun WhosWatching(onClose: () -> Unit) {
val context = LocalContext.current
var asking by remember { mutableStateOf<Profile?>(null) }
var problem by remember { mutableStateOf<String?>(null) }
val first = remember { FocusRequester() }
LaunchedEffect(asking) { if (asking == null) runCatching { first.requestFocus() } }
BackHandler(asking == null, onBack = onClose)
fun choose(p: Profile) {
if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) { asking = p; problem = null }
else { if (p.kind == ProfileKind.Kids) AfterDarkLock.lock(); Profiles.current = p }
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(28.dp)) {
Image(painterResource(R.drawable.orbit_logo), "Orbit", Modifier.width(150.dp).height(38.dp))
Text("Who's watching?", color = Color.White, fontFamily = Anton, fontSize = 40.sp)
Row(horizontalArrangement = Arrangement.spacedBy(56.dp)) {
// Kids first, so a child's OK goes straight to their own.
Profiles.all.sortedBy { if (it.kind == ProfileKind.Kids) 0 else 1 }.forEachIndexed { i, p ->
var focused by remember { mutableStateOf(false) }
Column(Modifier.then(if (i == 0) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused }.clickable { choose(p) },
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box {
ProfileAvatar(p.picture, 132.dp, ringed = focused)
if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) Canvas(Modifier.align(Alignment.BottomEnd).size(28.dp)) {
val s = size.minDimension
drawRoundRect(Yellow, Offset(s * .15f, s * .45f), Size(s * .7f, s * .5f), androidx.compose.ui.geometry.CornerRadius(s * .08f))
drawArc(Yellow, 180f, 180f, false, Offset(s * .27f, s * .12f), Size(s * .46f, s * .62f), style = androidx.compose.ui.graphics.drawscope.Stroke(s * .1f))
}
}
Text(p.name, color = if (focused) Yellow else Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) Text("PIN", color = Muted, fontSize = 13.sp)
}
}
}
Text("Kids sees only the channels and games chosen for it." + if (AfterDarkLock.pinSet) " Grown-ups needs the PIN." else " Set a PIN in Settings › Profiles to keep Grown-ups for grown-ups.",
color = Muted, fontSize = 15.sp)
}
asking?.let { p ->
Box(Modifier.fillMaxSize().background(Navy.copy(alpha = .85f)), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
ProfileAvatar(p.picture, 90.dp)
Text(p.name, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
PinEntry("Enter the PIN", problem, onDone = { pin ->
if (AfterDarkLock.unlock(context, pin)) { asking = null; Profiles.current = p } else problem = "That's not the PIN"
}, onCancel = { asking = null })
}
}
}
}
}
/** Settings › Profiles: the PIN, each profile's picture, and what Kids can see and play. */
@Composable
internal fun ProfilesDetail(firstSwitch: FocusRequester, modifier: Modifier) {
val context = LocalContext.current
var choosingFor by remember { mutableStateOf<Profile?>(null) }
val kids = Profiles.all.firstOrNull { it.kind == ProfileKind.Kids }
val grown = Profiles.all.first { it.kind == ProfileKind.Grown }
choosingFor?.let { p ->
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
BackHandler { choosingFor = null }
Text("Choose a picture for ${p.name}", color = Yellow, fontFamily = Anton, fontSize = 22.sp)
Text("The cast, a few favourite things, or a silhouette in a colour of your own.", color = Muted, fontSize = 12.sp)
val first = remember { FocusRequester() }
LaunchedEffect(Unit) { first.requestFocus() }
LazyVerticalGrid(GridCells.Fixed(6), Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(ProfilePictures) { pic ->
var focused by remember { mutableStateOf(false) }
Column(Modifier.then(if (pic == p.picture) Modifier.focusRequester(first) else Modifier).onFocusChanged { focused = it.isFocused }
.clickable { Profiles.update(context, p.copy(picture = pic)); choosingFor = null }, horizontalAlignment = Alignment.CenterHorizontally) {
ProfileAvatar(pic, 64.dp, ringed = focused)
Text(pictureName(pic) + if (pic == p.picture) " (now)" else "", color = if (focused) Yellow else Color.White, fontSize = 11.sp, maxLines = 1)
}
}
}
}
return
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ProfileAvatar(grown.picture, 48.dp)
Text(grown.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
OrbitButton("Change picture", Modifier.focusRequester(firstSwitch), onClick = { choosingFor = grown })
}
// The PIN: what keeps Grown-ups, After Dark and The Late Show for grown-ups.
LockDetail(remember { FocusRequester() }, Modifier.fillMaxWidth())
if (kids != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 6.dp)) {
ProfileAvatar(kids.picture, 48.dp)
Text(kids.name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
OrbitButton("Change picture", onClick = { choosingFor = kids })
OrbitButton("Arcade: ${if (kids.arcade) "On" else "Off"}", selected = kids.arcade, onClick = { Profiles.update(context, kids.copy(arcade = !kids.arcade)) })
OrbitButton("Drive-In: ${if (kids.driveIn) "On" else "Off"}", selected = kids.driveIn, onClick = { Profiles.update(context, kids.copy(driveIn = !kids.driveIn)) })
}
Text("CHANNELS FOR KIDS", color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
val channels = SmartChannels.sortedBy { it.number }.filter { it.number !in LockedChannels }
LazyVerticalGrid(GridCells.Fixed(4), Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(channels, key = { it.number }) { programming ->
val on = programming.number in kids.channels
var focused by remember { mutableStateOf(false) }
val corner = RoundedCornerShape(8.dp)
Row(Modifier.height(44.dp).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 { Profiles.update(context, kids.copy(channels = if (on) kids.channels - programming.number else kids.channels + 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(22.dp).background(if (on) Color(0xFF6EDCAA) else Color(0xFF283C64), RoundedCornerShape(5.dp)), contentAlignment = Alignment.Center) {
if (on) Text("✓", color = Navy, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
}
}
Text("Kids never sees After Dark or The Late Show. What's On and Catch Up show only Kids' channels. Leaving Kids needs the PIN.", color = Muted, fontSize = 11.sp)
}
}
}
@@ -111,7 +111,7 @@ internal enum class SettingsSection(val label: String, val icon: Int) {
Viewing("Watching", R.drawable.orbit_icon_entertainment),
Arrivals("New arrivals", R.drawable.orbit_icon_movies),
Director("Director's Chair", R.drawable.orbit_icon_entertainment),
Lock("After Dark lock", R.drawable.orbit_icon_profile),
Profiles("Profiles", R.drawable.orbit_icon_profile),
Account("Emby account", R.drawable.orbit_icon_profile),
}
@@ -287,7 +287,7 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio
SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, firstSwitch, detail)
SettingsSection.Arrivals -> arrivals(detail, firstSwitch)
SettingsSection.Director -> director(detail, firstSwitch)
SettingsSection.Lock -> LockDetail(firstSwitch, detail)
SettingsSection.Profiles -> ProfilesDetail(firstSwitch, detail)
SettingsSection.Account -> AccountDetail(server, titles, onSignOut, firstSwitch, detail)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,34 @@
package tv.orbit.classic
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
class ProfilesTest {
@After fun reset() { Profiles.current = null }
@Test fun profilesSurviveBeingSavedAndRead() {
val changed = DefaultProfiles.map { if (it.kind == ProfileKind.Kids) it.copy(picture = "wand", channels = setOf(TOTS_NUMBER, 611), arcade = false, driveIn = true) else it }
assertEquals(changed, Profiles.decode(Profiles.encode(changed)))
// A saved list without Grown-ups is never used: Orbit goes back to the two it starts with.
assertEquals(DefaultProfiles, Profiles.decode(Profiles.encode(changed.filter { it.kind == ProfileKind.Kids })))
}
@Test fun kidsSeesOnlyItsChannelsAndNeverAfterDark() {
val kids = DefaultProfiles.first { it.kind == ProfileKind.Kids }
val grown = DefaultProfiles.first { it.kind == ProfileKind.Grown }
assertFalse(profileHides(TOTS_NUMBER, kids)); assertTrue(profileHides(304, kids)); assertTrue(profileHides(AFTER_DARK_NUMBER, kids))
assertFalse(profileHides(304, grown)); assertFalse(profileHides(304, null))
assertTrue(AFTER_DARK_NUMBER !in KidsStartChannels)
// Kids watching locks After Dark and The Late Show, PIN or not.
Profiles.current = kids
assertTrue(AfterDarkLock.locked)
Profiles.current = grown
assertEquals(AfterDarkLock.pinSet && !AfterDarkLock.unlocked, AfterDarkLock.locked)
}
@Test fun everyPictureHasAName() {
assertEquals(18, ProfilePictures.size)
assertEquals("Fairy wand", pictureName("wand")); assertEquals("Pink", pictureName("sil-pink")); assertEquals("Ollie", pictureName("ollie"))
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><polygon points="22.50,67.50 22.50,35.00 37.50,51.00 50.00,27.50 62.50,51.00 77.50,35.00 77.50,67.50" fill="#FFD84A"/><circle cx="35.00" cy="60.00" r="3.50" fill="#FF4FA3"/><circle cx="50.00" cy="60.00" r="3.50" fill="#FF4FA3"/><circle cx="65.00" cy="60.00" r="3.50" fill="#FF4FA3"/></svg>

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><circle cx="50" cy="50" r="35" fill="#FFFFFF"/><polygon points="50.00,33.50 43.82,29.01 46.18,21.74 53.82,21.74 56.18,29.01" fill="#14213D"/><polygon points="65.69,44.90 68.05,37.63 75.69,37.63 78.06,44.90 71.87,49.39" fill="#14213D"/><polygon points="59.70,63.35 67.34,63.35 69.70,70.62 63.52,75.11 57.34,70.62" fill="#14213D"/><polygon points="40.30,63.35 42.66,70.62 36.48,75.11 30.30,70.62 32.66,63.35" fill="#14213D"/><polygon points="34.31,44.90 28.13,49.39 21.94,44.90 24.31,37.63 31.95,37.63" fill="#14213D"/><polygon points="50.00,40.00 59.51,46.91 55.88,58.09 44.12,58.09 40.49,46.91" fill="#14213D"/></svg>

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><circle cx="50" cy="57.5" r="15.0" fill="#FFFFFF"/><circle cx="31.00" cy="44.00" r="6.50" fill="#FFFFFF"/><circle cx="43.00" cy="30.00" r="6.50" fill="#FFFFFF"/><circle cx="57.00" cy="30.00" r="6.50" fill="#FFFFFF"/><circle cx="69.00" cy="44.00" r="6.50" fill="#FFFFFF"/></svg>

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><path d="M 15.00 62.50 A 35.00 35.00 0 0 1 85.00 62.50" fill="none" stroke="#FF6B5E" stroke-width="4.00"/><path d="M 19.00 62.50 A 31.00 31.00 0 0 1 81.00 62.50" fill="none" stroke="#F7A928" stroke-width="4.00"/><path d="M 23.00 62.50 A 27.00 27.00 0 0 1 77.00 62.50" fill="none" stroke="#FFD84A" stroke-width="4.00"/><path d="M 27.00 62.50 A 23.00 23.00 0 0 1 73.00 62.50" fill="none" stroke="#6EDCAA" stroke-width="4.00"/><path d="M 31.00 62.50 A 19.00 19.00 0 0 1 69.00 62.50" fill="none" stroke="#45B8F5" stroke-width="4.00"/><path d="M 35.00 62.50 A 15.00 15.00 0 0 1 65.00 62.50" fill="none" stroke="#A77BFF" stroke-width="4.00"/></svg>

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><polygon points="50.00,15.00 62.50,40.00 62.50,70.00 37.50,70.00 37.50,40.00" fill="#EBF0FA"/><circle cx="50" cy="44.0" r="6.0" fill="#45B8F5"/><polygon points="37.50,55.00 27.50,72.50 37.50,70.00" fill="#FF6B5E"/><polygon points="62.50,55.00 72.50,72.50 62.50,70.00" fill="#FF6B5E"/><polygon points="43.00,71.00 50.00,86.00 57.00,71.00" fill="#FFC23D"/></svg>

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"><line x1="25.0" y1="77.5" x2="55.0" y2="47.5" stroke="#F0F0FF" stroke-width="5" stroke-linecap="round"/><polygon points="61.00,20.00 66.29,33.72 80.97,34.51 69.56,43.78 73.34,57.99 61.00,50.00 48.66,57.99 52.44,43.78 41.03,34.51 55.71,33.72" fill="#FF9EDE"/><circle cx="30.00" cy="27.50" r="3.50" fill="#FFEC96"/><circle cx="80.00" cy="67.50" r="3.00" fill="#FFEC96"/><circle cx="45.00" cy="75.00" r="2.50" fill="#FFEC96"/></svg>

After

Width:  |  Height:  |  Size: 517 B