Commitbb097a8bRecorded9 Jul 2026Repositorylive-crafter
Merge live-crafter Wave 1
Changed
src/live-crafter/config.sgl | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
src/live-crafter/dashboard.sgl | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
src/live-crafter/obs.sgl | 25 ++++++++++++++++---
src/live-crafter/restream.sgl | 12 +++++++++
src/live-crafter/theme.sgl | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/phase-test.sgl | 40 ++++++++++++++++++++++++++++++
test/stream-health-test.sgl | 57 +++++++++++++++++++++++++++++++++++++++++++
7 files changed, 626 insertions(+), 29 deletions(-)Diff
A merge. Shown against its first parent, so this is the effect of merging rather than the work of the branch.
src/live-crafter/config.sglmodified
@@ -54,6 +54,7 @@
54
set-ticker-index! 55
effective-ticker 56
current-ticker-text+57
current-overlay-title 58
59
;; Stream monitor 60
get-monitor-platform@@ -75,6 +76,18 @@
76
volume-stream ;; Volume during stream (background) 77
fade-duration ;; Default fade duration in seconds 78
+79
;; Show-flow phases+80
phase-definitions+81
phase-definition+82
current-phase+83
current-phase-definition+84
current-phase-label+85
set-current-phase!+86
get-phase-ending-countdown+87
set-phase-ending-countdown!+88
clear-phase-ending-countdown!+89
broadcast-phase-update!+90
91
;; Playlist 92
playlist-file 93
get-playlist@@ -104,6 +117,10 @@
117
set-obs-current-scene! 118
get-obs-scenes 119
set-obs-scenes!+120
get-obs-stream-health+121
update-obs-stream-health!+122
reset-obs-stream-health!+123
obs-health-dropping-now? 124
125
;; OBS broadcast 126
broadcast-obs-update!@@ -199,6 +216,9 @@
216
"Follow on the Fediverse - https://fosstodon.org/@daviwil" 217
"Everything Built with Sigil - https://usesigil.org" 218
"Code on Codeberg - https://codeberg.org/FluxHarmonic"))+219
(define *phase-title* #f)+220
(define *phase-ticker-mode* 'normal)+221
(define *phase-ticker-message* #f) 222
223
(define (current-stream-ticker) *stream-ticker*) 224
@@ -235,10 +255,15 @@
255
;;; modulo the effective list length so a stale index (viewer entry just 256
;;; disappeared, item deleted) can never send list-ref out of range. 257
(define (current-ticker-text)−238
(let ((items (effective-ticker)))−239
(if (null? items)−240
""−241
(list-ref items (modulo ticker-index (length items))))))+258
(if (and (eq? *phase-ticker-mode* 'fixed) *phase-ticker-message*)+259
*phase-ticker-message*+260
(let ((items (effective-ticker)))+261
(if (null? items)+262
""+263
(list-ref items (modulo ticker-index (length items)))))))+264
+265
(define (current-overlay-title)+266
(or *phase-title* *stream-title*)) 267
268
;; ============================================================ 269
;; Stream Config Persistence@@ -300,9 +325,93 @@
325
326
;; Audio volume presets 327
(define volume-intro 80) ;; Louder for intro/intermission−303
(define volume-stream 10) ;; Quieter background during stream+328
(define volume-stream 5) ;; Quieter background during stream 329
(define fade-duration 3) ;; Seconds for fade transitions 330
+331
;; ============================================================+332
;; Show-flow Phases+333
;; ============================================================+334
+335
(define *phase-definitions*+336
(list+337
(dict id: 'starting-soon+338
label: "Starting Soon"+339
scene: "Logo Screen"+340
volume-preset: 'intro+341
title: "Starting Soon"+342
ticker-mode: 'fixed+343
ticker-message: "Starting soon"+344
overlay-state: 'starting-soon+345
stop-countdown: #f)+346
(dict id: 'live+347
label: "Live"+348
scene: "Screen New"+349
volume-preset: 'stream+350
title: #f+351
ticker-mode: 'normal+352
ticker-message: #f+353
overlay-state: 'live+354
stop-countdown: #f)+355
(dict id: 'break+356
label: "Break"+357
scene: "Be Right Back"+358
volume-preset: 'intro+359
title: "Be Right Back"+360
ticker-mode: 'fixed+361
ticker-message: "Be right back"+362
overlay-state: 'break+363
stop-countdown: #f)+364
(dict id: 'ending+365
label: "Ending"+366
scene: "Thanks For Watching"+367
volume-preset: 'intro+368
title: "Thanks for Watching"+369
ticker-mode: 'fixed+370
ticker-message: "Thanks for watching"+371
overlay-state: 'ending+372
stop-countdown: 30)))+373
+374
(define *current-phase* #f)+375
(define *phase-ending-countdown* #f)+376
+377
(define (phase-definitions) *phase-definitions*)+378
+379
(define (phase-definition id)+380
(find (lambda (phase)+381
(eq? (dict-ref phase id:) id))+382
*phase-definitions*))+383
+384
(define (current-phase) *current-phase*)+385
+386
(define (current-phase-definition)+387
(and *current-phase* (phase-definition *current-phase*)))+388
+389
(define (current-phase-label)+390
(let ((phase (current-phase-definition)))+391
(if phase+392
(dict-ref phase label: "Phase")+393
"No Phase")))+394
+395
(define (set-current-phase! id)+396
(let ((phase (phase-definition id)))+397
(when phase+398
(set! *current-phase* id)+399
(set! *phase-title* (dict-ref phase title: #f))+400
(set! *phase-ticker-mode* (dict-ref phase ticker-mode: 'normal))+401
(set! *phase-ticker-message* (dict-ref phase ticker-message: #f))+402
phase)))+403
+404
(define (get-phase-ending-countdown) *phase-ending-countdown*)+405
+406
(define (set-phase-ending-countdown! seconds)+407
(set! *phase-ending-countdown* seconds))+408
+409
(define (clear-phase-ending-countdown!)+410
(set! *phase-ending-countdown* #f))+411
+412
(define (broadcast-phase-update!)+413
(broadcast-unified! 'phase))+414
415
;; ============================================================ 416
;; Playlist (loaded from file) 417
;; ============================================================@@ -419,6 +528,16 @@
528
(define *obs-streaming* #f) 529
(define *obs-current-scene* #f) 530
(define *obs-scenes* '())+531
(define *obs-stream-health*+532
#{ active?: #f+533
congestion: #f+534
bitrate-kbps: #f+535
skipped-frames: 0+536
total-frames: 0+537
dropping-now?: #f })+538
(define *obs-last-skipped-frames* #f)+539
(define *obs-last-output-bytes* #f)+540
(define *obs-last-output-duration* #f) 541
542
(define (get-obs-connected?) *obs-connected*) 543
(define (set-obs-connected! v) (set! *obs-connected* v))@@ -432,6 +551,76 @@
551
(define (get-obs-scenes) *obs-scenes*) 552
(define (set-obs-scenes! s) (set! *obs-scenes* s)) 553
+554
(define (get-obs-stream-health) *obs-stream-health*)+555
+556
(define (obs-health-dropping-now?)+557
(dict-ref *obs-stream-health* dropping-now?: #f))+558
+559
(define (stream-status-number data . keys)+560
(let loop ((ks keys))+561
(if (null? ks)+562
#f+563
(let ((value (dict-ref data (car ks) #f)))+564
(if (number? value)+565
value+566
(loop (cdr ks)))))))+567
+568
(define (calculate-bitrate-kbps bytes duration-ms)+569
(if (and bytes duration-ms (> duration-ms 0))+570
(exact (round (/ (* bytes 8.0) (/ duration-ms 1000.0) 1000.0)))+571
#f))+572
+573
(define (stream-status-bitrate-kbps data)+574
(or (stream-status-number data outputBitrate: outputKbitrate: kbitsPerSec:)+575
(let ((bytes (stream-status-number data outputBytes:))+576
(duration-ms (stream-status-number data outputDuration:)))+577
(cond+578
((and bytes duration-ms+579
*obs-last-output-bytes*+580
*obs-last-output-duration*+581
(>= bytes *obs-last-output-bytes*)+582
(> duration-ms *obs-last-output-duration*))+583
(calculate-bitrate-kbps (- bytes *obs-last-output-bytes*)+584
(- duration-ms *obs-last-output-duration*)))+585
(else+586
(calculate-bitrate-kbps bytes duration-ms))))))+587
+588
(define (update-obs-stream-health! data)+589
(let* ((active (dict-ref data outputActive: (get-obs-streaming?)))+590
(congestion (stream-status-number data outputCongestion:))+591
(bitrate (stream-status-bitrate-kbps data))+592
(skipped (or (stream-status-number data outputSkippedFrames:+593
outputDroppedFrames:)+594
0))+595
(total (or (stream-status-number data outputTotalFrames:) 0))+596
(dropping-now (and active+597
*obs-last-skipped-frames*+598
(> skipped *obs-last-skipped-frames*))))+599
(set-obs-streaming! active)+600
(set! *obs-stream-health*+601
(dict active?: active+602
congestion: congestion+603
bitrate-kbps: bitrate+604
skipped-frames: skipped+605
total-frames: total+606
dropping-now?: (if dropping-now #t #f)))+607
(set! *obs-last-skipped-frames* skipped)+608
(set! *obs-last-output-bytes* (stream-status-number data outputBytes:))+609
(set! *obs-last-output-duration* (stream-status-number data outputDuration:))+610
*obs-stream-health*))+611
+612
(define (reset-obs-stream-health!)+613
(set! *obs-last-skipped-frames* #f)+614
(set! *obs-last-output-bytes* #f)+615
(set! *obs-last-output-duration* #f)+616
(set! *obs-stream-health*+617
#{ active?: #f+618
congestion: #f+619
bitrate-kbps: #f+620
skipped-frames: 0+621
total-frames: 0+622
dropping-now?: #f }))+623
624
(define (broadcast-obs-update!) 625
(broadcast-unified! 'obs)) 626
src/live-crafter/dashboard.sglmodified
@@ -14,6 +14,7 @@
14
(sigil random) 15
(sigil http) 16
(sigil sxml)+17
(sigil async) 18
(sigil web routes) 19
(sigil web ui) 20
(sigil web live)@@ -100,6 +101,33 @@
101
(div (@ (id "ticker-add-container")) 102
,(ticker-add-button-sxml)))) 103
+104
(define (phase-button-sxml phase)+105
(let* ((id (dict-ref phase id:))+106
(id-text (symbol->string id))+107
(active? (eq? id (current-phase)))+108
(class (if active?+109
"btn phase-button active"+110
"btn phase-button")))+111
(sg-button (dict-ref phase label:)+112
action: (string-append "/dashboard/phase/" id-text)+113
target: "#phase-strip-container"+114
mode: "inner"+115
class: class)))+116
+117
(define (phase-strip-sxml)+118
(let ((countdown (get-phase-ending-countdown)))+119
`(div (@ (id "phase-strip"))+120
(div (@ (class "phase-buttons"))+121
,@(map phase-button-sxml (phase-definitions)))+122
(div (@ (class "phase-state"))+123
(span (@ (class "phase-label")) ,(current-phase-label))+124
,(if countdown+125
`(span (@ (class "phase-countdown"))+126
,(string-append "Stopping in "+127
(number->string countdown)+128
"s"))+129
"")))))+130
131
;; Map platform symbol to CSS class for indicator dot 132
(define (dashboard-platform-class platform) 133
(case platform@@ -204,6 +232,70 @@
232
"OBS") 233
"OBS Off"))) 234
+235
(define (stream-health-alert?)+236
(or (obs-health-dropping-now?)+237
(and (get-obs-streaming?)+238
(restream-platform-degraded?))))+239
+240
(define (dashboard-header-sxml)+241
`(header (@ (class ,(if (stream-health-alert?)+242
"dashboard-header dashboard-header-alert"+243
"dashboard-header"))+244
(id "dashboard-header")+245
(data-sg-sse "/events"))+246
(h1 "Live Crafter")+247
(div (@ (class "status-indicators"))+248
,(irc-status-indicator-sxml)+249
,(obs-status-indicator-sxml)+250
,(restream-status-indicator-sxml))))+251
+252
(define (health-value value suffix)+253
(if value+254
(string-append (number->string value) suffix)+255
"n/a"))+256
+257
(define (congestion-value congestion)+258
(if congestion+259
(let ((pct (if (<= congestion 1)+260
(exact (round (* congestion 100)))+261
(exact (round congestion)))))+262
(string-append (number->string pct) "%"))+263
"n/a"))+264
+265
(define (stream-health-panel-sxml)+266
(let* ((health (get-obs-stream-health))+267
(active (dict-ref health active?: #f))+268
(bitrate (dict-ref health bitrate-kbps: #f))+269
(congestion (dict-ref health congestion: #f))+270
(skipped (dict-ref health skipped-frames: 0))+271
(total (dict-ref health total-frames: 0))+272
(dropping (dict-ref health dropping-now?: #f))+273
(restream-degraded (and active (restream-platform-degraded?)))+274
(state-class (if (or dropping restream-degraded)+275
"stream-health-state alert"+276
"stream-health-state")))+277
`(div (@ (id "stream-health-panel"))+278
(div (@ (class ,state-class))+279
(span (@ (class ,(if active "status-dot online" "status-dot"))))+280
,(cond+281
(dropping "Frame drops now")+282
(restream-degraded "Restream degraded")+283
(active "Stream healthy")+284
(else "Stream offline")))+285
(div (@ (class "stream-health-grid"))+286
(div (@ (class "stream-health-metric"))+287
(span (@ (class "metric-label")) "Bitrate")+288
(span (@ (class "metric-value")) ,(health-value bitrate " kbps")))+289
(div (@ (class "stream-health-metric"))+290
(span (@ (class "metric-label")) "Congestion")+291
(span (@ (class "metric-value")) ,(congestion-value congestion)))+292
(div (@ (class "stream-health-metric"))+293
(span (@ (class "metric-label")) "Skipped")+294
(span (@ (class "metric-value"))+295
,(string-append (number->string skipped)+296
" / "+297
(number->string total))))))))+298
299
(define (obs-scene-button-sxml scene) 300
(let ((is-current (and (get-obs-current-scene) 301
(string=? scene (get-obs-current-scene)))))@@ -385,20 +477,21 @@
477
(body 478
(div (@ (class "dashboard")) 479
;; Compact header bar−388
(header (@ (class "dashboard-header"))−389
(h1 "Live Crafter")−390
(div (@ (class "status-indicators"))−391
(div (@ (data-sg-sse "/events"))−392
,(irc-status-indicator-sxml))−393
(div (@ (data-sg-sse "/events"))−394
,(obs-status-indicator-sxml))−395
(div (@ (data-sg-sse "/events"))−396
,(restream-status-indicator-sxml))))+480
,(dashboard-header-sxml) 481
482
;; Main content area 483
(div (@ (class "dashboard-main")) 484
;; Left sidebar - controls 485
(div (@ (class "controls-sidebar"))+486
;; Show Flow+487
(div (@ (class "panel"))+488
(div (@ (class "panel-header"))+489
(h2 "Show Flow"))+490
(div (@ (class "panel-body")+491
(data-sg-sse "/events"))+492
(div (@ (id "phase-strip-container"))+493
,(phase-strip-sxml))))+494
495
;; Stream Title 496
(div (@ (class "panel")) 497
(div (@ (class "panel-header"))@@ -489,8 +582,14 @@
582
(data-sg-auto-scroll "bottom")) 583
,@(map chat-message-sxml (message-history)))) 584
−492
;; Right sidebar - stream monitor+585
;; Right sidebar - stream health and monitor 586
(div (@ (class "monitor-sidebar"))+587
(div (@ (class "panel"))+588
(div (@ (class "panel-header"))+589
(h2 "Stream Health"))+590
(div (@ (class "panel-body")+591
(data-sg-sse "/events"))+592
,(stream-health-panel-sxml))) 593
,(stream-monitor-sxml))))))) 594
595
;; ============================================================@@ -582,7 +681,7 @@
681
;; Overlay targets 682
(ui-update target: "#title" 683
mode: "inner"−585
content: `(span ,(current-stream-title)))))+684
content: `(span ,(current-overlay-title))))) 685
686
;; Chat messages - affects both dashboard feed and overlay 687
((chat)@@ -600,9 +699,9 @@
699
700
;; IRC connection state - dashboard status indicator 701
((irc)−603
(ui-update target: "#irc-status"−604
mode: "inner"−605
content: (irc-status-indicator-sxml)))+702
(ui-update target: "#dashboard-header"+703
mode: "morph"+704
content: (dashboard-header-sxml))) 705
706
;; Audio updates - dashboard only 707
((audio)@@ -610,15 +709,31 @@
709
mode: "inner" 710
content: (audio-status-sxml))) 711
+712
;; Phase updates - dashboard + overlay title/ticker+713
((phase)+714
(string-append+715
(ui-update target: "#phase-strip-container"+716
mode: "inner"+717
content: (phase-strip-sxml))+718
(ui-update target: "#title"+719
mode: "inner"+720
content: `(span ,(current-overlay-title)))+721
(ui-update target: "#ticker"+722
mode: "inner"+723
content: (ticker-sxml))))+724
725
;; OBS updates - dashboard only 726
((obs) 727
(string-append 728
(ui-update target: "#obs-panel-content" 729
mode: "inner" 730
content: (obs-panel-inner-sxml))−619
(ui-update target: "#obs-status"−620
mode: "inner"−621
content: (obs-status-indicator-sxml))))+731
(ui-update target: "#stream-health-panel"+732
mode: "morph"+733
content: (stream-health-panel-sxml))+734
(ui-update target: "#dashboard-header"+735
mode: "morph"+736
content: (dashboard-header-sxml)))) 737
738
;; Restream updates - dashboard only 739
((restream)@@ -626,9 +741,12 @@
741
(ui-update target: "#restream-panel-content" 742
mode: "inner" 743
content: (restream-panel-inner-sxml))−629
(ui-update target: "#restream-status"−630
mode: "inner"−631
content: (restream-status-indicator-sxml))))+744
(ui-update target: "#stream-health-panel"+745
mode: "morph"+746
content: (stream-health-panel-sxml))+747
(ui-update target: "#dashboard-header"+748
mode: "morph"+749
content: (dashboard-header-sxml)))) 750
751
;; Ticker updates - affects both dashboard and overlay 752
((ticker)@@ -709,6 +827,62 @@
827
;; OBS Handlers 828
;; ============================================================ 829
+830
(define *ending-countdown-generation* 0)+831
+832
(define (next-ending-countdown-generation!)+833
(set! *ending-countdown-generation* (+ *ending-countdown-generation* 1))+834
*ending-countdown-generation*)+835
+836
(define (phase-volume phase)+837
(case (dict-ref phase volume-preset: 'stream)+838
((intro) volume-intro)+839
((stream) volume-stream)+840
(else volume-stream)))+841
+842
(define (start-ending-countdown! seconds generation)+843
(set-phase-ending-countdown! seconds)+844
(broadcast-phase-update!)+845
(go+846
(let loop ((remaining seconds))+847
(when (and (eq? (current-phase) 'ending)+848
(= generation *ending-countdown-generation*)+849
(> remaining 0))+850
(sleep 1)+851
(let ((next (- remaining 1)))+852
(when (and (eq? (current-phase) 'ending)+853
(= generation *ending-countdown-generation*))+854
(set-phase-ending-countdown! next)+855
(broadcast-phase-update!)+856
(if (> next 0)+857
(loop next)+858
(when (and (eq? (current-phase) 'ending)+859
(= generation *ending-countdown-generation*))+860
(obs-stop-stream!)+861
(clear-phase-ending-countdown!)+862
(broadcast-phase-update!)))))))))+863
+864
(define (run-phase! phase)+865
(let ((countdown-generation (next-ending-countdown-generation!)))+866
(set-current-phase! (dict-ref phase id:))+867
(clear-phase-ending-countdown!)+868
(let ((scene (dict-ref phase scene: #f)))+869
(when scene+870
(obs-switch-scene! scene)))+871
(start-fade (phase-volume phase) fade-duration)+872
(broadcast-phase-update!)+873
(broadcast-dashboard-update!)+874
(let ((countdown (dict-ref phase stop-countdown: #f)))+875
(when countdown+876
(start-ending-countdown! countdown countdown-generation)))))+877
+878
(define (handle-phase req)+879
(let* ((phase-id-text (path-param req "id"))+880
(phase-id (and phase-id-text (string->symbol phase-id-text)))+881
(phase (and phase-id (phase-definition phase-id))))+882
(when phase+883
(run-phase! phase))+884
(http-response/html 200 (sxml->xml (phase-strip-sxml)))))+885
886
(define (handle-obs-connect req) 887
(obs-connect!) 888
(http-response/html 200 (sxml->xml (obs-panel-inner-sxml))))@@ -801,6 +975,7 @@
975
(route method: 'POST pattern: "/test" handler: handle-test) 976
(route method: 'POST pattern: "/dashboard/irc/reconnect" handler: handle-irc-reconnect) 977
(route method: 'POST pattern: "/dashboard/highlight/:id" handler: handle-highlight)+978
(route method: 'POST pattern: "/dashboard/phase/:id" handler: handle-phase) 979
;; OBS routes 980
(route method: 'POST pattern: "/dashboard/obs/connect" handler: handle-obs-connect) 981
(route method: 'POST pattern: "/dashboard/obs/disconnect" handler: handle-obs-disconnect)src/live-crafter/obs.sglmodified
@@ -56,6 +56,7 @@
56
(define *ws-connection* #f) 57
(define *request-id* 0) 58
(define *pending-scene* #f) ;; Scene we're waiting for OBS to confirm+59
(define stream-health-poll-interval 5) 60
61
;; Generate unique request ID 62
(define (next-request-id!)@@ -193,6 +194,8 @@
194
((string=? event-type "StreamStateChanged") 195
(let ((active (dict-ref (dict-ref data eventData:) outputActive:))) 196
(set-obs-streaming! active)+197
(when (not active)+198
(reset-obs-stream-health!)) 199
(broadcast-obs-update!)))))) 200
201
;;; Handle Request Response@@ -222,9 +225,8 @@
225
;; Stream status response 226
((string=? request-type "GetStreamStatus") 227
(when response-data−225
(let ((active (dict-ref response-data outputActive:)))−226
(set-obs-streaming! active)−227
(broadcast-obs-update!)))))))+228
(update-obs-stream-health! response-data)+229
(broadcast-obs-update!)))))) 230
231
;; ============================================================ 232
;; Connection Management@@ -240,7 +242,8 @@
242
(begin 243
(set! *ws-connection* conn) 244
;; Start receive loop in background−243
(go (obs-receive-loop)))+245
(go (obs-receive-loop))+246
(go (obs-stream-health-poll-loop conn))) 247
(begin 248
(obs-log "Connection failed") 249
(set-obs-connected! #f)@@ -253,6 +256,7 @@
256
(set! *ws-connection* #f) 257
(set-obs-connected! #f) 258
(set-obs-streaming! #f)+259
(reset-obs-stream-health!) 260
(set-obs-current-scene! #f) 261
(set-obs-scenes! '()) 262
(broadcast-obs-update!)@@ -273,6 +277,8 @@
277
(obs-log "Connection closed") 278
(set! *ws-connection* #f) 279
(set-obs-connected! #f)+280
(set-obs-streaming! #f)+281
(reset-obs-stream-health!) 282
(broadcast-obs-update!)) 283
((ws-message? msg) 284
(handle-message (ws-message-data msg))@@ -322,6 +328,17 @@
328
;; State Refresh 329
;; ============================================================ 330
+331
;;; Poll OBS stream health while this WebSocket connection remains current.+332
;;; The loop only sends GetStreamStatus while streaming; StreamStateChanged+333
;;; events are enough to wake the dashboard into the live/offline state.+334
(define (obs-stream-health-poll-loop conn)+335
(let loop ()+336
(when (eq? conn *ws-connection*)+337
(when (get-obs-streaming?)+338
(send-request! "GetStreamStatus"))+339
(sleep stream-health-poll-interval)+340
(loop))))+341
342
;;; Refresh all OBS state 343
(define (obs-refresh-state!) 344
(when (get-obs-connected?)src/live-crafter/restream.sglmodified
@@ -51,6 +51,7 @@
51
restream-connected? 52
restream-connection-state 53
restream-platform-connections+54
restream-platform-degraded? 55
56
;; Broadcast 57
broadcast-restream-update!@@ -406,6 +407,17 @@
407
(define (restream-platform-connections) 408
*platform-connections*) 409
+410
(define (restream-platform-status-degraded? status)+411
(and status+412
(not (string=? status "connected"))+413
(not (string=? status "connecting"))))+414
+415
(define (restream-platform-degraded?)+416
(any (lambda (entry)+417
(restream-platform-status-degraded?+418
(dict-ref (cdr entry) status: #f)))+419
(dict->alist *platform-connections*)))+420
421
;; ============================================================ 422
;; Platform Mapping 423
;; ============================================================src/live-crafter/theme.sglmodified
@@ -71,6 +71,11 @@
71
(border-bottom "1px solid var(--color-border)") 72
(flex-shrink "0")) 73
+74
(css ".dashboard-header.dashboard-header-alert"+75
(background-color "rgba(248, 81, 73, 0.18)")+76
(border-bottom-color "var(--color-error)")+77
(box-shadow "inset 0 -2px 0 var(--color-error)"))+78
79
(css ".dashboard-header h1" 80
(margin "0") 81
(font-size "16px")@@ -97,6 +102,9 @@
102
(css ".status-dot.online" 103
(background-color "var(--color-success)")) 104
+105
(css ".status-dot.alert"+106
(background-color "var(--color-error)"))+107
108
;; Main content area - fills remaining space 109
(css ".dashboard-main" 110
(display "flex")@@ -195,6 +203,53 @@
203
(padding-top "12px") 204
(border-top "1px solid var(--color-border)")) 205
+206
;; Show-flow phase strip+207
(css "#phase-strip"+208
(display "flex")+209
(flex-direction "column")+210
(gap "8px"))+211
+212
(css ".phase-buttons"+213
(display "grid")+214
(grid-template-columns "repeat(2, minmax(0, 1fr))")+215
(gap "6px"))+216
+217
(css ".phase-button"+218
(width "100%")+219
(padding "6px 8px")+220
(font-size "12px")+221
(white-space "nowrap")+222
(overflow "hidden")+223
(text-overflow "ellipsis"))+224
+225
(css ".phase-button.active"+226
(background-color "var(--accent-yellow)")+227
(border-color "var(--accent-yellow)")+228
(box-shadow "0 0 0 2px rgba(227, 179, 65, 0.28)")+229
(color "#1f1700")+230
(font-weight "700"))+231
+232
(css ".phase-button.active:hover"+233
(background-color "var(--accent-yellow)")+234
(border-color "var(--accent-yellow)")+235
(color "#1f1700"))+236
+237
(css ".phase-state"+238
(display "flex")+239
(justify-content "space-between")+240
(align-items "center")+241
(gap "8px")+242
(font-size "12px")+243
(color "var(--color-text-muted)"))+244
+245
(css ".phase-state .phase-label"+246
(font-weight "600")+247
(color "var(--color-text)"))+248
+249
(css ".phase-countdown"+250
(color "var(--color-error)")+251
(font-weight "600"))+252
253
;; Events feed styles - fills available height 254
(css ".events-feed" 255
(flex "1")@@ -448,6 +503,58 @@
503
(margin "0 0 12px 0") 504
(color "var(--color-text-muted)")) 505
+506
;; Stream health panel+507
(css "#stream-health-panel"+508
(display "flex")+509
(flex-direction "column")+510
(gap "10px"))+511
+512
(css ".stream-health-state"+513
(display "flex")+514
(align-items "center")+515
(gap "8px")+516
(padding "6px 8px")+517
(background-color "var(--color-bg)")+518
(border "1px solid var(--color-border)")+519
(border-radius "4px")+520
(font-size "12px")+521
(color "var(--color-text-muted)"))+522
+523
(css ".stream-health-state.alert"+524
(color "var(--color-error)")+525
(border-color "var(--color-error)")+526
(background-color "rgba(248, 81, 73, 0.10)"))+527
+528
(css ".stream-health-state.alert .status-dot"+529
(background-color "var(--color-error)"))+530
+531
(css ".stream-health-grid"+532
(display "grid")+533
(grid-template-columns "repeat(3, minmax(0, 1fr))")+534
(gap "8px"))+535
+536
(css ".stream-health-metric"+537
(display "flex")+538
(flex-direction "column")+539
(gap "2px")+540
(padding "8px")+541
(background-color "var(--color-bg)")+542
(border-radius "4px")+543
(min-width "0"))+544
+545
(css ".stream-health-metric .metric-label"+546
(font-size "10px")+547
(text-transform "uppercase")+548
(letter-spacing "0.5px")+549
(color "var(--color-text-muted)"))+550
+551
(css ".stream-health-metric .metric-value"+552
(font-size "13px")+553
(font-weight "600")+554
(white-space "nowrap")+555
(overflow "hidden")+556
(text-overflow "ellipsis"))+557
558
;; Restream Panel styles 559
(css ".restream-disconnected" 560
(text-align "center")test/phase-test.sgladded
@@ -0,0 +1,40 @@
+1
;;; Tests for show-flow phase configuration/state.+2
+3
(import (sigil core)+4
(sigil dict)+5
(sigil test)+6
(live-crafter config))+7
+8
(test-group "show-flow phases"+9
+10
(test "default phase definitions include David's OBS scene mapping"+11
(assert-equal "Logo Screen"+12
(dict-ref (phase-definition 'starting-soon) scene:))+13
(assert-equal "Screen New"+14
(dict-ref (phase-definition 'live) scene:))+15
(assert-equal "Be Right Back"+16
(dict-ref (phase-definition 'break) scene:))+17
(assert-equal "Thanks For Watching"+18
(dict-ref (phase-definition 'ending) scene:)))+19
+20
(test "starting soon uses phase overlay title and fixed ticker"+21
(set-current-phase! 'starting-soon)+22
(assert-equal 'starting-soon (current-phase))+23
(assert-equal "Starting Soon" (current-overlay-title))+24
(assert-equal "Starting soon" (current-ticker-text)))+25
+26
(test "live restores stream title and normal ticker rotation"+27
(set-stream-title! "Episode Title")+28
(set-stream-ticker! (list "First" "Second"))+29
(set-ticker-index! 1)+30
(set-current-phase! 'live)+31
(assert-equal "Episode Title" (current-overlay-title))+32
(assert-equal "Second" (current-ticker-text)))+33
+34
(test "ending carries a stop countdown setting"+35
(let ((phase (phase-definition 'ending)))+36
(assert-equal 30 (dict-ref phase stop-countdown:))+37
(set-phase-ending-countdown! 30)+38
(assert-equal 30 (get-phase-ending-countdown))+39
(clear-phase-ending-countdown!)+40
(assert-equal #f (get-phase-ending-countdown)))))test/stream-health-test.sgladded
@@ -0,0 +1,57 @@
+1
;;; Tests for OBS stream-health state.+2
+3
(import (sigil core)+4
(sigil dict)+5
(sigil test)+6
(live-crafter config))+7
+8
(test-group "OBS stream health"+9
+10
(test "first nonzero skipped-frame total is historical, not active dropping"+11
(reset-obs-stream-health!)+12
(let ((h (update-obs-stream-health!+13
#{ outputActive: #t+14
outputSkippedFrames: 12+15
outputTotalFrames: 1200+16
outputCongestion: 0.25+17
outputBytes: 750000+18
outputDuration: 3000 })))+19
(assert-true (dict-ref h active?: #f))+20
(assert-false (dict-ref h dropping-now?: #t))+21
(assert-equal 12 (dict-ref h skipped-frames:))+22
(assert-equal 1200 (dict-ref h total-frames:))+23
(assert-equal 2000 (dict-ref h bitrate-kbps:))+24
(assert-equal 0.25 (dict-ref h congestion:))))+25
+26
(test "skipped-frame increase while active marks active dropping"+27
(reset-obs-stream-health!)+28
(update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 2 })+29
(let ((h (update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 4 })))+30
(assert-true (dict-ref h dropping-now?: #f))+31
(assert-true (obs-health-dropping-now?))))+32
+33
(test "bitrate uses the most recent OBS status window after first sample"+34
(reset-obs-stream-health!)+35
(update-obs-stream-health!+36
#{ outputActive: #t+37
outputBytes: 100000+38
outputDuration: 1000 })+39
(let ((h (update-obs-stream-health!+40
#{ outputActive: #t+41
outputBytes: 250000+42
outputDuration: 2000 })))+43
(assert-equal 1200 (dict-ref h bitrate-kbps:))))+44
+45
(test "same skipped-frame total clears active dropping"+46
(reset-obs-stream-health!)+47
(update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 4 })+48
(update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 5 })+49
(let ((h (update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 5 })))+50
(assert-false (dict-ref h dropping-now?: #t))))+51
+52
(test "inactive status resets active flag and does not alert on increases"+53
(reset-obs-stream-health!)+54
(update-obs-stream-health! #{ outputActive: #t outputSkippedFrames: 1 })+55
(let ((h (update-obs-stream-health! #{ outputActive: #f outputSkippedFrames: 8 })))+56
(assert-false (dict-ref h active?: #t))+57
(assert-false (dict-ref h dropping-now?: #t)))))