From 2a110fbc7231b51e068c1a54e87132d272aebdeb Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 08:31:49 +0000 Subject: [PATCH] Director's Chair: place or drop from a pop-up Pressing a pick opens its pop-up: place it on the suggested channel, change to another channel the scoring allows (three at most, each with its reason), or drop it. An ask offers its two channels or Drop. Placing adds the title to that channel's own list as an accepted choice, so it joins at the next top-up; nothing is booked and committed bookings never change. Every decision is recorded, and decided titles make way for the next. A placed film's playable copy is found when the pool is read. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../java/tv/orbit/classic/DirectorChair.kt | 199 +++++++++++++++--- .../java/tv/orbit/classic/DirectorScoring.kt | 6 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 8 +- .../tv/orbit/classic/EmbyPlaybackProtocol.kt | 14 +- .../java/tv/orbit/classic/ScheduleStore.kt | 2 + .../tv/orbit/classic/DirectorChairTest.kt | 33 +++ 6 files changed, 223 insertions(+), 39 deletions(-) diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt index 5d72b1b..81c197d 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorChair.kt @@ -27,36 +27,81 @@ import java.time.LocalDate import java.time.ZoneId /** - * Settings › Director's Chair (Paul's approved design, 4 November 2026): one read-only summary of what's new and where it fits. - * Routine matches as counts, three to five discoveries, and only the genuine either-or calls. It books nothing, changes no - * pool or schedule and approves nothing; auto-approval is off. Scan gaps (titles not yet read, no collections, an + * Settings › Director's Chair (Paul's approved design, 4 November 2026; decisions approved 5 November): what's new and where it fits. + * Routine matches as counts, three to five discoveries, and only the genuine either-or calls. You place or drop each one yourself: + * it books nothing and approves nothing on its own; auto-approval is off. Scan gaps (titles not yet read, no collections, an * incomplete catalogue) are shown, never hidden. */ internal enum class DirectorMode(val label: String, val picks: Int) { Familiar("FAMILIAR", 3), Balanced("BALANCED", 4), Surprise("SURPRISE ME", 5) } -internal class DeskPick(val title: String, val channel: String, val tag: String, val line: String) -internal class DeskAsk(val title: String, val first: String, val second: String) +/** A channel the Chair can place a title on, with the reason in a line. */ +internal class DeskOption(val number: Int, val name: String, val reason: String) { + fun toJson(): JSONObject = JSONObject().put("n", number).put("c", name).put("r", reason) + companion object { fun of(o: JSONObject) = DeskOption(o.optInt("n"), o.optString("c"), o.optString("r")) } +} + +/** A discovery: [options] are where it can go, its suggested channel first. [id], [type] and [year] are the library item's, for placing it. */ +internal class DeskPick(val title: String, val channel: String, val tag: String, val line: String, val id: String = "", val type: String = "", val year: Int? = null, + val options: List = emptyList()) +/** An either-or call: [options] are the two channels it's torn between. */ +internal class DeskAsk(val title: String, val first: String, val second: String, val id: String = "", val type: String = "", val year: Int? = null, + val options: List = emptyList(), val line: String = "") + +private fun JSONObject.item(id: String, type: String, year: Int?, options: List) = + put("i", id).put("k", type).put("y", year ?: JSONObject.NULL).put("o", JSONArray(options.map { it.toJson() })) +private fun JSONObject.options() = optJSONArray("o")?.let { a -> (0 until a.length()).map { DeskOption.of(a.getJSONObject(it)) } }.orEmpty() +private fun JSONObject.year() = if (isNull("y")) null else optInt("y").takeIf { it > 0 } internal class DeskReport(val newlyReleased: Int, val newToLibrary: Int, val scanned: Int, val unread: Int, val strongByChannel: List>, val strong: Int, - val picks: List, val asks: List, val collections: Int, val catalogueComplete: Boolean, val franchises: Int, val computedMs: Long, val tookMs: Long) { + val picks: List, val asks: List, val collections: Int, val catalogueComplete: Boolean, val franchises: Int, val computedMs: Long, val tookMs: Long, + /** Titles you've already placed or dropped, kept out of the picks and asks. */ + val decided: Int = 0) { fun toJson(): JSONObject = JSONObject().put("newlyReleased", newlyReleased).put("newToLibrary", newToLibrary).put("scanned", scanned).put("unread", unread) .put("strong", strong).put("strongByChannel", JSONArray(strongByChannel.map { JSONObject().put("c", it.first).put("n", it.second) })) - .put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line) })) - .put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second) })) + .put("picks", JSONArray(picks.map { JSONObject().put("t", it.title).put("c", it.channel).put("g", it.tag).put("l", it.line).item(it.id, it.type, it.year, it.options) })) + .put("asks", JSONArray(asks.map { JSONObject().put("t", it.title).put("a", it.first).put("b", it.second).put("l", it.line).item(it.id, it.type, it.year, it.options) })) .put("collections", collections).put("franchises", franchises).put("catalogueComplete", catalogueComplete).put("computedMs", computedMs).put("tookMs", tookMs) + .put("decided", decided) companion object { fun of(o: JSONObject): DeskReport { fun list(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty() return DeskReport(o.optInt("newlyReleased"), o.optInt("newToLibrary"), o.optInt("scanned"), o.optInt("unread"), list("strongByChannel").map { it.optString("c") to it.optInt("n") }, o.optInt("strong"), - list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l")) }, - list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b")) }, - o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs")) + list("picks").map { DeskPick(it.optString("t"), it.optString("c"), it.optString("g"), it.optString("l"), it.optString("i"), it.optString("k"), it.year(), it.options()) }, + list("asks").map { DeskAsk(it.optString("t"), it.optString("a"), it.optString("b"), it.optString("i"), it.optString("k"), it.year(), it.options(), it.optString("l")) }, + o.optInt("collections"), o.optBoolean("catalogueComplete"), o.optInt("franchises"), o.optLong("computedMs"), o.optLong("tookMs"), o.optInt("decided")) } } } -/** Paul's standing editorial calls so far, shown so the director's reasoning is visible. Decisions on the Chair itself come later. */ +/** A decision you made on the Chair: placed on [channel], or dropped (channel null). Recorded for the Chair to learn from. */ +internal class ChairDecision(val id: String, val title: String, val type: String, val placed: Boolean, val channel: Int?, val suggested: Int?, val atMs: Long) { + fun toJson(): JSONObject = JSONObject().put("i", id).put("t", title).put("k", type).put("p", placed).put("c", channel ?: JSONObject.NULL) + .put("s", suggested ?: JSONObject.NULL).put("a", atMs) + companion object { + fun of(o: JSONObject) = ChairDecision(o.getString("i"), o.optString("t"), o.optString("k"), o.optBoolean("p"), + if (o.isNull("c")) null else o.optInt("c"), if (o.isNull("s")) null else o.optInt("s"), o.optLong("a")) + } +} + +internal fun loadDecisions(file: File): List = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { ChairDecision.of(a.getJSONObject(it)) } } }.getOrDefault(emptyList()) + +/** Adds [decision] to the record, replacing any earlier decision on the same title. Written whole, through a temporary file. */ +internal fun saveDecision(file: File, decision: ChairDecision) = synchronized(DecisionLock) { + val all = loadDecisions(file).filter { it.id != decision.id } + decision + val temp = File(file.parentFile, file.name + ".tmp") + temp.writeText(JSONArray(all.map { it.toJson() }).toString()); if (!temp.renameTo(file)) { file.writeText(temp.readText()); temp.delete() } +} +private val DecisionLock = Any() + +/** The reason a channel suits a title, in a line, from the scoring's own evidence. */ +private fun reasonLine(why: List): String = + why.filter { it.startsWith("themes:") || it.startsWith("genres ") || it.startsWith("British") || it.startsWith("in the ") }.take(2).joinToString("; ") + .ifEmpty { why.firstOrNull { it.startsWith("description like") } ?: "fits the channel's brief" } +/** Where a placed title can go: the scored channels, best first, three at most, never one the scoring ruled out. */ +private fun optionsOf(p: Placement, max: Int = 3): List = p.ranked.take(max).map { (b, _, why) -> DeskOption(b.number, b.name, reasonLine(why)) } + +/** Paul's standing editorial calls so far, shown so the director's reasoning is visible. */ internal val DirectorRules = listOf("Made-for-TV romances: not for Orbit", "Tales from the Crypt: After Dark strand", "Black Mirror: Paradox from 21:00") /** @@ -89,12 +134,15 @@ internal fun franchiseLinks(file: File, pools: Map>): Triple>, released: List, carriedWorks: Set, - originals: Map, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): DeskReport = - buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone).getValue(mode) + originals: Map, newToLibrary: Int, catalogueComplete: Boolean, mode: DirectorMode, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), + decided: Set = emptySet()): DeskReport = + buildDeskReports(evidenceFile, collectionsFile, pools, released, carriedWorks, originals, newToLibrary, catalogueComplete, nowMs, zone, decided).getValue(mode) /** All three modes' reports from one scoring pass: the modes only differ in how many discoveries they show and how far they reach. */ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools: Map>, released: List, carriedWorks: Set, - originals: Map, newToLibrary: Int, catalogueComplete: Boolean, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): Map { + originals: Map, newToLibrary: Int, catalogueComplete: Boolean, nowMs: Long, zone: ZoneId = ZoneId.systemDefault(), + /** Titles you've placed or dropped on the Chair: never shown again, so the next ones take their place. */ + decided: Set = emptySet()): Map { val started = System.nanoTime() val evidence = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() } .associate { it.optString("id") to TitleEvidence.of(it, originals[it.optString("id")]) } else emptyMap() @@ -109,17 +157,19 @@ internal fun buildDeskReports(evidenceFile: File, collectionsFile: File, pools: val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() val premiered = released.associate { it.id to it.premiere } fun recent(id: String) = premiered[id]?.takeIf { it.length == 10 }?.let { runCatching { LocalDate.parse(it) }.getOrNull() }?.let { !it.isBefore(today.minusDays(14)) } == true - val asks = placed.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map { DeskAsk(it.evidence.name, it.top.name, it.second.name) } + val undecided = placed.filter { it.evidence.id !in decided } + val asks = undecided.filter { it.tier == FitTier.NeedsEye }.sortedByDescending { it.s1 }.take(3).map { + DeskAsk(it.evidence.name, it.top.name, it.second.name, it.evidence.id, it.evidence.type, it.evidence.year, optionsOf(it, 2), it.evidence.overview?.let { o -> sentence(o) } ?: "No description in the library yet") } val tookMs = (System.nanoTime() - started) / 1_000_000 return DirectorMode.values().associateWith { mode -> // Discoveries: the strongest strong matches, one per channel; Surprise Me reaches into the possibles for channels the strong list missed. - val pool = strong.sortedByDescending { it.s1 }.distinctBy { it.top.number }.toMutableList() - if (mode == DirectorMode.Surprise) pool += placed.filter { it.tier == FitTier.Possible && pool.none { p -> p.top.number == it.top.number } }.sortedByDescending { it.s1 }.take(2) + val pool = undecided.filter { it.tier == FitTier.Strong }.sortedByDescending { it.s1 }.distinctBy { it.top.number }.toMutableList() + if (mode == DirectorMode.Surprise) pool += undecided.filter { it.tier == FitTier.Possible && pool.none { p -> p.top.number == it.top.number } }.sortedByDescending { it.s1 }.take(2) val picks = pool.take(mode.picks).map { p -> DeskPick(p.evidence.name, p.top.name, when { p.tier == FitTier.Possible -> "SOMETHING DIFFERENT"; recent(p.evidence.id) -> "JUST RELEASED"; else -> "STRONG MATCH" }, - p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet") + p.evidence.overview?.let { sentence(it) } ?: "No description in the library yet", p.evidence.id, p.evidence.type, p.evidence.year, optionsOf(p)) } - DeskReport(candidates.size, newToLibrary, read.size, candidates.size - read.size, byChannel, strong.size, picks, asks, collections, catalogueComplete, franchises, nowMs, tookMs) + DeskReport(candidates.size, newToLibrary, read.size, candidates.size - read.size, byChannel, strong.size, picks, asks, collections, catalogueComplete, franchises, nowMs, tookMs, placed.count { it.evidence.id in decided }) } } @@ -131,8 +181,8 @@ private val DirectorLock = Any() * a time: a second caller waits for the first and then reads its copy. Read only. */ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, userId: String, nowMs: Long = System.currentTimeMillis()): Map? = synchronized(DirectorLock) { - val evidence = store.evidenceFile(); val collections = store.collectionsFile(); val cache = store.directorFile() - val key = listOf(evidence.lastModified(), collections.lastModified(), store.poolsStamp(), catalogue.summary().total ?: 0L).joinToString(":") + val evidence = store.evidenceFile(); val collections = store.collectionsFile(); val cache = store.directorFile(); val decisions = store.decisionsFile() + val key = listOf(evidence.lastModified(), collections.lastModified(), store.poolsStamp(), catalogue.summary().total ?: 0L, decisions.lastModified()).joinToString(":") runCatching { JSONObject(cache.readText()) }.getOrNull()?.takeIf { it.optString("key") == key && nowMs - it.optLong("savedMs") < 24 * 60 * 60_000L }?.let { saved -> val reports = saved.optJSONObject("reports") if (reports != null) return@synchronized DirectorMode.values().associateWith { DeskReport.of(reports.getJSONObject(it.name)) } @@ -143,7 +193,8 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us val carriedWorks = catalogue.worksOf(ids.values.flatten()).values.toSet() val (arrivals, _) = store.loadArrivals(userId) val newToLibrary = arrivals.count { nowMs - it.firstSeenMs < NEW_TO_LIBRARY_DAYS * 24 * 60 * 60_000L } - val reports = buildDeskReports(evidence, collections, ids, released, carriedWorks, catalogue.originalTitles(released.map { it.id }), newToLibrary, catalogue.complete(), nowMs) + val reports = buildDeskReports(evidence, collections, ids, released, carriedWorks, catalogue.originalTitles(released.map { it.id }), newToLibrary, catalogue.complete(), nowMs, + decided = loadDecisions(decisions).map { it.id }.toSet()) runCatching { cache.writeText(JSONObject().put("key", key).put("savedMs", nowMs).put("reports", JSONObject().apply { reports.forEach { (m, r) -> put(m.name, r.toJson()) } }).toString()) } val b = reports.getValue(DirectorMode.Balanced) android.util.Log.i("OrbitSchedule", "director newlyReleased=${b.newlyReleased} read=${b.scanned} strong=${b.strong} asks=${b.asks.size} collections=${b.collections} tookMs=${b.tookMs}") @@ -153,17 +204,46 @@ internal fun directorReports(store: ScheduleStore, catalogue: CatalogueStore, us /** The first sentence of a description, kept short for a card. */ internal fun sentence(text: String): String = text.split(Regex("(?<=[.!?])\\s")).first().let { if (it.length > 90) it.take(87).trimEnd() + "…" else it } +/** + * Places a title from the Chair on [channel] (approved by Paul on 5 November 2026): it's added to that channel's own list, as an + * accepted choice, so it joins the channel's pool at its next top-up. Nothing is booked here and committed bookings never change. + */ +internal fun placeFromChair(store: ScheduleStore, userId: String, id: String, title: String, type: String, year: Int?, channel: Int, suggested: Int?, nowMs: Long = System.currentTimeMillis()) { + require(EmbyClient.validId(id) && (type == "Movie" || type == "Series")) + val choices = store.loadChoices(userId, channel) + if (choices.accepted.none { it.id == id }) + store.saveChoices(userId, channel, ChannelChoices(choices.accepted + Suggestion(id, title.take(160), year, type, "", "Placed by you from the Director's Chair"), choices.rejected - id)) + saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, true, channel, suggested, nowMs)) +} + +/** Drops a title from the Chair: it isn't offered again. No channel or schedule changes. */ +internal fun dropFromChair(store: ScheduleStore, id: String, title: String, type: String, suggested: Int?, nowMs: Long = System.currentTimeMillis()) = + saveDecision(store.decisionsFile(), ChairDecision(id, title.take(160), type, false, null, suggested, nowMs)) + +/** What the Chair's pop-up acts on: one pick or one ask. */ +internal class ChairChoice(val title: String, val id: String, val type: String, val year: Int?, val tag: String?, val line: String, val options: List, val ask: Boolean) + private val ChairNavy = Color(0xFF07163A) private val ChairPanel = Color(0xFF10285A) private val ChairGold = Color(0xFFF2C14E) private val ChairMint = Color(0xFF6EDCAA) private val ChairSky = Color(0xFF78BEFF) -/** Settings › Director's Chair: read only, as approved. [load] builds (or reads back) the report off the main thread. */ +/** + * Settings › Director's Chair. Press a pick or an ask for its pop-up: place it on the suggested channel or another that fits, + * or drop it. [place] and [drop] save the decision off the main thread; nothing is booked from here and auto-approval is off. + */ @Composable -internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier) { +internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequester, modifier: Modifier, + place: (ChairChoice, DeskOption) -> Unit = { _, _ -> }, drop: (ChairChoice) -> Unit = {}) { var mode by remember { mutableStateOf(DirectorMode.Balanced) } - val report by produceState(null, mode) { value = withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() } } + var version by remember { mutableIntStateOf(0) } + var report by remember { mutableStateOf(null) } + // A decision hides its title at once; the report is then worked out again behind the scenes, keeping this one on screen. + var decided by remember { mutableStateOf(emptySet()) } + var open by remember { mutableStateOf(null) } + var said by remember { mutableStateOf(null) } + LaunchedEffect(mode, version) { withContext(Dispatchers.Default) { runCatching { load(mode) }.getOrNull() }?.let { report = it } } Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Text("DIRECTOR'S CHAIR", color = ChairGold, fontFamily = Anton, fontSize = 20.sp) @@ -189,7 +269,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ r.strongByChannel.take(6).forEach { (c, n) -> Text("$c $n", color = Muted, fontSize = 11.sp) } if (r.strongByChannel.size > 6) Text("+${r.strongByChannel.size - 6} more channels", color = Muted, fontSize = 11.sp) Spacer(Modifier.height(4.dp)) - Text("Held as proposals. Nothing is booked.", color = Muted, fontSize = 10.sp) + Text(if (r.decided > 0) "${r.decided} decided by you. The rest are counts only." else "Counts only. Only the few below ask for a decision.", color = Muted, fontSize = 10.sp) // Scan status, always visible: what hasn't been read yet is said, not hidden. val gaps = listOfNotNull("${r.unread} not read yet".takeIf { r.unread > 0 }, "no collections read, so franchises unknown".takeIf { r.collections == 0 }, "franchises unknown: none of the library's ${r.collections} collections is a franchise".takeIf { r.collections > 0 && r.franchises == 0 }, @@ -198,26 +278,77 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ } Column(Modifier.weight(1.3f), verticalArrangement = Arrangement.spacedBy(5.dp)) { Text("WORTH A LOOK", color = Color.White, fontFamily = Anton, fontSize = 14.sp) - if (r.picks.isEmpty()) Text("Nothing stands out this week.", color = Muted, fontSize = 12.sp) - r.picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK), p.line) } + val picks = r.picks.filter { it.id !in decided } + if (picks.isEmpty()) Text("Nothing stands out this week.", color = Muted, fontSize = 12.sp) + picks.forEach { p -> ChairCard(p.tag, p.title, p.channel.uppercase(java.util.Locale.UK), p.line, + onClick = { open = ChairChoice(p.title, p.id, p.type, p.year, p.tag, p.line, p.options, false) }.takeIf { p.options.isNotEmpty() && p.id.isNotEmpty() }) } } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { Text("NEEDS YOUR EYE", color = Color.White, fontFamily = Anton, fontSize = 14.sp) - if (r.asks.isEmpty()) Text("No either-or calls this week.", color = Muted, fontSize = 12.sp) - r.asks.forEach { a -> ChairCard(null, a.title, null, "${a.first} or ${a.second}?") } + val asks = r.asks.filter { it.id !in decided } + if (asks.isEmpty()) Text("No either-or calls this week.", color = Muted, fontSize = 12.sp) + asks.forEach { a -> ChairCard(null, a.title, null, "${a.first} or ${a.second}?", + onClick = { open = ChairChoice(a.title, a.id, a.type, a.year, null, a.line, a.options, true) }.takeIf { a.options.isNotEmpty() && a.id.isNotEmpty() }) } Spacer(Modifier.height(4.dp)) Text("RULES IN FORCE", color = Color.White, fontFamily = Anton, fontSize = 13.sp) DirectorRules.forEach { Text("· $it", color = Muted, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } - Text("Read only: nothing is booked, placed or changed from this screen. Worked out in ${r.tookMs} ms.", color = Muted, fontSize = 10.sp) + Text(said ?: "Press a title to place it or drop it. A placed title joins that channel's list at its next top-up; nothing already booked changes. Worked out in ${r.tookMs} ms.", + color = if (said != null) ChairMint else Muted, fontSize = 10.sp) + } + open?.let { choice -> + ChairPopup(choice, onPlace = { option -> + open = null; decided = decided + choice.id; place(choice, option) + said = "${choice.title} placed on ${option.name}. It joins the channel at its next top-up, within the hour."; version++ + }, onDrop = { + open = null; decided = decided + choice.id; drop(choice) + said = "${choice.title} dropped. The Chair won't offer it again."; version++ + }, onClose = { open = null }) + } +} + +/** The pop-up for one title: its suggested channel (or, for an ask, the two it's torn between), any other fits, and Drop. */ +@Composable +private fun ChairPopup(choice: ChairChoice, onPlace: (DeskOption) -> Unit, onDrop: () -> Unit, onClose: () -> Unit) { + var others by remember { mutableStateOf(false) } + androidx.compose.ui.window.Dialog(onDismissRequest = onClose, properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false)) { + val firstButton = remember { FocusRequester() } + LaunchedEffect(others) { runCatching { firstButton.requestFocus() } } + Column(Modifier.width(620.dp).background(ChairNavy, RoundedCornerShape(10.dp)).border(2.dp, ChairGold, RoundedCornerShape(10.dp)).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(if (choice.ask) "NEEDS YOUR EYE" else choice.tag ?: "WORTH A LOOK", color = ChairGold, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + Text(choice.title.uppercase(java.util.Locale.UK), color = Color.White, fontFamily = Anton, fontSize = 28.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(listOfNotNull(choice.year?.toString(), if (choice.type == "Series") "Series" else "Film").joinToString(" · "), color = ChairSky, fontSize = 12.sp) + Text(choice.line, color = Color.White, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + // Asks show both channels from the start; a pick shows its suggestion, and the other fits on Change channel. + val shown = when { choice.ask || others -> choice.options; else -> choice.options.take(1) } + shown.forEachIndexed { i, option -> + Row(Modifier.fillMaxWidth().background(ChairPanel, RoundedCornerShape(6.dp)).padding(10.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Column(Modifier.weight(1f)) { + Text(option.name.uppercase(java.util.Locale.UK) + if (i == 0 && !choice.ask) " · SUGGESTED" else "", color = ChairSky, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(option.reason, color = Muted, fontSize = 11.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + OrbitButton("Place on ${option.name}", if (i == 0) Modifier.focusRequester(firstButton) else Modifier, onClick = { onPlace(option) }) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (!choice.ask && !others && choice.options.size > 1) OrbitButton("Change channel", onClick = { others = true }) + OrbitButton("Drop", onClick = onDrop) + OrbitButton("Not now", onClick = onClose) + } + Text("Placing adds it to that channel's own list; it joins at the next top-up and nothing already booked changes. Drop keeps it off the Chair.", + color = Muted, fontSize = 10.sp) + } } } @Composable -private fun ChairCard(tag: String?, title: String, channel: String?, line: String) { +private fun ChairCard(tag: String?, title: String, channel: String?, line: String, onClick: (() -> Unit)? = null) { var focused by remember { mutableStateOf(false) } - Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.focusable().background(if (focused) Color(0xFF1C3A70) else ChairPanel, RoundedCornerShape(6.dp)) + Column(Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier.focusable()) + .background(if (focused) Color(0xFF1C3A70) else ChairPanel, RoundedCornerShape(6.dp)) .border(if (focused) 2.dp else 0.dp, ChairGold, RoundedCornerShape(6.dp)).padding(horizontal = 8.dp, vertical = 5.dp)) { Row { tag?.let { Text(it, Modifier.weight(1f), color = ChairGold, fontSize = 9.sp, fontWeight = FontWeight.SemiBold) }; channel?.let { Text(it, color = ChairSky, fontSize = 9.sp, fontWeight = FontWeight.SemiBold) } } Text(title, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt index 0a04977..6ff6402 100644 --- a/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt +++ b/android/app/src/main/java/tv/orbit/classic/DirectorScoring.kt @@ -227,7 +227,9 @@ internal fun channelScore(e: TitleEvidence, b: Brief, text: ChannelText, links: internal enum class FitTier(val label: String) { Strong("Strong match"), Possible("Possible"), NeedsEye("Needs your eye"), None("No clear home") } -internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Double, val second: Brief, val s2: Double, val why: List) { +internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Double, val second: Brief, val s2: Double, val why: List, + /** Every channel the title can go on (a score above nothing), best first, each with its reasons: the Chair's other homes for it. */ + val ranked: List>> = emptyList()) { /** Strong: 0.45 or more and clearly ahead. Possible: 0.35 or more. Needs your eye: two channels within 15%. As measured on the samples. */ val tier get() = when { s1 >= .45 && s2 < s1 * .85 -> FitTier.Strong @@ -240,7 +242,7 @@ internal class Placement(val evidence: TitleEvidence, val top: Brief, val s1: Do internal fun placeTitle(e: TitleEvidence, text: ChannelText, links: FranchiseLinks = FranchiseLinks.Unknown): Placement { val scored = Briefs.map { b -> channelScore(e, b, text, links).let { (s, why) -> Triple(b, s, why) } }.sortedByDescending { it.second } val (b1, s1, why) = scored[0]; val (b2, s2, _) = scored[1] - return Placement(e, b1, s1, b2, s2, why) + return Placement(e, b1, s1, b2, s2, why, scored.filter { it.second > 0 }) } internal fun closeEnough(a: Double, b: Double) = abs(a - b) < 1e-6 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 f74e5ba..0f624de 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -509,7 +509,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } SettingsScreen(settingsSection, { settingsSection = it }, modifier, busy, message, HttpsEmbyTransport.SERVER, sample?.total, arrivals = { detail, first -> NewArrivalsDetail(::loadArrivalsReport, first, detail) }, - director = { detail, first -> DirectorChair(::loadDirectorReport, first, detail) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, + director = { detail, first -> DirectorChair(::loadDirectorReport, first, detail, + place = { choice, option -> session?.let { active -> scope.launch(Dispatchers.IO) { runCatching { + placeFromChair(scheduleStore, active.accountId, choice.id, choice.title, choice.type, choice.year, option.number, choice.options.firstOrNull()?.number) + android.util.Log.i("OrbitSchedule", "director placed channel=${option.number} suggested=${option.number == choice.options.firstOrNull()?.number}") + }.onFailure { android.util.Log.w("OrbitSchedule", "director placeFailed=${it.javaClass.simpleName}") } } } }, + drop = { choice -> scope.launch(Dispatchers.IO) { runCatching { dropFromChair(scheduleStore, choice.id, choice.title, choice.type, choice.options.firstOrNull()?.number) } + .onFailure { android.util.Log.w("OrbitSchedule", "director dropFailed=${it.javaClass.simpleName}") }; android.util.Log.i("OrbitSchedule", "director dropped") } }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) }, onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on -> val tots = scheduledChannels[TOTS_NUMBER] val active = session diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt index c696bf5..b9305a8 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackProtocol.kt @@ -363,8 +363,9 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v checkActive() if (pool.any { it.id == accepted.id || seriesKey(it.title) == seriesKey(accepted.title) }) continue if (accepted.type == "Movie") { - val source = accepted.sourceId ?: continue - val duration = accepted.durationMs ?: continue + // A film placed from the Director's Chair carries no copy yet: its first copy this box can play is found here, or it waits. + val (source, duration) = if (accepted.sourceId != null && accepted.durationMs != null) accepted.sourceId to accepted.durationMs + else { requests++; playableFilm(accepted.id, programming.englishAudio) ?: continue } pool += PoolSeries(accepted.id, accepted.title, listOf(PoolEpisode(accepted.id, source, EpisodeMark(1, 1), accepted.title, duration)), accepted.group) } else { val whole = readWholeSeries(transport, userId, ::currentToken, SeriesCandidate(accepted.id, accepted.title, accepted.year, ""), checkActive, onRequest = { requests++ }, canDecode = BoxDecoders::canDecode, englishAudio = programming.englishAudio) @@ -374,6 +375,15 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v // An editorial withdrawal takes that exact item out, however it arrived: list, old line-up or accepted suggestion. return PoolRead(withoutWithdrawn(pool, programming.withdrawn), requests, partial.carried) } + /** One film's first copy this box can play, as (source id, runtime), by its exact library item; null if it has none. */ + private fun playableFilm(id: String, englishAudio: Boolean): Pair? { + if (!EmbyClient.validId(id)) return null + val reply = runCatching { transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&Ids=$id&Fields=MediaSources&EnableImages=false&EnableUserData=false", + token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)) }.getOrNull() ?: return null + if (reply.status !in 200..299) return null + val row = EmbyClient.parse(reply.body).optJSONArray("Items")?.optJSONObject(0)?.takeIf { it.optString("Id") == id && it.optString("Type") == "Movie" } ?: return null + return playableSource(row, BoxDecoders::canDecode, englishAudio) + } fun suggestFor(rules: List, alreadyInPool: Set, rejected: Set, takenTitles: Set = emptySet(), checkActive: () -> Unit = {}) = findSuggestions(transport, userId, ::currentToken, rules, alreadyInPool, rejected, checkActive, BoxDecoders::canDecode, takenTitles, englishAudio = true) fun fetchArtwork(path: String): ByteArray? = (transport as? HttpsEmbyTransport)?.fetchImage(path, currentToken()) diff --git a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt index 2cd4c4e..ad3a45c 100644 --- a/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt +++ b/android/app/src/main/java/tv/orbit/classic/ScheduleStore.kt @@ -54,6 +54,8 @@ internal class ScheduleStore(private val directory: File) { fun collectionsFile() = File(directory, "collections-0.jsonl") /** The Director's Chair's last report, so the screen opens straight away. */ fun directorFile() = File(directory, "director-0.json") + /** Your decisions on the Director's Chair ([ChairDecision]): what you placed where, and what you dropped. */ + fun decisionsFile() = File(directory, "director-decisions-0.json") /** When the channels' pools last changed, without reading them: their files' times together. */ fun poolsStamp(): Long = directory.listFiles { f -> f.name.startsWith("episodes-") && f.name.endsWith(".json") }?.sumOf { it.lastModified() } ?: 0L diff --git a/android/app/src/test/java/tv/orbit/classic/DirectorChairTest.kt b/android/app/src/test/java/tv/orbit/classic/DirectorChairTest.kt index 766f313..977b548 100644 --- a/android/app/src/test/java/tv/orbit/classic/DirectorChairTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/DirectorChairTest.kt @@ -96,6 +96,39 @@ class DirectorChairTest { assertTrue(ev.exists()) } + @Test fun picksCarryTheirChoicesAndDecidedTitlesMakeWayForTheNext() { + val cands = listOf(line("101", "Ocean Life", "Series", listOf("Documentary"), "Wildlife of the ocean: reefs, sharks and the species of the deep sea."), + line("102", "Forest Life", "Series", listOf("Documentary"), "Wildlife of the forest: animals and species of the ancient woods and nature.")) + val (ev, sets) = files(cands) + val released = listOf(arrival("101", "Ocean Life"), arrival("102", "Forest Life")) + val before = buildDeskReport(ev, sets, pools, released, emptySet(), emptyMap(), 2, true, DirectorMode.Balanced, 1_793_800_000_000) + val pick = before.picks.first() + assertEquals(1, before.picks.size); assertEquals("Series", pick.type) // one pick per channel: both are Terra's + assertEquals(201, pick.options.first().number) // the suggested channel first + assertTrue(pick.options.size <= 3); assertTrue(pick.options.none { it.number == 107 }) // never a channel the scoring ruled out (Evidence takes no documentaries) + assertTrue(pick.options.first().reason.isNotBlank()) + // Once decided, a title isn't shown again; the counts stay as they were. + val after = buildDeskReport(ev, sets, pools, released, emptySet(), emptyMap(), 2, true, DirectorMode.Balanced, 1_793_800_000_000, decided = setOf(pick.id)) + assertTrue(after.picks.none { it.id == pick.id }); assertEquals(before.strong, after.strong); assertEquals(1, after.decided) + assertEquals(setOf("101", "102") - pick.id, after.picks.map { it.id }.toSet()) // the next one takes its place + val back = DeskReport.of(before.toJson()).picks.first() + assertEquals(pick.id, back.id); assertEquals(pick.options.map { it.number }, back.options.map { it.number }); assertEquals(pick.year, back.year) + } + + @Test fun placingAddsToTheChannelsOwnListAndDropOnlyRecords() { + val dir = kotlin.io.path.createTempDirectory("chair").toFile().apply { deleteOnExit() } + val store = ScheduleStore(dir); val user = "a1b2c3" + placeFromChair(store, user, "5000001", "Ocean Life", "Series", 2026, 201, 201, 10) + placeFromChair(store, user, "5000001", "Ocean Life", "Series", 2026, 201, 201, 11) // twice: still once on the list + assertEquals(listOf("5000001"), store.loadChoices(user, 201).accepted.map { it.id }) + dropFromChair(store, "5000002", "Wedding Bells", "Movie", 609, 12) + assertTrue(store.loadChoices(user, 609).accepted.isEmpty()) + val record = loadDecisions(store.decisionsFile()) + assertEquals(listOf("5000001" to true, "5000002" to false), record.map { it.id to it.placed }) + assertEquals(201, record.first().channel); assertNull(record.last().channel); assertEquals(609, record.last().suggested) + assertThrows(IllegalArgumentException::class.java) { placeFromChair(store, user, "5000003", "A Season", "Season", null, 201, 201) } + } + @Test fun aReportReadsBackAsItWasSaved() { val r = DeskReport(10, 2, 9, 1, listOf("Terra" to 1), 1, listOf(DeskPick("A", "Terra", "STRONG MATCH", "Line")), listOf(DeskAsk("B", "Sci-Fi", "Horror")), 0, false, 0, 5, 6) val back = DeskReport.of(r.toJson())