diff --git a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt index 42675bb..e4424c1 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatalogueStore.kt @@ -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 = emptySet(), years: IntRange? = null, type: String? = null, titleLike: String? = null, limit: Int = 400): List { + val where = mutableListOf("type IN ('Movie','Series')"); val args = mutableListOf() + 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): List = 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 diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 93a62c5..50a5181 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -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 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 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, 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, 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 = 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) } diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index aebe163..0f2f40e 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -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 { diff --git a/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt new file mode 100644 index 0000000..7e85e11 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CreateChannel.kt @@ -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, skipped: Set) -> List, + val search: suspend (text: String) -> List, + /** Saves the channel and its shows and switches it on; returns what to tell you. */ + val create: suspend (CustomChannel, List) -> String, + /** A channel's shows as they stand, for editing. */ + val showsOf: suspend (Int) -> List = { 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 = emptyList(), chosenAtFirst: List = 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>(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(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, 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) + } + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt b/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt new file mode 100644 index 0000000..10f4e64 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CustomChannels.kt @@ -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 = 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>(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) { + 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> = emptyList() to emptyList() + /** Every channel of your own as the planner sees it. */ + val programmings: List 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): List { + 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) + +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, val years: IntRange?, val type: String?, val words: List) + +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, chosen: List, skipped: Set, limit: Int = 12): List { + 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> { it.second }.thenBy { it.first.title }).take(limit) + .map { (row, _, reason) -> Suggestion(row.id, row.title, row.year, row.type, "", reason) }.toList() +} diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 1e42371..fec0845 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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>(emptyMap()) } var suggesting by remember { mutableStateOf(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) { diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 6223acc..b81bb47 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -248,8 +248,9 @@ internal fun GuideGrid(channels: List, 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() } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index ce6b8d2..0004bec 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -105,7 +105,9 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC var problem by remember(channel) { mutableStateOf(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 } diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 521e69e..d8e4ddf 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -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?>(null) } + var made by remember { mutableStateOf(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) diff --git a/android/app/src/main/java/tv/orbit/classic/Workspaces.kt b/android/app/src/main/java/tv/orbit/classic/Workspaces.kt index 054420b..85886ca 100644 --- a/android/app/src/main/java/tv/orbit/classic/Workspaces.kt +++ b/android/app/src/main/java/tv/orbit/classic/Workspaces.kt @@ -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 diff --git a/android/app/src/test/java/tv/orbit/classic/CustomChannelsTest.kt b/android/app/src/test/java/tv/orbit/classic/CustomChannelsTest.kt new file mode 100644 index 0000000..7fd4ad4 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/CustomChannelsTest.kt @@ -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>()) { 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" }) + } +} diff --git a/design/create-your-own/shots/21-create-start.png b/design/create-your-own/shots/21-create-start.png new file mode 100644 index 0000000..4aba5e0 Binary files /dev/null and b/design/create-your-own/shots/21-create-start.png differ diff --git a/design/create-your-own/shots/22-create-shows.png b/design/create-your-own/shots/22-create-shows.png new file mode 100644 index 0000000..fe8e575 Binary files /dev/null and b/design/create-your-own/shots/22-create-shows.png differ diff --git a/design/create-your-own/shots/23-create-style.png b/design/create-your-own/shots/23-create-style.png new file mode 100644 index 0000000..c21d7b0 Binary files /dev/null and b/design/create-your-own/shots/23-create-style.png differ diff --git a/design/create-your-own/shots/24-create-hours.png b/design/create-your-own/shots/24-create-hours.png new file mode 100644 index 0000000..08b8aa9 Binary files /dev/null and b/design/create-your-own/shots/24-create-hours.png differ