2026-11-07 19:59:47 +00:00
const STATION_TONES = { playing : "ok" , loading : "info" , reload : "info" , blocked : "warn" , error : "bad" , due : "teal" , stopped : "neutral" , idle : "neutral" , off : "neutral" };
const NOTE_ID = /^[0-9a-f]{16}$/ ;
let radioDom = null ;
function voiceReplay ( note ) {
2026-11-08 17:26:51 +00:00
return typeof note === "string" && NOTE_ID . test ( note ) ? h ( "audio" , { class : "voice-replay" , controls : true , preload : "none" , src : `/api/voice/ ${ note } .wav` , "aria-label" : "Play voice message" }) : null ;
2026-11-07 19:59:47 +00:00
}
function daysText ( days ) {
2026-11-08 17:26:51 +00:00
return { "0,1,2,3,4,5,6" : "daily" , "0,1,2,3,4" : "weekdays" , "5,6" : "on weekends" }[ days . join ( "," )] || days . map (( day ) => WEEKDAYS [ day ]). join ( " " );
2026-11-07 19:59:47 +00:00
}
function scheduleOf ( stream ) {
2026-11-08 17:26:51 +00:00
return ` ${ daysText ( stream . days ) } , ${ stream . from === "00:00" && stream . to === "24:00" ? "around the clock" : ` ${ stream . from } to ${ stream . to } ` } ` ;
2026-11-07 19:59:47 +00:00
}
function stationState ( stream , index ) {
if ( index === radio . index && radio . state ) {
return [ radio . state , radio . short , radio . note ];
}
if ( ! stream . on ) {
2026-11-08 17:26:51 +00:00
return [ "off" , "off" , "Switched off in the settings" ];
2026-11-07 19:59:47 +00:00
}
if ( ! Ambience . activeStream ([ stream ], Ambience . slotOf ( new Date ()))) {
2026-11-08 17:26:51 +00:00
return [ "idle" , "not due" , `Plays ${ scheduleOf ( stream ) } ; ▶ plays it now` ];
2026-11-07 19:59:47 +00:00
}
2026-11-08 17:26:51 +00:00
return radio . pick === - 1 ? [ "stopped" , "stopped" , "Stopped by hand; ▶ plays it again" ] : [ "due" , "due" , "Due as soon as no earlier station plays" ];
2026-11-07 19:59:47 +00:00
}
function stationRow ( stream , index ) {
const [ kind , label , note ] = stationState ( stream , index );
const live = index === radio . index && ( kind === "playing" || kind === "loading" );
const action = live
2026-11-08 17:26:51 +00:00
? iconBtn ( "bi-stop-fill" , "Stop" , "btn small ghost" , stopStation , `Stop: pause ${ stream . name } ` )
: iconBtn ( "bi-play-fill" , "Play" , "btn small ghost" , () => playStation ( index ), `Play: listen to ${ stream . name } now` );
2026-11-07 19:59:47 +00:00
action . dataset . action = live ? "stop" : "play" ;
return h ( "li" , { class : "station" , "data-station" : index , "data-state" : kind },
h ( "span" , { class : "station-name" , title : stream . name }, stream . name ),
h ( "span" , { class : "station-when" , title : scheduleOf ( stream )}, scheduleOf ( stream )),
h ( "span" , { class : "pill station-note" , "data-tone" : STATION_TONES [ kind ], title : note }, label ),
action );
}
function drawStations () {
if ( ! radioDom ) {
return ;
}
const sig = JSON . stringify ([ settings . streams , radio . index , radio . state , radio . note , radio . pick , Math . floor ( Date . now () / 60000 )]);
if ( sig === radioDom . stationsSig ) {
return ;
}
radioDom . stationsSig = sig ;
radioDom . stations . replaceChildren (...( settings . streams . length
? settings . streams . map ( stationRow )
2026-11-08 17:26:51 +00:00
: [ h ( "li" , { class : "muted" }, "No station yet: add one under ⚙ Settings → Internet radio." )]));
2026-11-07 19:59:47 +00:00
}
function speakerChips () {
return Object . entries ( people ()). sort (([, a ], [, b ]) => a . now . localeCompare ( b . now )). map (([ key , entry ]) => h ( "li" , { class : "speaker" , "data-key" : key , title : entry . now },
2026-11-08 22:06:31 +00:00
h ( "span" , { class : "speaker-face" }, face ( key , false )), h ( "span" , { class : "speaker-name" }, entry . now )));
2026-11-07 19:59:47 +00:00
}
function drawSpeaker () {
const now = speech . current && speech . current . who ? speech . current : null ;
2026-11-08 22:06:31 +00:00
for ( const art of document . querySelectorAll ( ".avatar[data-face]" )) {
art . classList . toggle ( "speaking" , Boolean ( now && art . dataset . face === now . who ));
}
2026-11-07 19:59:47 +00:00
if ( radioDom ) {
for ( const chip of radioDom . speakers . querySelectorAll ( ".speaker" )) {
chip . classList . toggle ( "speaking" , Boolean ( now && chip . dataset . key === now . who ));
}
radioDom . caption . hidden = ! now ;
radioDom . caption . replaceChildren (...( now ? [
2026-11-08 22:06:31 +00:00
h ( "span" , { class : "speaker-face" }, face ( now . who , false )),
2026-11-08 17:26:51 +00:00
h ( "strong" , {}, ` ${ nameOf ( now . who ) } speaking` ),
2026-11-07 19:59:47 +00:00
h ( "span" , { class : "caption-line" , title : now . line }, now . line ),
2026-11-08 17:26:51 +00:00
iconBtn ( "bi-stop-fill" , "Stop speaking" , "btn small ghost" , cancelSpeech ),
2026-11-07 19:59:47 +00:00
] : []));
}
if ( ui . screen === "agents" && ( ui . views . agents === "orbit" || ui . views . agents === "chord" )) {
stage ();
}
}
function sentRows () {
const rows = state . messages . slice ( - 5 ). reverse ();
return rows . length ? h ( "ul" , { class : "mini" }, rows . map (( message ) => h ( "li" , {},
h ( "div" , { class : "row" }, klass ( message . class || "post" ), when ( message . created_ts )),
2026-11-08 17:26:51 +00:00
h ( "span" , { class : "muted" }, `to ${ message . to === "*" ? "all" : message . to } ` ),
2026-11-07 19:59:47 +00:00
h ( "span" , {}, short ( message . text )),
2026-11-08 17:26:51 +00:00
voiceReplay ( message . voice )))) : h ( "p" , { class : "muted" }, "Nothing sent in the chosen time range yet." );
2026-11-07 19:59:47 +00:00
}
function drawRadio () {
if ( ! radioDom ) {
return ;
}
const crew = JSON . stringify ( people ());
if ( crew !== radioDom . crew ) {
radioDom . crew = crew ;
radioDom . speakers . replaceChildren (... speakerChips ());
drawSpeaker ();
}
const sent = JSON . stringify ( state . messages . slice ( - 5 ));
if ( sent !== radioDom . sent ) {
radioDom . sent = sent ;
radioDom . outbox . replaceChildren ( sentRows ());
}
drawStations ();
}
function radioScreen () {
2026-11-08 17:26:51 +00:00
const speakers = h ( "ul" , { class : "speakers" , "aria-label" : "Speakers" });
2026-11-07 19:59:47 +00:00
const caption = h ( "div" , { class : "radio-caption" , role : "status" , "aria-live" : "polite" , hidden : true });
2026-11-08 17:26:51 +00:00
const stations = h ( "ul" , { class : "stations" , "aria-label" : "Stations" });
2026-11-07 19:59:47 +00:00
const outbox = h ( "div" , {});
2026-11-09 22:22:46 +00:00
const send = h ( "button" , { type : "button" , class : "btn small soft" , ... named ( "Send …" , "Send: the radio spot under the Agents toolbar" ), on : { click : openRadio }}, icon ( "bi-broadcast-pin" ), "Send …" );
radioDom = { speakers , caption , stations , outbox , crew : "" , sent : "" , stationsSig : "" };
2026-11-07 19:59:47 +00:00
return h ( "div" , { class : "stack radio-view" },
h ( "h2" , { class : "title" }, "Radio" ),
h ( "div" , { class : "radio-grid" },
2026-11-09 22:22:46 +00:00
h ( "section" , { class : "card radio-talk" , "aria-label" : "Radio" }, h ( "div" , { class : "card-head" }, h ( "h3" , { class : "group-title" }, "Radio" ), send ), caption , speakers ),
2026-11-07 19:59:47 +00:00
h ( "div" , { class : "stack" },
2026-11-08 17:26:51 +00:00
h ( "section" , { class : "card" , "aria-label" : "Internet radio" },
h ( "div" , { class : "card-head" }, h ( "h3" , { class : "group-title" }, "Internet radio" ), btn ( "Edit stations" , "btn small ghost" , settingsSheet )),
2026-11-07 19:59:47 +00:00
stations ),
2026-11-08 17:26:51 +00:00
h ( "section" , { class : "card" , "aria-label" : "Recently sent" }, h ( "h3" , { class : "group-title" }, "Recently sent" ), outbox ))));
2026-11-07 19:59:47 +00:00
}