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())