Create your own on the TV: channels of your own in Yours, from 701
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m9s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m29s

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:
2026-11-06 08:43:50 +00:00
co-authored by Claude Opus 5.5
parent cdc8528d77
commit 3dd7133d22
15 changed files with 513 additions and 13 deletions
@@ -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