@@ -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 < DeskOption > = 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 < DeskOption > = emptyList ( ) , val line : String = " " )
private fun JSONObject . item ( id : String , type : String , year : Int ? , options : List < DeskOption > ) =
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 < Pair < String , Int > > , val strong : Int ,
val picks : List < DeskPick > , val asks : List < DeskAsk > , val collections : Int , val catalogueComplete : Boolean , val franchises : Int , val computedMs : Long , val tookMs : Long ) {
val picks : List < DeskPick > , val asks : List < DeskAsk > , 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 . D ecisions on the Chair itself come later . */
/** A decision you made on the Chair: placed on [channel], or dropped (channel null) . R ecorded 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 < ChairDecision > = 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 > ) : 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 < DeskOption > = 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<Int, List<String>>): Triple<F
* Titles already on a channel are left out (by work, whichever copy). [mode] sets how many discoveries and how far they reach.
*/
internal fun buildDeskReport ( evidenceFile : File , collectionsFile : File , pools : Map < Int , List < String > > , released : List < Arrival > , carriedWorks : Set < String > ,
originals : Map < String , String > , 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 < String , String > , newToLibrary : Int , catalogueComplete : Boolean , mode : DirectorMode , nowMs : Long , zone : ZoneId = ZoneId . systemDefault ( ) ,
decided : Set < String > = 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 < Int , List < String > > , released : List < Arrival > , carriedWorks : Set < String > ,
originals : Map < String , String > , newToLibrary : Int , catalogueComplete : Boolean , nowMs : Long , zone : ZoneId = ZoneId . systemDefault ( ) ) : Map < DirectorMode , DeskReport > {
originals : Map < String , String > , 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 < String > = emptySet ( ) ) : Map < DirectorMode , DeskReport > {
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 < DirectorMode , DeskReport > ? = 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 < DeskOption > , 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 < DeskReport ? > ( null , mode ) { value = withContext ( Dispatchers . Default ) { runCatching { load ( mode ) } . getOrNull ( ) } }
var version by remember { mutableIntStateOf ( 0 ) }
var report by remember { mutableStateOf < DeskReport ? > ( 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 < String > ( ) ) }
var open by remember { mutableStateOf < ChairChoice ? > ( null ) }
var said by remember { mutableStateOf < String ? > ( 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 )