Create your own on the TV: channels of your own in Yours, from 701
Ported from release 0.108.46; screenshots of the four screens in all six looks in design/create-your-own/shots. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,26 @@ internal class CatalogueStore(context: Context, private val file: File) : Catalo
|
||||
buildList { while (c.moveToNext()) if (imdbOf(c.getString(1)) == imdb) add(c.getString(0)) }
|
||||
}
|
||||
}.filterValues { it.isNotEmpty() }
|
||||
/**
|
||||
* Films and series for Create your own: matching any of [genres], in [years], of [type], or whose title contains [titleLike].
|
||||
* Read only, bounded by [limit].
|
||||
*/
|
||||
@Synchronized fun chairRows(genres: Set<String> = emptySet(), years: IntRange? = null, type: String? = null, titleLike: String? = null, limit: Int = 400): List<ChairRow> {
|
||||
val where = mutableListOf("type IN ('Movie','Series')"); val args = mutableListOf<String>()
|
||||
if (type != null) { where += "type = ?"; args += type }
|
||||
if (genres.isNotEmpty()) { where += "(" + genres.joinToString(" OR ") { "('|' || genres || '|') LIKE ?" } + ")"; genres.forEach { args += "%|$it|%" } }
|
||||
if (years != null) { where += "year BETWEEN ? AND ?"; args += years.first.toString(); args += years.last.toString() }
|
||||
if (!titleLike.isNullOrBlank()) { where += "title LIKE ?"; args += "%${titleLike.trim().take(80)}%" }
|
||||
return readableDatabase.rawQuery("SELECT id, type, title, year, genres FROM items WHERE ${where.joinToString(" AND ")} ORDER BY title LIMIT $limit", args.toTypedArray()).use { c ->
|
||||
buildList { while (c.moveToNext()) add(ChairRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4).split('|').filter { it.isNotBlank() }.toSet())) }
|
||||
}
|
||||
}
|
||||
/** The same rows for chosen titles, by item. Read only. */
|
||||
@Synchronized fun chairRowsById(ids: Collection<String>): List<ChairRow> = ids.distinct().chunked(400).flatMap { chunk ->
|
||||
readableDatabase.rawQuery("SELECT id, type, title, year, genres FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
|
||||
buildList { while (c.moveToNext()) add(ChairRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4).split('|').filter { it.isNotBlank() }.toSet())) }
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
/** The open server's catalogue. */
|
||||
fun get(context: Context): CatalogueStore = Workspaces.current(context).catalogue
|
||||
|
||||
@@ -80,7 +80,9 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
|
||||
*/
|
||||
val storyGrouping: Boolean = false,
|
||||
/** Shorts grouping (Paul, 6 November 2026): short episodes gathered into slots of about half an hour. Null for none. */
|
||||
val shorts: ShortsRule? = null) {
|
||||
val shorts: ShortsRule? = null,
|
||||
/** Marathon (Create your own): this many episodes of a show back to back before the next takes over; 0 for none. */
|
||||
val marathon: Int = 0) {
|
||||
/** The items taken off this channel on the server signed in to: [withdrawnOnOriginal] there, none anywhere else. */
|
||||
val withdrawn: Set<String> get() = if (Workspaces.originalOpen) withdrawnOnOriginal else emptySet()
|
||||
/** A show's weight here, by its tier (a show the brief doesn't name counts as supporting), scaled by [catalogueScaleHours]. */
|
||||
@@ -181,7 +183,10 @@ private val AfterDarkFilms = listOf("The Thing (1982)", "The Evil Dead (1983)",
|
||||
"Heathers", "The Adventures of Buckaroo Banzai Across the 8th Dimension", "Big Trouble in Little China", "Barbarella", "The Warriors (1979)", "Evil Dead II",
|
||||
"Army of Darkness", "House (1977)", "The Holy Mountain", "Repo! The Genetic Opera")
|
||||
|
||||
internal val SmartChannels = listOf(
|
||||
/** Orbit's channels, and any you've made yourself (Create your own), as the planner and every screen see them. */
|
||||
internal val SmartChannels: List<ChannelProgramming> get() = BuiltInChannels + CustomChannels.programmings
|
||||
|
||||
private val BuiltInChannels = listOf(
|
||||
ChannelProgramming(AFTER_DARK_NUMBER, "After Dark", AfterDarkFilms, seriesCount = AfterDarkFilms.size, filmSeries = listOf(LAST_DRIVE_IN) + AfterDarkAnthologies, filmRestMs = 7 * 24 * 60 * 60_000L, adaptiveFilmRest = true,
|
||||
withdrawnOnOriginal = AfterDarkWithdrawn,
|
||||
dayParts = AfterDarkEvents + AfterDarkStrand + listOf(DayPart(21, 5), DayPart(5, 21, listOf("The 'Burbs", "'Til Death Do Us Part (2017)", "Tremors (1990)", "The Lost Boys (1987)", "Fright Night (1985)", "Poltergeist (1982)", "Scream (1996)",
|
||||
@@ -952,7 +957,9 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
|
||||
/** Story-aware mixing (Paul, 5 November 2026: part ones first, everywhere): a mixed show keeps each multi-part story whole and in order in its deck. */
|
||||
stories: Boolean = false,
|
||||
/** Shorts grouping ([ShortsRule]), or null for none. */
|
||||
shorts: ShortsRule? = null): ChannelPlan {
|
||||
shorts: ShortsRule? = null,
|
||||
/** Marathon: this many episodes of a show back to back before the next; 0 for none. */
|
||||
marathon: Int = 0): ChannelPlan {
|
||||
require(pool.isNotEmpty() && pool.all { it.episodes.isNotEmpty() })
|
||||
if (filmRestMs != null) return extendFilmSchedule(pool, cursor, fromMs, untilMs, filmRestMs, dayParts, zone, avoid, adaptiveRest, fixed, held)
|
||||
val marks = cursor.marks.toMutableMap()
|
||||
@@ -1004,7 +1011,9 @@ internal fun extendSchedule(pool: List<PoolSeries>, cursor: ScheduleCursor, from
|
||||
fun slotPick(open: ShortsSlot) = open.varied(turnOrder(pool, after).filter { show -> show.id != last && allowed(show) && open.takes(start, following(show).durationMs) && !avoid(show, start, start + following(show).durationMs) }) { it.id }
|
||||
val inSlot = slot?.let { open -> slotPick(open) ?: if (open.stretchIfShort(start)) slotPick(open) else null }
|
||||
if (inSlot == null) slot = null
|
||||
val picked = inSlot ?: turnOrder(pool, after).firstOrNull { show ->
|
||||
// A marathon keeps the show it's on for its run, while that show still has an episode for here.
|
||||
val onward = if (marathon > 1 && run in 1 until marathon) available.firstOrNull { it.id == last && !avoid(it, start, start + following(it).durationMs) } else null
|
||||
val picked = onward ?: inSlot ?: turnOrder(pool, after).firstOrNull { show ->
|
||||
when {
|
||||
!allowed(show) -> { if (show in available) waiting += show.title; false }
|
||||
avoid(show, start, start + following(show).durationMs) -> { elsewhere += show.title; false }
|
||||
@@ -1100,7 +1109,9 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List
|
||||
/** Film channels only: see [FixedSlot]. */
|
||||
fixed: List<FixedSlot> = emptyList(), held: (PoolSeries, Long) -> Boolean = { _, _ -> false },
|
||||
/** See [ChannelProgramming.shorts]. */
|
||||
shorts: ShortsRule? = null): ChannelPlan? {
|
||||
shorts: ShortsRule? = null,
|
||||
/** See [ChannelProgramming.marathon]. */
|
||||
marathon: Int = 0): ChannelPlan? {
|
||||
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
|
||||
if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept
|
||||
if (pool.isNullOrEmpty()) return kept
|
||||
@@ -1109,7 +1120,11 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List
|
||||
val added = if (weightOf != null && filmRestMs == null) {
|
||||
val recent = kept?.entries.orEmpty().filter { it.startMs >= from - fileRestMs }.associate { it.media.item.id to it.startMs }
|
||||
extendByBlock(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, weightOf, dayParts, zone, avoid, shuffle, mixed, finishTogether, recent, trailing, fileRestMs, stories, shorts)
|
||||
<<<<<<< HEAD
|
||||
} else extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow, blockRotation, adaptiveRest, fixed, held, stories, shorts = shorts)
|
||||
=======
|
||||
} else extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow && marathon == 0, blockRotation, adaptiveRest, stories = stories, shorts = shorts, marathon = marathon)
|
||||
>>>>>>> b7b8972 (Create your own on the TV: channels of your own in Yours, from 701)
|
||||
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
|
||||
}
|
||||
|
||||
|
||||
@@ -357,11 +357,15 @@ internal class ChannelScheduler(private val store: ScheduleStore,
|
||||
(if (number == PREMIERE_NUMBER && premieres.isNotEmpty()) approvedDebutSlots(premieres) else emptyList())
|
||||
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it + premiereRotation(number, premieres, now).filter { p -> it.none { s -> s.id == p.id } }, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes,
|
||||
<<<<<<< HEAD
|
||||
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, shorts = programming.shorts,
|
||||
fixed = fixed, held = seasonalHeld().let { seasonal -> val premiere = premiereHeld(number); { f: PoolSeries, at: Long -> seasonal(f, at) || premiere(f, at) } })
|
||||
?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
if (plan !== existing) plan.entries.filter { (it.why.startsWith(ADVENT_WHY) || it.why.startsWith(NAUGHTY_WHY)) && existing?.entries?.none { e -> e.startMs == it.startMs } != false }
|
||||
.forEach { Log.i("OrbitSchedule", "channel=$number ${if (it.why.startsWith(ADVENT_WHY)) "advent door=" + it.why.removePrefix(ADVENT_WHY).substringBefore(';') else "naughty"} booked lateMin=${(it.startMs - (fixed.firstOrNull { f -> f.film.id == it.seriesId }?.startMs ?: it.startMs)) / 60_000}") }
|
||||
=======
|
||||
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, shorts = programming.shorts, marathon = programming.marathon) ?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
>>>>>>> b7b8972 (Create your own on the TV: channels of your own in Yours, from 701)
|
||||
if (plan !== existing) store.savePlan(userId, number, plan)
|
||||
// Stage C, report only: every work any plan books, so "not yet shown on Orbit" has evidence.
|
||||
if (plan !== existing) runCatching {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Create your own on the TV (Paul, 6 November 2026, from the approved design/create-your-own): how to start, then four
|
||||
* steps (name, shows, how it plays, when it's on). The phone page comes in its own update; the start screen says so.
|
||||
*/
|
||||
|
||||
/** What Settings needs from the screen that holds the library and the session. */
|
||||
internal class ChannelMaker(
|
||||
val suggest: suspend (description: String, chosen: List<Suggestion>, skipped: Set<String>) -> List<Suggestion>,
|
||||
val search: suspend (text: String) -> List<Suggestion>,
|
||||
/** Saves the channel and its shows and switches it on; returns what to tell you. */
|
||||
val create: suspend (CustomChannel, List<Suggestion>) -> String,
|
||||
/** A channel's shows as they stand, for editing. */
|
||||
val showsOf: suspend (Int) -> List<Suggestion> = { emptyList() })
|
||||
|
||||
internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows"), Style("How it plays"), Hours("When it's on") }
|
||||
|
||||
private val HourChoices = listOf(null to "All day", OnHours(18, 0) to "Evenings, 18:00 to midnight", OnHours(6, 12) to "Mornings, 06:00 to noon", OnHours(9, 17) to "Daytime, 09:00 to 17:00")
|
||||
|
||||
@Composable
|
||||
internal fun CreateChannelFlow(maker: ChannelMaker, editing: CustomChannel?, onDone: (String?) -> Unit, modifier: Modifier = Modifier,
|
||||
/** For previews: where to open, and what's been offered and chosen. */
|
||||
opening: CreateStep? = null, offeredAtFirst: List<Suggestion> = emptyList(), chosenAtFirst: List<Suggestion> = emptyList()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var step by remember { mutableStateOf(opening ?: if (editing != null) CreateStep.Name else CreateStep.Start) }
|
||||
var name by remember { mutableStateOf(editing?.name.orEmpty()) }
|
||||
val number = remember { editing?.number ?: CustomChannels.nextNumber() }
|
||||
var description by remember { mutableStateOf(editing?.description.orEmpty()) }
|
||||
var chosen by remember { mutableStateOf(chosenAtFirst) }
|
||||
var skipped by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var offered by remember { mutableStateOf(offeredAtFirst) }
|
||||
var style by remember { mutableStateOf(editing?.style ?: PlayStyle.Standard) }
|
||||
var hours by remember { mutableStateOf(editing?.hours) }
|
||||
var working by remember { mutableStateOf(false) }
|
||||
var note by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(editing) { if (editing != null && chosenAtFirst.isEmpty()) chosen = maker.showsOf(editing.number) }
|
||||
val first = remember { FocusRequester() }
|
||||
LaunchedEffect(step) { runCatching { first.requestFocus() } }
|
||||
BackHandler { if (step == CreateStep.Start || (editing != null && step == CreateStep.Name)) onDone(null) else step = CreateStep.entries[step.ordinal - 1] }
|
||||
if (number == null) { LaunchedEffect(Unit) { onDone("Every number from 701 to 799 is taken.") }; return }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (editing != null) "Edit ${editing.name}" else step.title, Modifier.weight(1f), color = Ink, fontFamily = Anton, fontSize = 24.sp)
|
||||
if (step != CreateStep.Start) Text("Step ${step.ordinal} of 4", color = Muted, fontSize = 13.sp)
|
||||
}
|
||||
note?.let { Text(it, color = Yellow, fontSize = 13.sp, maxLines = 2) }
|
||||
when (step) {
|
||||
CreateStep.Start -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ChoiceCard("On this TV", listOf("Name it and pick its number", "Add shows, or let the Director's Chair suggest them",
|
||||
"Choose how it plays", "Choose when it's on"), Modifier.weight(1f).focusRequester(first)) { step = CreateStep.Name }
|
||||
ChoiceCard("On your phone", listOf("Scan a code with your phone to make channels there", "Comes in the next update"), Modifier.weight(1f), enabled = false) { }
|
||||
}
|
||||
CreateStep.Name -> {
|
||||
Text("Your channel goes in Yours, as $number.", color = Muted, fontSize = 13.sp)
|
||||
OrbitField(name, { if (it.length <= 40) name = it }, "Name, like 90s Sci-Fi", Modifier.fillMaxWidth().focusRequester(first))
|
||||
OrbitButton("Next: shows", Modifier.width(220.dp), enabled = name.isNotBlank()) { step = CreateStep.Shows }
|
||||
}
|
||||
CreateStep.Shows -> Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column(Modifier.weight(1.7f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OrbitField(description, { if (it.length <= 120) description = it }, "Describe it, like 90s sci-fi, or a title to search for", Modifier.weight(1f).focusRequester(first))
|
||||
OrbitButton(if (working) "Looking…" else "Suggest", enabled = !working && description.isNotBlank()) {
|
||||
working = true
|
||||
scope.launch { offered = runCatching { maker.suggest(description, chosen, skipped).ifEmpty { maker.search(description) } }.getOrDefault(emptyList()); working = false
|
||||
note = if (offered.isEmpty()) "Nothing in your library fits that yet. Try other words, or a title." else null }
|
||||
}
|
||||
}
|
||||
Text("THE CHAIR SUGGESTS", color = Muted, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
items(offered.filter { o -> chosen.none { it.id == o.id } && o.id !in skipped }, key = { it.id }) { s ->
|
||||
Row(Modifier.fillMaxWidth().background(Panel, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.title, color = Ink, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(listOfNotNull(if (s.type == "Movie") "Film" else "Series", s.year?.toString()).joinToString(" · "), color = Muted, fontSize = 11.sp)
|
||||
}
|
||||
Text(s.reason, Modifier.weight(1f).padding(horizontal = 8.dp), color = Ink, fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
OrbitButton("Add") { chosen = chosen + s.copy(reason = "Chosen for your channel") }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
OrbitButton("Skip") { skipped = skipped + s.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
OrbitButton("Next: how it plays", Modifier.width(240.dp), enabled = chosen.isNotEmpty()) { step = CreateStep.Style }
|
||||
}
|
||||
Column(Modifier.weight(1f).fillMaxHeight().background(Deep, RoundedCornerShape(10.dp)).padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("IN YOUR CHANNEL", color = Muted, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
if (chosen.isEmpty()) Text("Nothing yet: add shows or films on the left.", color = Muted, fontSize = 12.sp)
|
||||
chosen.forEach { c -> Text("· ${c.title}", Modifier.clickable { chosen = chosen - c }, color = Ink, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("Add and skip both teach the Chair what this channel is for. OK on a title here takes it out.", color = Muted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
CreateStep.Style -> {
|
||||
Text("One style for the whole channel; you can change it later. It shapes what's planned from then on, never what's booked.", color = Muted, fontSize = 12.sp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PlayStyle.entries.forEachIndexed { i, s ->
|
||||
ChoiceCard(s.title, listOf(s.line), Modifier.weight(1f).then(if (i == 0) Modifier.focusRequester(first) else Modifier), selected = style == s) { style = s }
|
||||
}
|
||||
}
|
||||
OrbitButton("Next: when it's on", Modifier.width(240.dp)) { step = CreateStep.Hours }
|
||||
}
|
||||
CreateStep.Hours -> {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HourChoices.forEachIndexed { i, (h, label) -> OrbitButton(label, if (i == 0) Modifier.focusRequester(first) else Modifier, selected = hours == h) { hours = h } }
|
||||
}
|
||||
Text("Outside its hours the channel shows a \"back at\" card.", color = Muted, fontSize = 12.sp)
|
||||
Row(Modifier.fillMaxWidth().background(Deep, RoundedCornerShape(10.dp)).padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("$number", Modifier.background(Color(0xFF28144A), RoundedCornerShape(6.dp)).padding(horizontal = 12.dp, vertical = 6.dp), color = Yellow, fontFamily = Anton, fontSize = 26.sp)
|
||||
Column {
|
||||
Text(name, color = Ink, fontFamily = Anton, fontSize = 20.sp)
|
||||
Text("Yours · ${chosen.size} ${if (chosen.size == 1) "title" else "titles"} · ${style.title} · ${hours?.label ?: "All day"}", color = Muted, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
OrbitButton(if (working) "Making it…" else if (editing != null) "Save changes" else "Create and switch on", Modifier.width(260.dp), enabled = !working) {
|
||||
working = true
|
||||
scope.launch {
|
||||
val channel = CustomChannel(number, name.trim(), style, hours, description.trim(), chosen.map { it.title })
|
||||
onDone(runCatching { maker.create(channel, chosen) }.getOrElse { "That channel couldn't be made. Try again." })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceCard(title: String, lines: List<String>, modifier: Modifier, selected: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val corner = RoundedCornerShape(12.dp)
|
||||
Column(modifier.onFocusChanged { focused = it.isFocused }.background(if (focused) Navy else Panel, corner)
|
||||
.border(if (focused || selected) 3.dp else 0.dp, if (focused) Yellow else if (selected) Mint else Color.Transparent, corner)
|
||||
.clickable(enabled = enabled, onClick = onClick).padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, color = if (enabled) Ink else Muted, fontFamily = Anton, fontSize = 22.sp)
|
||||
lines.forEach { Text(it, color = Muted, fontSize = 12.sp) }
|
||||
if (selected) Text("Chosen", color = Mint, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OrbitField(value: String, onChange: (String) -> Unit, hint: String, modifier: Modifier) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(modifier.onFocusChanged { focused = it.isFocused }.background(Panel, RoundedCornerShape(8.dp)).border(2.dp, if (focused) Yellow else Blue, RoundedCornerShape(8.dp)).padding(10.dp)) {
|
||||
if (value.isEmpty()) Text(hint, color = Muted, fontSize = 15.sp)
|
||||
BasicTextField(value, { onChange(it.replace("\n", "")) }, Modifier.fillMaxWidth(), singleLine = true, textStyle = TextStyle(color = Ink, fontSize = 15.sp),
|
||||
cursorBrush = SolidColor(Yellow), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done))
|
||||
}
|
||||
}
|
||||
|
||||
/** A channel of your own outside its hours: its name, and when it's back. */
|
||||
@Composable
|
||||
internal fun OffAirCard(channel: SampleChannel, back: String) {
|
||||
Box(Modifier.fillMaxSize().background(Navy), contentAlignment = Alignment.Center) {
|
||||
StarrySky()
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("${channel.number}", color = Yellow, fontFamily = Anton, fontSize = 40.sp)
|
||||
Text(channel.name, color = Ink, fontFamily = Anton, fontSize = 34.sp)
|
||||
Text("Off air. $back.", color = Muted, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Create your own (Paul, 6 November 2026, from the approved design/create-your-own): channels people make from their own
|
||||
* library, numbered from 701 in a new Yours block. A channel is its name, number, playback style and hours; its shows are
|
||||
* its accepted choices ([ChannelChoices]), as a Director's Chair placement is. Each server keeps its own, in its workspace.
|
||||
* It's planned by the same planner as Orbit's channels, and a style only shapes what's planned from then on: nothing booked
|
||||
* is ever changed by editing a channel.
|
||||
*/
|
||||
internal const val YOURS_FIRST = 701
|
||||
internal const val YOURS_LAST = 799
|
||||
|
||||
internal enum class PlayStyle(val title: String, val line: String) {
|
||||
/** As Orbit's own channels: shows take turns, episodes in order, shorts in slots, the usual rest rules. */
|
||||
Standard("Standard TV", "As Orbit's own channels: shows take turns, episodes in order, shorts in slots"),
|
||||
Shuffled("Shuffled", "Episodes in a mixed order, every one before any repeats"),
|
||||
/** A timetable: each show at its own hours, every day. */
|
||||
Blocks("Blocks", "A timetable: each show at its own times, every day"),
|
||||
Marathon("Marathon", "One show at a time, several episodes back to back")
|
||||
}
|
||||
|
||||
/** When a channel is on: all day, or from one hour to another (wrapping past midnight). */
|
||||
internal data class OnHours(val fromHour: Int, val toHour: Int) {
|
||||
fun covers(hour: Int) = if (fromHour <= toHour) hour in fromHour until toHour else hour >= fromHour || hour < toHour
|
||||
val label get() = "%02d:00 to %s".format(fromHour, if (toHour == 0 || toHour == 24) "midnight" else "%02d:00".format(toHour))
|
||||
}
|
||||
|
||||
internal data class CustomChannel(val number: Int, val name: String, val style: PlayStyle = PlayStyle.Standard, val hours: OnHours? = null, val description: String = "",
|
||||
/** The shows' titles in the order they were added: Blocks' timetable follows it. The shows themselves are the accepted choices. */
|
||||
val shows: List<String> = emptyList()) {
|
||||
init { require(number in YOURS_FIRST..YOURS_LAST && name.isNotBlank() && name.length <= 40) }
|
||||
fun toJson(): JSONObject = JSONObject().put("number", number).put("name", name).put("style", style.name).put("description", description)
|
||||
.put("hours", hours?.let { JSONObject().put("from", it.fromHour).put("to", it.toHour) } ?: JSONObject.NULL).put("shows", JSONArray(shows))
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = CustomChannel(o.getInt("number"), o.getString("name").take(40),
|
||||
runCatching { PlayStyle.valueOf(o.optString("style")) }.getOrDefault(PlayStyle.Standard),
|
||||
o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120),
|
||||
o.optJSONArray("shows")?.let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it).take(160) } }.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
/** Short episodes on a channel of your own: the same slots as ToonZ's, under a plain name. */
|
||||
internal val YoursShorts = ShortsRule { _, _ -> "Shorts" }
|
||||
|
||||
/** How many episodes a marathon runs before the next show takes over. */
|
||||
internal const val MARATHON_RUN = 4
|
||||
|
||||
internal object CustomChannels {
|
||||
var list by mutableStateOf<List<CustomChannel>>(emptyList())
|
||||
private set
|
||||
private const val FILE = "yours.json"
|
||||
|
||||
fun load(scheduleDir: File) {
|
||||
list = runCatching { JSONArray(File(scheduleDir, FILE).readText()).let { a -> (0 until a.length()).mapNotNull { runCatching { CustomChannel.fromJson(a.getJSONObject(it)) }.getOrNull() } } }
|
||||
.getOrDefault(emptyList()).distinctBy { it.number }.sortedBy { it.number }
|
||||
}
|
||||
fun save(scheduleDir: File, channels: List<CustomChannel>) {
|
||||
scheduleDir.mkdirs()
|
||||
val temporary = File(scheduleDir, "$FILE.tmp")
|
||||
temporary.writeText(JSONArray(channels.map { it.toJson() }).toString())
|
||||
if (!temporary.renameTo(File(scheduleDir, FILE))) throw java.io.IOException("Your channels could not be saved.")
|
||||
list = channels.sortedBy { it.number }
|
||||
}
|
||||
fun upsert(scheduleDir: File, channel: CustomChannel) = save(scheduleDir, list.filter { it.number != channel.number } + channel)
|
||||
fun remove(scheduleDir: File, number: Int) = save(scheduleDir, list.filter { it.number != number })
|
||||
/** For a server change: the next workspace's channels are its own. */
|
||||
fun clear() { list = emptyList() }
|
||||
fun nextNumber(): Int? = (YOURS_FIRST..YOURS_LAST).firstOrNull { n -> list.none { it.number == n } }
|
||||
fun of(number: Int) = list.firstOrNull { it.number == number }
|
||||
|
||||
@Volatile private var cached: Pair<List<CustomChannel>, List<ChannelProgramming>> = emptyList<CustomChannel>() to emptyList()
|
||||
/** Every channel of your own as the planner sees it. */
|
||||
val programmings: List<ChannelProgramming> get() {
|
||||
val now = list
|
||||
if (cached.first !== now) cached = now to now.map(::programmingOf)
|
||||
return cached.second
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel of your own as the planner sees it. Its shows are its accepted choices, so it has no list of its own; its style
|
||||
* becomes the planner's settings.
|
||||
*/
|
||||
internal fun programmingOf(channel: CustomChannel): ChannelProgramming = ChannelProgramming(
|
||||
channel.number, channel.name, emptyList(), seriesCount = 60,
|
||||
finishTogether = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Blocks,
|
||||
exactTitles = true, shuffleEpisodes = channel.style == PlayStyle.Shuffled,
|
||||
dayParts = if (channel.style == PlayStyle.Blocks) timetableParts(channel.shows) else emptyList(),
|
||||
storyGrouping = channel.style == PlayStyle.Standard,
|
||||
shorts = if (channel.style == PlayStyle.Standard) YoursShorts else null,
|
||||
marathon = if (channel.style == PlayStyle.Marathon) MARATHON_RUN else 0)
|
||||
|
||||
/**
|
||||
* Blocks: the day as a timetable, two hours a show, in the order the shows were added, from 06:00 round the clock. Every
|
||||
* show gets the same hours every day, so it's on at its time, like a schedule printed in a magazine.
|
||||
*/
|
||||
internal fun timetableParts(titles: List<String>): List<DayPart> {
|
||||
if (titles.isEmpty()) return emptyList()
|
||||
return (0 until 12).map { k -> val from = (6 + k * 2) % 24; DayPart(from, (from + 2) % 24, listOf(titles[k % titles.size]), group = "timetable") }
|
||||
}
|
||||
|
||||
/** "Back at 18:00" when a channel of your own is outside its hours at [timeMs]; null when it's on. */
|
||||
internal fun offAirUntil(number: Int, timeMs: Long, zone: ZoneId = ZoneId.systemDefault()): String? {
|
||||
val hours = CustomChannels.of(number)?.hours ?: return null
|
||||
val hour = Instant.ofEpochMilli(timeMs).atZone(zone).hour
|
||||
return if (hours.covers(hour)) null else "Back at %02d:00".format(hours.fromHour)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Director's Chair's suggestions for a channel described in a few words (Paul: "involve the Director's Chair to select
|
||||
* similar to what the user's list has"): titles from the catalogue whose genres and years fit the words, then those most
|
||||
* like the shows already chosen, each with its reason. [candidates] are catalogue rows; nothing already chosen or skipped.
|
||||
*/
|
||||
internal class ChairRow(val id: String, val type: String, val title: String, val year: Int?, val genres: Set<String>)
|
||||
|
||||
private val GenreWords = mapOf(
|
||||
"sci-fi" to "Science Fiction", "scifi" to "Science Fiction", "science fiction" to "Science Fiction", "space" to "Science Fiction",
|
||||
"comedy" to "Comedy", "comedies" to "Comedy", "sitcom" to "Comedy", "sitcoms" to "Comedy", "horror" to "Horror", "scary" to "Horror",
|
||||
"cartoon" to "Animation", "cartoons" to "Animation", "animation" to "Animation", "animated" to "Animation", "anime" to "Anime",
|
||||
"western" to "Western", "westerns" to "Western", "crime" to "Crime", "detective" to "Crime", "drama" to "Drama", "documentary" to "Documentary",
|
||||
"documentaries" to "Documentary", "action" to "Action", "fantasy" to "Fantasy", "family" to "Family", "kids" to "Kids", "children" to "Kids",
|
||||
"mystery" to "Mystery", "romance" to "Romance", "romantic" to "Romance", "thriller" to "Thriller", "thrillers" to "Thriller",
|
||||
"war" to "War", "music" to "Music", "musical" to "Music", "adventure" to "Adventure", "history" to "History", "reality" to "Reality")
|
||||
|
||||
/** What a description asks for: genres, a span of years, and films or series. */
|
||||
internal class Wish(val genres: Set<String>, val years: IntRange?, val type: String?, val words: List<String>)
|
||||
|
||||
internal fun wishOf(description: String): Wish {
|
||||
val text = description.lowercase()
|
||||
val genres = GenreWords.filterKeys { Regex("\\b${Regex.escape(it)}\\b").containsMatchIn(text) }.values.toSet()
|
||||
val decade = Regex("\\b(?:19|20)?([0-9])0'?s\\b").find(text)?.groupValues?.get(1)?.toInt()?.let { d -> val base = if (d >= 3) 1900 + d * 10 else 2000 + d * 10; base..base + 9 }
|
||||
val type = when { Regex("\\b(films?|movies?)\\b").containsMatchIn(text) -> "Movie"; Regex("\\b(series|shows?|sitcoms?)\\b").containsMatchIn(text) -> "Series"; else -> null }
|
||||
return Wish(genres, decade, type, text.split(Regex("[^a-z0-9]+")).filter { it.length > 2 })
|
||||
}
|
||||
|
||||
internal fun chairSuggestions(description: String, candidates: List<ChairRow>, chosen: List<ChairRow>, skipped: Set<String>, limit: Int = 12): List<Suggestion> {
|
||||
val wish = wishOf(description)
|
||||
val liked = chosen.flatMap { it.genres }.groupingBy { it }.eachCount()
|
||||
val taken = chosen.map { it.id }.toSet() + skipped
|
||||
return candidates.asSequence().filter { it.id !in taken && (wish.type == null || it.type == wish.type) }.mapNotNull { row ->
|
||||
val genreHits = row.genres.intersect(wish.genres)
|
||||
val inYears = wish.years?.let { r -> row.year != null && row.year in r }
|
||||
if (wish.genres.isNotEmpty() && genreHits.isEmpty()) return@mapNotNull null
|
||||
if (inYears == false) return@mapNotNull null
|
||||
val likeness = row.genres.sumOf { liked[it] ?: 0 }
|
||||
val score = genreHits.size * 3 + (if (inYears == true) 2 else 0) + minOf(likeness, 6)
|
||||
val reason = (genreHits.sorted() + listOfNotNull(wish.years?.let { "${it.first}s" }) +
|
||||
listOfNotNull(if (likeness > 0) "like what's already in it" else null)).joinToString(", ").replaceFirstChar { it.uppercase() }.ifEmpty { "From your library" }
|
||||
Triple(row, score, reason)
|
||||
}.sortedWith(compareByDescending<Triple<ChairRow, Int, String>> { it.second }.thenBy { it.first.title }).take(limit)
|
||||
.map { (row, _, reason) -> Suggestion(row.id, row.title, row.year, row.type, "", reason) }.toList()
|
||||
}
|
||||
@@ -105,6 +105,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
var guideCategory by remember { mutableStateOf(ChannelCategory.All) }
|
||||
val liveHandoff = remember { LiveHandoff() }
|
||||
val scheduleStore = remember(workspace) { workspace.schedules }
|
||||
// Your own channels (Create your own) are this server's, read before anything plans or restores.
|
||||
remember(workspace) { CustomChannels.load(workspace.scheduleDir); true }
|
||||
val scheduler = remember(workspace) { ChannelScheduler(scheduleStore, copies = { if (catalogue.complete()) catalogue.idsByImdb(it) else null }, original = workspace.original) }
|
||||
var scheduledChannels by remember { mutableStateOf<Map<Int, ScheduledChannel>>(emptyMap()) }
|
||||
var suggesting by remember { mutableStateOf<ChannelProgramming?>(null) }
|
||||
@@ -617,7 +619,25 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks."
|
||||
else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." }
|
||||
} }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
|
||||
onSignOut = { job?.cancel(); closeSession(signOut = true) }, onRemoveServer = { job?.cancel(); closeSession(remove = true) }, windingDown = windingDown, onWindDown = { on ->
|
||||
onSignOut = { job?.cancel(); closeSession(signOut = true) }, onRemoveServer = { job?.cancel(); closeSession(remove = true) },
|
||||
maker = session?.let { active -> ChannelMaker(
|
||||
suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
|
||||
val wish = wishOf(description)
|
||||
val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
|
||||
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
|
||||
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
|
||||
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
|
||||
create = { channel, shows ->
|
||||
withContext(Dispatchers.IO) {
|
||||
CustomChannels.upsert(workspace.scheduleDir, channel)
|
||||
val before = scheduleStore.loadChoices(active.accountId, channel.number)
|
||||
scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
|
||||
}
|
||||
android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
|
||||
SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
|
||||
"${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
|
||||
}) },
|
||||
windingDown = windingDown, onWindDown = { on ->
|
||||
val tots = scheduledChannels[TOTS_NUMBER]
|
||||
val active = session
|
||||
if (tots != null && active != null) scope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -248,8 +248,9 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
|
||||
Row(Modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Kids never sees what a grown-up takeover is showing.
|
||||
val asleep = kidsAsleep(takeoverOf(line.number, cell.show))
|
||||
val offAir = offAirUntil(line.number, cell.show.startMs)
|
||||
if (!asleep && cellWidth >= GuideTitleMinWidth) ShortsMark(cell.show, line.number, Modifier.padding(end = 5.dp))
|
||||
Text(if (cellWidth < GuideTitleMinWidth) "…" else if (asleep) "Asleep till 6am" else guideTitle(line.number, cell.show).first, Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp,
|
||||
Text(if (cellWidth < GuideTitleMinWidth) "…" else if (asleep) "Asleep till 6am" else if (offAir != null) "Off air · ${offAir.lowercase().replaceFirstChar { it.uppercase() }}" else guideTitle(line.number, cell.show).first, Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp,
|
||||
fontWeight = if (isFocus || onNow) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (runsOn && cellWidth >= GuideTitleMinWidth) Text(" ►", color = ink, fontSize = 9.sp)
|
||||
if (past && cellWidth >= GuideTitleMinWidth + 14.dp) { Spacer(Modifier.width(4.dp)); CatchUpMark() }
|
||||
|
||||
@@ -105,7 +105,9 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
var problem by remember(channel) { mutableStateOf<String?>(null) }
|
||||
var retryAttempt by remember(channel) { mutableIntStateOf(0) }
|
||||
// Kids on a channel during a grown-up takeover: it's asleep, and nothing of it is opened.
|
||||
val asleep = kidsAsleep(takeoverOf(channel.number, current))
|
||||
// A channel of your own outside its hours is off air, and nothing of it is opened either.
|
||||
val offAir = offAirUntil(channel.number, current.startMs)
|
||||
val asleep = kidsAsleep(takeoverOf(channel.number, current)) || offAir != null
|
||||
// A new wall-clock slot disposes the old player before opening its successor.
|
||||
LaunchedEffect(channel, current.startMs, retryAttempt, asleep) {
|
||||
if (asleep) return@LaunchedEffect
|
||||
@@ -164,7 +166,8 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
|
||||
val window = remember { PlaybackWindow() }
|
||||
Dialog(onDismissRequest = { window.onBack() }, properties = DialogProperties(usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
if (asleep) { SideEffect { window.onBack = onClose }; KidsAsleep(channel, takeoverOf(channel.number, current)!!) }
|
||||
if (offAir != null) { SideEffect { window.onBack = onClose }; OffAirCard(channel, offAir) }
|
||||
else if (asleep) { SideEffect { window.onBack = onClose }; KidsAsleep(channel, takeoverOf(channel.number, current)!!) }
|
||||
else if (plan != null) key(current.startMs) {
|
||||
val announce = remember { announcedChannel[0] != channel.number }
|
||||
SideEffect { announcedChannel[0] = channel.number }
|
||||
|
||||
@@ -135,6 +135,7 @@ internal fun channelBlock(number: Int) = when (number / 100) {
|
||||
3 -> "Films"
|
||||
4 -> "Sport"
|
||||
6 -> "Cartoons and kids"
|
||||
7 -> "Yours"
|
||||
else -> "Other"
|
||||
}
|
||||
|
||||
@@ -169,12 +170,14 @@ private fun SettingLine(title: String, detail: String, button: @Composable () ->
|
||||
|
||||
@Composable
|
||||
private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boolean,
|
||||
onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, firstSwitch: FocusRequester, list: LazyListState, modifier: Modifier) {
|
||||
onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, firstSwitch: FocusRequester, list: LazyListState, modifier: Modifier,
|
||||
onCreate: (() -> Unit)? = null, onEdit: (CustomChannel) -> Unit = {}) {
|
||||
val all = SmartChannels.sortedBy { it.number }
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("${all.count { isOn(it.number) }} of ${all.size} channels on", color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("A channel that's on runs its full schedule. Once it's been on, turning it off leaves a short repeat.", color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
message?.let { Text(it, color = Yellow, fontSize = 13.sp, maxLines = 3, overflow = TextOverflow.Ellipsis) }
|
||||
if (onCreate != null) OrbitButton("Create a channel", enabled = !busy, onClick = onCreate)
|
||||
LazyColumn(Modifier.weight(1f), state = list, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(all, key = { it.number }) { programming ->
|
||||
val on = isOn(programming.number)
|
||||
@@ -187,6 +190,7 @@ private fun ChannelsDetail(busy: Boolean, message: String?, isOn: (Int) -> Boole
|
||||
if (brand != null) Image(painterResource(brand.logo), brand.displayName, Modifier.logoPlate().width(88.dp).height(28.dp))
|
||||
Text(brand?.displayName ?: programming.name, Modifier.weight(1f), color = Ink, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (programming.suggestionRules.isNotEmpty()) OrbitButton("Titles", enabled = !busy, onClick = { onSuggestions(programming) })
|
||||
CustomChannels.of(programming.number)?.let { yours -> OrbitButton("Edit", enabled = !busy, onClick = { onEdit(yours) }) }
|
||||
OrbitButton(if (on) "On" else "Off", Modifier.width(64.dp).then(if (programming == all.first()) Modifier.focusRequester(firstSwitch) else Modifier), selected = on, enabled = !busy, onClick = { onSwitch(programming, !on) })
|
||||
}
|
||||
}
|
||||
@@ -306,7 +310,12 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio
|
||||
arrivals: @Composable (Modifier, FocusRequester) -> Unit,
|
||||
director: @Composable (Modifier, FocusRequester) -> Unit,
|
||||
isOn: (Int) -> Boolean, onSwitch: (ChannelProgramming, Boolean) -> Unit, onSuggestions: (ChannelProgramming) -> Unit, onSignOut: () -> Unit,
|
||||
windingDown: Boolean, onWindDown: (Boolean) -> Unit, onRemoveServer: () -> Unit = {}) {
|
||||
windingDown: Boolean, onWindDown: (Boolean) -> Unit, onRemoveServer: () -> Unit = {},
|
||||
/** Create your own: null where there's no library to make channels from. */
|
||||
maker: ChannelMaker? = null) {
|
||||
// Making or editing a channel of your own takes over the Channels detail until it's done.
|
||||
var making by remember { mutableStateOf<Pair<Boolean, CustomChannel?>?>(null) }
|
||||
var made by remember { mutableStateOf<String?>(null) }
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
val firstSwitch = remember { FocusRequester() }
|
||||
val channelList = rememberLazyListState()
|
||||
@@ -325,7 +334,9 @@ internal fun SettingsScreen(section: SettingsSection, onSection: (SettingsSectio
|
||||
}
|
||||
val detail = Modifier.weight(1f).fillMaxHeight()
|
||||
when (section) {
|
||||
SettingsSection.Channels -> ChannelsDetail(busy, message, isOn, onSwitch, onSuggestions, firstSwitch, channelList, detail)
|
||||
SettingsSection.Channels -> making?.let { (_, editing) -> if (maker != null) CreateChannelFlow(maker, editing, { result -> made = result; making = null }, detail) }
|
||||
?: ChannelsDetail(busy, made ?: message, isOn, onSwitch, onSuggestions, firstSwitch, channelList, detail,
|
||||
onCreate = if (maker != null) ({ made = null; making = true to null }) else null, onEdit = { c -> made = null; making = true to c })
|
||||
SettingsSection.Viewing -> ViewingDetail(isOn(TOTS_NUMBER), windingDown, onWindDown, firstSwitch, detail)
|
||||
SettingsSection.Arrivals -> arrivals(detail, firstSwitch)
|
||||
SettingsSection.Director -> director(detail, firstSwitch)
|
||||
|
||||
@@ -86,7 +86,7 @@ internal object Workspaces {
|
||||
current = next
|
||||
// What one server showed or approved never carries over to another.
|
||||
PromoBoard.items = emptyList(); PromoBoard.shown.clear(); PromoBoard.preview = null
|
||||
ApprovedDriveInEvents.list = emptyList()
|
||||
ApprovedDriveInEvents.list = emptyList(); CustomChannels.clear()
|
||||
CuratedDriveInEvents.list = emptyList(); CuratedDriveInEvents.copies = emptyMap(); CuratedDriveInEvents.missing = emptyMap()
|
||||
EmbyServer.origin = origin
|
||||
return next
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/** Create your own (Paul, 6 November 2026): channels of your own, their styles, hours and the Chair's suggestions. */
|
||||
class CustomChannelsTest {
|
||||
@get:Rule val folder = TemporaryFolder()
|
||||
private val zone = ZoneId.of("Europe/London")
|
||||
private fun at(h: Int, m: Int = 0) = ZonedDateTime.of(2026, 11, 9, h, m, 0, 0, zone).toInstant().toEpochMilli()
|
||||
private fun show(id: String, minutes: Double, count: Int = 20) =
|
||||
PoolSeries(id, id, (1..count).map { PoolEpisode("$id-$it", "s", EpisodeMark(1, it), "Ep $it", (minutes * 60_000).toLong()) })
|
||||
@After fun reset() = CustomChannels.clear()
|
||||
|
||||
@Test fun channelsAreSavedPerFolderAndJoinTheLineUp() {
|
||||
val dir = folder.newFolder()
|
||||
assertEquals(701, CustomChannels.nextNumber())
|
||||
CustomChannels.upsert(dir, CustomChannel(701, "90s Sci-Fi", PlayStyle.Blocks, OnHours(18, 0), "90s sci-fi", listOf("The X-Files", "Babylon 5")))
|
||||
CustomChannels.clear(); assertTrue(CustomChannels.list.isEmpty())
|
||||
CustomChannels.load(dir)
|
||||
val back = CustomChannels.of(701)!!
|
||||
assertEquals(PlayStyle.Blocks, back.style); assertEquals(OnHours(18, 0), back.hours); assertEquals(listOf("The X-Files", "Babylon 5"), back.shows)
|
||||
assertEquals(702, CustomChannels.nextNumber())
|
||||
assertEquals("90s Sci-Fi", SmartChannels.single { it.number == 701 }.name)
|
||||
assertEquals("Yours", channelBlock(701))
|
||||
CustomChannels.remove(dir, 701); assertNull(SmartChannels.firstOrNull { it.number == 701 })
|
||||
}
|
||||
|
||||
@Test fun eachStyleBecomesThePlannersSettings() {
|
||||
val standard = programmingOf(CustomChannel(701, "A"))
|
||||
assertTrue(standard.finishTogether); assertNotNull(standard.shorts); assertTrue(standard.storyGrouping)
|
||||
assertTrue(programmingOf(CustomChannel(702, "B", PlayStyle.Shuffled)).shuffleEpisodes)
|
||||
assertEquals(MARATHON_RUN, programmingOf(CustomChannel(703, "C", PlayStyle.Marathon)).marathon)
|
||||
val blocks = programmingOf(CustomChannel(704, "D", PlayStyle.Blocks, shows = listOf("One", "Two", "Three")))
|
||||
assertEquals(12, blocks.dayParts.size)
|
||||
assertEquals(listOf("One"), blocks.dayParts.first { it.fromHour == 6 }.titles); assertEquals(listOf("Two"), blocks.dayParts.first { it.fromHour == 8 }.titles)
|
||||
}
|
||||
|
||||
@Test fun aMarathonRunsSeveralEpisodesOfAShowBeforeTheNext() {
|
||||
val pool = listOf(show("a", 22.0), show("b", 22.0), show("c", 22.0))
|
||||
val plan = extendSchedule(pool, ScheduleCursor(), at(9), at(17), noThirdInARow = false, marathon = MARATHON_RUN)
|
||||
val runs = plan.entries.fold(mutableListOf<Pair<String, Int>>()) { acc, e -> if (acc.lastOrNull()?.first == e.seriesId) acc[acc.lastIndex] = e.seriesId to acc.last().second + 1 else acc += e.seriesId to 1; acc }
|
||||
assertTrue(runs.dropLast(1).all { it.second == MARATHON_RUN })
|
||||
assertTrue(runs.map { it.first }.zipWithNext().none { (x, y) -> x == y })
|
||||
}
|
||||
|
||||
@Test fun hoursSayWhenTheChannelIsOffAir() {
|
||||
val dir = folder.newFolder()
|
||||
CustomChannels.upsert(dir, CustomChannel(701, "Evenings", hours = OnHours(18, 0)))
|
||||
assertEquals("Back at 18:00", offAirUntil(701, at(10), zone)); assertNull(offAirUntil(701, at(20), zone)); assertNull(offAirUntil(701, at(23, 30), zone))
|
||||
assertNull(offAirUntil(605, at(10), zone))
|
||||
assertTrue(OnHours(22, 6).covers(2)); assertFalse(OnHours(22, 6).covers(12))
|
||||
}
|
||||
|
||||
@Test fun theChairSuggestsWhatFitsTheWordsAndWhatsAlreadyChosen() {
|
||||
val wish = wishOf("90s sci-fi, late evenings")
|
||||
assertEquals(setOf("Science Fiction"), wish.genres); assertEquals(1990..1999, wish.years)
|
||||
assertEquals(1960..1969, wishOf("60s westerns").years); assertEquals("Movie", wishOf("horror films").type)
|
||||
val rows = listOf(ChairRow("x", "Series", "The X-Files", 1993, setOf("Science Fiction", "Mystery", "Drama")),
|
||||
ChairRow("b", "Series", "Babylon 5", 1993, setOf("Science Fiction", "Drama")), ChairRow("s", "Series", "Stargate SG-1", 1997, setOf("Science Fiction", "Action")),
|
||||
ChairRow("f", "Series", "Firefly", 2002, setOf("Science Fiction")), ChairRow("c", "Series", "Cheers", 1990, setOf("Comedy")))
|
||||
val chosen = listOf(ChairRow("d", "Series", "Twin Peaks", 1990, setOf("Mystery", "Drama")))
|
||||
val picks = chairSuggestions("90s sci-fi", rows, chosen, skipped = setOf("s"))
|
||||
assertEquals(listOf("x", "b"), picks.map { it.id }) // in the decade, the genre, and like what's chosen; Stargate skipped
|
||||
assertTrue(picks.first().reason.contains("Science Fiction")); assertTrue(picks.first().reason.contains("like what's already in it"))
|
||||
assertTrue(chairSuggestions("90s sci-fi", rows, emptyList(), emptySet()).none { it.id == "f" || it.id == "c" })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
Reference in New Issue
Block a user