diff --git a/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt b/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt index 7a0c7cd..aae2e2f 100644 --- a/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt +++ b/android/app/src/main/java/tv/orbit/classic/AfterDarkLock.kt @@ -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) 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 06a4663..098c871 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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, 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 34adad8..2903b08 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/Profiles.kt b/android/app/src/main/java/tv/orbit/classic/Profiles.kt new file mode 100644 index 0000000..2e1db41 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/Profiles.kt @@ -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 = 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(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) = 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 { + 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(null) } + var problem by remember { mutableStateOf(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(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) + } + } +} 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 8a12b8b..e689391 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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) } } diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_crown.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_crown.webp new file mode 100644 index 0000000..bd252ef Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_crown.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_football.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_football.webp new file mode 100644 index 0000000..7b58f2f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_football.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_paw.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_paw.webp new file mode 100644 index 0000000..cf04cc6 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_paw.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_rainbow.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_rainbow.webp new file mode 100644 index 0000000..0ed64a8 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_rainbow.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_rocket.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_rocket.webp new file mode 100644 index 0000000..91c894a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_rocket.webp differ diff --git a/android/app/src/main/res/drawable-nodpi/orbit_picture_wand.webp b/android/app/src/main/res/drawable-nodpi/orbit_picture_wand.webp new file mode 100644 index 0000000..4227734 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/orbit_picture_wand.webp differ diff --git a/android/app/src/test/java/tv/orbit/classic/ProfilesTest.kt b/android/app/src/test/java/tv/orbit/classic/ProfilesTest.kt new file mode 100644 index 0000000..a6b6520 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/ProfilesTest.kt @@ -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")) + } +} diff --git a/design/profiles/pictures/check.png b/design/profiles/pictures/check.png new file mode 100644 index 0000000..1d310a8 Binary files /dev/null and b/design/profiles/pictures/check.png differ diff --git a/design/profiles/pictures/crown.png b/design/profiles/pictures/crown.png new file mode 100644 index 0000000..9ca651b Binary files /dev/null and b/design/profiles/pictures/crown.png differ diff --git a/design/profiles/pictures/crown.svg b/design/profiles/pictures/crown.svg new file mode 100644 index 0000000..4a6ff16 --- /dev/null +++ b/design/profiles/pictures/crown.svg @@ -0,0 +1 @@ + diff --git a/design/profiles/pictures/football.png b/design/profiles/pictures/football.png new file mode 100644 index 0000000..28a9a3f Binary files /dev/null and b/design/profiles/pictures/football.png differ diff --git a/design/profiles/pictures/football.svg b/design/profiles/pictures/football.svg new file mode 100644 index 0000000..7d5c47d --- /dev/null +++ b/design/profiles/pictures/football.svg @@ -0,0 +1 @@ + diff --git a/design/profiles/pictures/paw.png b/design/profiles/pictures/paw.png new file mode 100644 index 0000000..6290a0a Binary files /dev/null and b/design/profiles/pictures/paw.png differ diff --git a/design/profiles/pictures/paw.svg b/design/profiles/pictures/paw.svg new file mode 100644 index 0000000..d2e161a --- /dev/null +++ b/design/profiles/pictures/paw.svg @@ -0,0 +1 @@ + diff --git a/design/profiles/pictures/rainbow.png b/design/profiles/pictures/rainbow.png new file mode 100644 index 0000000..84e6388 Binary files /dev/null and b/design/profiles/pictures/rainbow.png differ diff --git a/design/profiles/pictures/rainbow.svg b/design/profiles/pictures/rainbow.svg new file mode 100644 index 0000000..ad97fb4 --- /dev/null +++ b/design/profiles/pictures/rainbow.svg @@ -0,0 +1 @@ + diff --git a/design/profiles/pictures/rocket.png b/design/profiles/pictures/rocket.png new file mode 100644 index 0000000..ac1b80d Binary files /dev/null and b/design/profiles/pictures/rocket.png differ diff --git a/design/profiles/pictures/rocket.svg b/design/profiles/pictures/rocket.svg new file mode 100644 index 0000000..73c4820 --- /dev/null +++ b/design/profiles/pictures/rocket.svg @@ -0,0 +1 @@ + diff --git a/design/profiles/pictures/wand.png b/design/profiles/pictures/wand.png new file mode 100644 index 0000000..67a53f4 Binary files /dev/null and b/design/profiles/pictures/wand.png differ diff --git a/design/profiles/pictures/wand.svg b/design/profiles/pictures/wand.svg new file mode 100644 index 0000000..2c5bdad --- /dev/null +++ b/design/profiles/pictures/wand.svg @@ -0,0 +1 @@ +