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