Profiles, Who's watching, and the starry sky (from 0.108.34); VectorCraft picture sources
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
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"))
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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 |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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 |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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 |
|
After Width: | Height: | Size: 12 KiB |
@@ -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 |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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 |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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 |