-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnode.lua
More file actions
2578 lines (2414 loc) · 113 KB
/
node.lua
File metadata and controls
2578 lines (2414 loc) · 113 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Infotext Player für info-beamer hosted
--
-- Reiner Renderer: HTTP-Fetching erfolgt im Python-Service-Sidecar
-- (siehe ./service), der Folien herunterlädt und manifest.json schreibt.
-- Diese Datei wird via util.json_watch beobachtet — bei Änderung wird
-- zum nächsten Zyklus-Ende auf die neue Folge geswitcht.
--
-- Crossfade zwischen Folien innerhalb eines Zyklus UND über die
-- Zyklus-Grenze hinweg (letzte Folie alt → erste Folie neu, ggf. nach
-- Manifest-Update).
--
-- Backup- und Hintergrund-Slot akzeptieren je ein Bild ODER ein Video.
-- Folien selbst koennen Bilder (PNG) ODER H.264-Videos (MP4) sein,
-- in beliebiger Mischung in der Playlist. Video-Folien werden in voller
-- Dateilaenge ausgespielt (Manifest-duration wird ignoriert) und mit
-- Hard-Cut an Image- bzw. anderen Video-Folien angeschlossen — der
-- Crossfade-Shader kann nur GL-Texturen samplen, nicht raw-Videos.
--
-- Bilder funktionieren auf jedem von info-beamer hosted unterstuetzten
-- Pi (JPEG/PNG, max. 2048x2048 wegen GL-Texture-Limit). Video-Wiedergabe
-- laeuft via raw=true GL-Pipeline:
-- * Pi 3 / 3B / 3B+ / Zero 2 W / Pi 4 / CM4: H.264 hardware-beschleunigt
-- * Pi 4+ zusaetzlich HEVC hardware-beschleunigt (info-beamer hosted v10+)
-- * Pi 5: H.264 in Software (kein HW-Decoder mehr in der VPU);
-- funktioniert, kostet aber spuerbar mehr CPU. HEVC bleibt HW.
-- Pi 3 / 3B / Zero 2 W haben nur EINEN H.264-Hardware-Decoder-Slot. BG-
-- und FG-Video koennen nicht gleichzeitig HW-decodiert laufen — das
-- BG-Video wird daher fuer die Dauer einer Video-Folie via
-- background_yield() komplett freigegeben und mit background_resume()
-- wieder geladen. Auf Pi 4/5 ist diese Yield-Strategie konservativ
-- aber unschaedlich.
--
-- Layer-Stack (negativ = hinter GL-Surface):
-- -3: Hintergrund-Video (background_slot)
-- -2: Backup-Video (backup_slot)
-- -1: Foreground-Video (fg_video, nur waehrend Video-Folien aktiv)
-- 0: GL-Surface mit Folien-Image, Cornerlogo, Zeit-Overlay
-- Cornerlogo + Zeit liegen damit auch ueber laufenden Video-Folien.
--
-- Audio (info-beamer mischt automatisch alle :start()-aktiven Quellen):
-- * Normalbetrieb (PLAYING): Audio des Hintergrund-Videos.
-- * Backup-Zustand (IDLE) mit Backup-VIDEO: Audio des Backup-Videos.
-- * Backup-Zustand mit Backup-BILD: Audio des Hintergrund-Videos
-- laeuft weiter, das Backup-Bild liegt nur visuell darueber.
-- * Video-Folie (PLAYING): FG-Audio mischt sich mit der bisherigen
-- Quelle (Stream/Jukebox); BG-Audio entfaellt, weil BG fuer den
-- Decoder-Slot weichen muss.
--
-- Single-Video-Playlist (#slides == 1, einziger Slot ist Video): das
-- Video wird mit looped=true geladen — Decoder-internes nahtloses
-- Looping, frame-genau, ohne Dispose+Reload-Luecke. Das bleibt fuer
-- die gesamte Standzeit der Playlist so, bis ein Manifest-Update
-- ueber den Sidecar eine neue Liste liefert (force-Advance via
-- pending_slides bricht den Loop fuer den swap_slides-Pfad).
gl.setup(NATIVE_WIDTH, NATIVE_HEIGHT)
util.no_globals()
local json = require "json"
------------------------------------------------------------
-- Konfiguration
------------------------------------------------------------
-- fade_duration und audio_ducking_fade sind im Setup in Millisekunden
-- konfiguriert (UI-Skala) und werden hier in Sekunden gehalten —
-- file_watch dividiert beim Read durch 1000. Sekunden passen direkt
-- zu sys.now()-Differenzen, die in den Render-Loops gerechnet werden.
local CONFIG = {
fade_duration = 0.5,
default_duration = 10,
audio_ducking_db = 0, -- Absenkung waehrend FG-Video (<= 0)
audio_ducking_fade = 0.25, -- Rampe in Sekunden (= 250 ms im Setup)
}
-- Raw-Videos rendern in info-beamer auf einer eigenen Ebene außerhalb
-- der GL-Pipeline. layer < 0 platziert das Video HINTER der GL-Surface,
-- sodass transparente Folien-Pixel via gl.clear(_, _, _, 0) und
-- transparenten Folien-PNG-Bereichen das Video durchscheinen lassen.
-- Reihenfolge der negativen Layer (siehe Datei-Header): BG (-3),
-- Backup (-2), Foreground-Video-Folie (-1, dynamisch). Backup auf
-- hoeherer (= weniger negativer) Ebene als BG, damit es im IDLE-
-- Zustand das BG-Video ueberdeckt; FG-Video wiederum oberhalb von
-- Backup, damit eine Video-Folie BG/Backup ueberblendet.
-- slot.audio steuert, ob die Resource mit Audio-Track geladen wird.
-- Backup-Video: immer mit Audio (Audio-Quelle nur waehrend IDLE+
-- backup-video sichtbar; Pause durch :stop() in anderen States ist
-- ok, weil das Video dann ohnehin off-screen ge:place't ist).
-- Background-Video: dynamisch, abhaengig von audio_stream.enabled.
-- Stream aus → audio=true (BG-Video kann Audio-Quelle sein)
-- Stream an → audio=false (BG-Video laeuft visuell durchgehend
-- und wird vom Routing nicht :stop()ed)
-- Bei Toggle des Stream-Zustands forciert update_media_slot ueber
-- den slot.audio_loaded-Vergleich einen Reload des BG-Videos.
local backup_slot = { res = nil, kind = nil, file = nil, label = "Backup-Inhalt", layer = -2, audio = true }
local background_slot = { res = nil, kind = nil, file = nil, label = "Hintergrund-Inhalt", layer = -3, audio = true }
-- Foreground-Video-Slot fuer Video-Folien. Nur eine Resource gleichzeitig
-- (Lazy-Load beim Eintritt in eine Video-Folie, Dispose beim Verlassen).
-- BG-Video MUSS vor dem Load via background_yield() freigegeben werden,
-- weil Pi 3B nur einen H.264-Decoder hat. looped=false → das Video laeuft
-- bis :state() == "finished", dann advanced der Render-Loop.
--
-- Mess-Felder fuer Diagnose-Logging:
-- * load_started_at : sys.now() nach erfolgreichem fg_video_load
-- * first_placed_at : sys.now() beim ersten Render-Frame mit
-- video_placeable=true (= erster sichtbarer
-- Decoder-Frame)
-- * last_placed_at : sys.now() im aktuellen Render-Tick, solange
-- das Video placeable ist. Wird pro Frame
-- ueberschrieben — beim fg_video_unload steht
-- damit der Zeitstempel des letzten sichtbaren
-- Frames.
-- Daraus zwei Log-Werte:
-- first_placed_at - load_started_at = Decoder-Spinup-Zeit
-- last_placed_at - first_placed_at = sichtbare Abspieldauer
-- Hilft bei Tuning des PENDING_IMAGE_HOLD_TIMEOUT und Erkennung
-- problematischer Videos.
local fg_video = {
res = nil,
file = nil,
layer = -1,
load_started_at = nil,
first_placed_at = nil,
last_placed_at = nil,
}
-- Optionales Zeit-Overlay. Wird im PLAYING-Zustand über den Folien
-- gezeichnet, im IDLE-Zustand vom Backup-Layer überdeckt (durch
-- Render-Reihenfolge). Erfordert ein per Setup hochgeladenes Font-
-- Asset; ohne Schrift wird das Overlay übersprungen.
local time_overlay = {
enabled = false,
-- text wird vom service-Sidecar (mit korrekter Timezone) per
-- UDP-IPC zugestellt und via util.data_mapper{ time = … }
-- eingespielt. Kein Disk-IO.
text = "",
locale = "de",
font_res = nil,
font_file = nil,
size = 80,
color = { r = 1, g = 1, b = 1, a = 1 },
x = 1820,
y = 980,
align = "right",
}
-- Optionales Cornerlogo: PNG mit Alphakanal, in Originalgröße bei
-- (x, y) gezeichnet.
-- Wird IMMER zuletzt gezeichnet, liegt also auch im Backup-Zustand
-- sichtbar oben drüber.
local corner_logo = {
enabled = false,
res = nil,
file = nil,
x = 0,
y = 0,
}
------------------------------------------------------------
-- Player-State
------------------------------------------------------------
local STATE_IDLE = "idle" -- keine Folien aktiv → backup_slot
local STATE_PLAYING = "playing"
local state = STATE_IDLE
local slides = {} -- [{file, kind, duration, res}, ...]
local current_idx = 1
local slide_started = 0
local pending_slides = nil -- nächste Liste, swap am Zyklus-Ende
local last_cur = nil -- zuletzt gerenderte Folie (Slide-Wechsel-Hook)
-- Watchdog gegen Dauer-Fail aller Folien einer Playlist: Render setzt
-- slide_drew=true, sobald die aktuelle Folie tatsaechlich gezeichnet
-- wurde (Image-Decode fertig + non-failed bzw. FG-Video placeable).
-- Beim Slide-Advance wird ausgewertet: war ueber die gesamte Lebenszeit
-- der Folie kein einziger Frame zeichenbar (slide_drew bleibt false),
-- inkrementiert consecutive_failed_slides; andernfalls Reset auf 0.
-- slide_drew wird im selben Schritt zurueckgesetzt, sodass die
-- naechste Folie wieder bei false startet. Erreicht der Counter
-- #slides (kompletter Cycle ohne sichtbaren Frame), wechselt der
-- Player nach IDLE und zeigt das Backup. Zusaetzliche Resets beim
-- IDLE->PLAYING-Uebergang und nach jedem Manifest-Update am Cycle-
-- Ende (s. swap_slides-Aufrufer im Advance-Pfad bzw. IDLE-Branch).
local slide_drew = false
local consecutive_failed_slides = 0
-- Zyklus-Crossfade (letzte Folie alt → erste Folie neu).
local outgoing = nil -- nil oder {res, dispose_after}
local cycle_fade_start = 0
-- Visuelle Synchronisierung GL-Surface (Layer 0) <-> Raw-Video-Layer
-- (-3/-1) bei Wechseln zwischen Image+BG-Video und FG-Video. Raw-Videos
-- werden vom Compositor unabhaengig von der GL-Pipeline gepostet —
-- :dispose() schlaegt typisch erst einen Compositor-Frame spaeter durch,
-- :load_video braucht Decoder-Spinup. Ohne Korrektur:
-- * Image+BG -> FG-Video: Image weg in Frame N+1 (GL-clear sofort),
-- BG erst in N+2 weg (Compositor-Lag). 1 Frame "nur BG".
-- * FG-Video -> Image+BG: FG weg in N+1, Image sofort gezeichnet,
-- BG-Video poppt in N+k rein (loading->playing).
-- Image-Hold beim Image->Video-Wechsel: das alte Image wird auf der
-- GL-Surface weiter gezeichnet, um den Uebergang zum gerade ladenden
-- FG-Video zu ueberbruecken. Hold-Modus haengt vom BG-Typ ab:
--
-- * BG-Image (one_shot=false): Multi-Frame-Hold bis FG placeable
-- oder bis Hold-Deadline (Sicherheits-Timeout). BG-Image und
-- Hold bleiben gemeinsam sichtbar bis das Video uebernimmt — kein
-- "BG-allein"-Zwischenframe.
-- * BG-Video (one_shot=true): 1-Frame-Hold als reine Compositor-
-- Lag-Kompensation. background_yield disposed das BG-Video, der
-- Compositor zieht das Layer aber erst einen Frame spaeter ab —
-- der Hold deckt genau dieses eine Frame, sodass FG-Image und
-- BG-Video synchron verschwinden. Danach Schwarz, bis FG ready.
--
-- Felder:
-- res = Image-Resource (Userdata)
-- deadline = sys.now()-Zeitpunkt fuer Sicherheits-Timeout im
-- Multi-Frame-Modus
-- dispose_after = true, sobald reconcile_window oder swap_slides
-- die Resource aus ihrem urspruenglichen Slot
-- entfernen wuerden — der Hold uebernimmt dann die
-- Disposal-Verantwortung beim Aufloesen
-- (analog zum outgoing-Mechanismus)
-- one_shot = true: nach einem Render-Frame clearen
-- false: bis video_ready oder deadline halten
local pending_image_hold = nil
local PENDING_IMAGE_HOLD_TIMEOUT = 1.0 -- s, deutlich groesser als
-- typische FG-Video-Loading-
-- Zeiten auf Pi 3B
local bg_resume_gate = nil -- {deadline,last_tick}. Solange
-- gesetzt: Image-Pfad zeichnet die
-- Folie nicht (Time-Overlay und
-- Cornerlogo laufen weiter), bis
-- das resumte BG-Video state==
-- "playing" liefert.
local BG_RESUME_GATE_TIMEOUT = 0.5 -- Sicherheits-Fallback (Sek.) gegen
-- kaputtes BG-Video, das nie
-- "playing" erreicht.
-- Audio-Routing-Status: "background" | "backup" | "stream" | "jukebox" | nil
local audio_active = nil
-- Optionaler HTTP-/Icecast-Audio-Stream via resource.load_audio;
-- Aktivierung verlangt zusätzlich
-- runtime.outside_sources=true in package.json (für HTTP-URLs) und
-- die "audio"-Capability auf der Hardware (sys.provides "audio").
-- Pegel wird zur Laufzeit per :volume(0..1) gesteuert (Berechnung in
-- apply_audio_levels: db_to_linear(volume_db + ducking-Offset)). Watchdog
-- disposed bei state="error"/"finished" und reconnectet nach retry_after Sekunden.
local audio_stream = {
enabled = false,
url = "",
volume_db = 0, -- Basispegel in dB (0 = unity, <= -60 = stumm)
res = nil,
loaded_url = nil,
last_attempt = -math.huge,
retry_after = 5,
buffer = 5, -- Sekunden Pre-Buffer
available = sys.provides and sys.provides("audio") or false,
}
-- Erreichbarkeits-Probe vom Sidecar (UDP-IPC, Path "audio_probe").
-- Schickt im 3..5-s-Takt "ok" oder "fail", je nachdem ob ein
-- HTTP-GET an audio_stream.url einen Status < 400 zurueckliefert.
-- Solange ok ~= true (Probe-Resultat fail oder noch keine Probe
-- empfangen), unterdrueckt check_audio_stream_health jeden
-- (Re)load-Versuch. Hintergrund: ein bekannter SIGSEGV im info-
-- beamer-Audio-Worker beim Verarbeiten unerreichbarer URLs (404,
-- DNS-Fail, Conn-Refused, Timeout) reisst ohne diesen Schutz den
-- gesamten Knoten samt Watchdog im Sekundentakt mit. Aus Lua
-- nicht abfangbar (Crash im nativen Worker-Thread).
--
-- url: die URL, fuer die das letzte Probe-Resultat galt (vom
-- Sidecar im IPC-Payload mitgesendet). Reload-Gate akzeptiert ein
-- "ok" nur, wenn diese URL == audio_stream.url — andernfalls
-- koennte ein frisches "ok" der alten Konfig-URL versehentlich
-- den Load einer gerade geaenderten neuen URL freischalten,
-- bevor der Sidecar sie ueberhaupt geprobt hat.
--
-- stale_after = 60 s: laeuft die Probe aus (Sidecar tot/haengt
-- oder steckt in einem langen Folien-Download), prueft der
-- Reload-Gate via (now - last_msg_at) und blockt — sicherer
-- Default. Lieber stumm als Crash-Loop. Wert groesser als der
-- single-Download-Timeout im Sidecar (30 s) gewaehlt, damit ein
-- einzelner langer Download zwischen den Probe-Ticks die Probe
-- nicht knapp ueber die Schwelle drueckt.
local audio_probe = {
ok = nil,
url = nil,
last_msg_at = -math.huge,
stale_after = 60,
}
-- Optionale Jukebox: lokal gespeicherte Audio-Files (per Setup als
-- Resources hochgeladen) werden sequenziell oder zufaellig nacheinander
-- abgespielt. Genau ein Track ist gleichzeitig geladen. Sobald
-- :state() == "finished" liefert, disposed der Health-Check ihn und
-- laedt den naechsten Track der Reihenfolge. Bei "error" wird der
-- gleiche Track nicht endlos retried — wir wechseln direkt zum
-- naechsten der Liste, sonst koennte ein einzelnes kaputtes File die
-- gesamte Wiedergabe blockieren.
--
-- Reihenfolge: order ist eine Permutation von 1..#files (Sequenz oder
-- Fisher-Yates-Shuffle). order_pos zeigt auf den zuletzt geladenen
-- Eintrag. Nach dem Ende der Liste mischt sich order bei shuffle=true
-- neu — sonst startet sie wieder bei 1.
local audio_jukebox = {
enabled = false,
shuffle = false,
volume_db = 0, -- Basispegel in dB (0 = unity, <= -60 = stumm)
files = {}, -- {dateiname, ...}
order = {}, -- Indizes in files (Sequenz oder Shuffle)
order_pos = 0,
res = nil,
loaded_file = nil,
last_state = nil,
last_attempt = -math.huge,
retry_after = 2, -- Cooldown zwischen Load-Versuchen (Schutz
-- gegen Reload-Sturm bei wiederholten
-- Fehlern; bei normalen Track-Wechseln
-- vernachlaessigbar, weil last_attempt nur
-- im load_jukebox_track() gesetzt wird)
available = sys.provides and sys.provides("audio") or false,
}
-- Ducking: senkt den Pegel der aktiven Hintergrund-Quelle (Stream/
-- Jukebox) waehrend der Wiedergabe einer Vordergrund-Video-Folie um
-- CONFIG.audio_ducking_db ab. Trigger ist fg_video.res ~= nil — kein
-- separater Hook in fg_video_load/unload noetig.
--
-- Rampen-Modell: factor ∈ [0, 1] interpoliert linear in der Zeit
-- (1/fade pro Sekunde) zwischen 0 (kein Ducking) und 1 (volles
-- Ducking). apply_audio_levels mischt daraus den Pegel als
-- amplituden-lineare Interpolation zwischen base_lin und ducked_lin
-- (beide ueber db_to_linear gerechnet) — d.h. der Linear-Faktor
-- :volume() bewegt sich gleichmaessig, statt wie bei einer dB-
-- linearen Rampe vorne aggressiv zu fallen und hinten ins Stumme
-- zu trudeln. Wirkt insbesondere bei Fade-zu-(-60 dB) hoerbar
-- gleichmaessiger.
local audio_ducking = {
factor = 0,
last_t = nil, -- sys.now() der letzten Rampen-Anwendung
}
-- Math-RNG einmalig seeden, damit Shuffle nicht in jeder Session
-- identisch laeuft. sys.now() liefert eine Float-Sekunde seit
-- Knoten-Start; das genuegt fuer die Audio-Reihenfolge (kein
-- Krypto-Bedarf).
math.randomseed(math.floor((sys.now() or 0) * 1000) + 1)
------------------------------------------------------------
-- Hilfsfunktionen
------------------------------------------------------------
local function now() return sys.now() end
-- Hosted gibt resource-Optionen je nach Runtime-Version mal als String,
-- mal als Tabelle mit asset_name/filename zurück. Beide Formen abdecken.
local function resolve_resource(value)
if type(value) == "string" then
return (value ~= "" and value) or nil
end
if type(value) == "table" then
local n = value.asset_name or value.filename or value.file
return (n and n ~= "" and n) or nil
end
return nil
end
-- Typ eines Media-Assets bestimmen: zuerst Hosted-Metadaten, sonst über
-- die Endung erraten. Default ist "image" — funktioniert auf jedem Pi.
-- Endungs-Whitelist konservativ: info-beamer dokumentiert nur MP4 als
-- Video-Container; m4v ist Suffix-Variante, mov teilt das ISO-BMFF-
-- Layout und wird vom MMAL-Demuxer in der Praxis akzeptiert. webm/
-- mkv/avi sind nicht als unterstuetzt dokumentiert — solche Dateien
-- werden als "image" eingestuft und scheitern beim Image-Load mit
-- klarer Fehlermeldung, statt im Video-Pfad undefiniert wegzubrechen.
-- Identische Liste wie VIDEO_EXTENSIONS im service-Sidecar.
local function media_type_for(value, name)
if type(value) == "table" then
if value.type == "video" then return "video" end
if value.type == "image" then return "image" end
end
if name then
local ext = name:lower():match("%.([%w]+)$") or ""
if ext == "mp4" or ext == "m4v" or ext == "mov" then
return "video"
end
end
return "image"
end
-- Lädt ein Media-Asset entsprechend seines Typs. Videos werden mit
-- raw=true (GL-Pipeline) geladen. with_audio steuert pro Slot, ob
-- ein Audio-Track mitgeladen wird — das Hintergrund-Video bekommt
-- audio=false, weil sein Frame-Strom durchgehend visuell laufen muss
-- und info-beamers :stop() Audio nicht ohne Video muten kann. paused
-- =true, weil update_media_slot direkt nach load :start() aufruft
-- (so beginnt der Decoder unter unserer Kontrolle). Der pcall faengt
-- generische Lade-Fehler ab (Codec/Container nicht verarbeitbar,
-- Datei kaputt, Decoder-Slot anderweitig belegt) — auf Pi 5 wird
-- H.264 in Software dekodiert (kein HW-Decoder mehr in der VPU),
-- funktioniert aber, nur mit hoeherer CPU-Last.
local function load_media(name, kind, with_audio)
if kind == "video" then
return pcall(resource.load_video, {
file = name,
looped = true,
raw = true,
audio = with_audio and true or false,
paused = true,
})
end
return pcall(resource.load_image, {file = name})
end
local function dispose_list(list)
for _, s in ipairs(list or {}) do
if s.res then
pcall(function() s.res:dispose() end)
end
end
end
-- Sliding-Window fuer Image-Slide-Preload. Auf Pi 3B (256 MiB CMA) hat
-- jede 1920x1080-RGBA-Textur ~8 MB GPU-RAM Footprint; lange Playlists
-- vollstaendig vorzuladen sprengt das CMA und triggert "Cannot alloc
-- texture: out of memory" mit Watchdog-Reboot. Stattdessen: nur
-- current_idx + (SLIDE_WINDOW-1) Folgefolien als Texturen halten, der
-- Rest bleibt Metadaten-only. Reconcile laeuft am Frame-Ende.
--
-- Das Window ist zyklisch: am Playlist-Ende wrappt es zu slides[1]
-- zurueck, sodass am letzten Slide schon der naechste Cycle-Wrap-
-- Target im Vorrat liegt. Ohne Wrap-Around muesste slides[1] am
-- Cycle-Ende per Transition-Gate "kalt" geladen werden — das wuerde
-- nicht nur einen sichtbaren Delay produzieren, sondern auch die
-- Sequentialitaets-Garantie brechen, weil ein ausserhalb des
-- linearen Windows gestarteter Decode von any_image_in_flight()
-- nicht gesehen wuerde und reconcile_window-Phase 2 parallel
-- pending_slides[1] anstossen koennte.
--
-- Window-Groesse 5: deckt sicher auch laengere Ketten kurzer
-- Slide-Dauern ab (Transition-Gate verzoegert ohnehin, falls die
-- Naechste noch nicht ready ist; das Window dient als Vorrat).
-- Peak-GPU-Footprint bleibt mit 5 × 8 MB = 40 MB weit unter dem
-- CMA-Budget.
local SLIDE_WINDOW = 5
-- Liefert die zyklischen Indizes des aktuellen Sliding-Windows.
-- Bei n < SLIDE_WINDOW wird nur n-mal iteriert (kein doppelter
-- Slot). Reihenfolge: current_idx zuerst, dann monoton steigend
-- mit Wrap-Around.
local function window_indices(start_idx, n)
local count = math.min(SLIDE_WINDOW, n)
local start = math.max(1, start_idx or 1)
local indices = {}
for i = 0, count - 1 do
indices[i + 1] = ((start - 1 + i) % n) + 1
end
return indices
end
-- Image-Resource eines Slots freigeben, mit Handoff fuer
-- weiterlaufende Referenzen:
-- * outgoing : Cycle-Crossfade-Quelle
-- * pending_image_hold : Image-Hold beim Image->Video-Wechsel
-- In beiden Faellen markieren wir dispose_after, statt direkt zu
-- disposen — der jeweilige Aufloeser uebernimmt die Verantwortung,
-- sobald die Referenz nicht mehr gebraucht wird. Sonst wuerde der
-- laufende Cycle-Fade bzw. der noch zu zeichnende Hold ploetzlich
-- auf eine disposede Textur greifen.
local function dispose_slot_resource(slot)
if not slot or not slot.res then return end
if outgoing and outgoing.res == slot.res then
outgoing.dispose_after = true
elseif pending_image_hold and pending_image_hold.res == slot.res then
pending_image_hold.dispose_after = true
else
pcall(function() slot.res:dispose() end)
end
slot.res = nil
end
-- Image-Hold aufloesen. Disposed die Resource nur, wenn die
-- Disposal-Verantwortung im Verlauf an den Hold uebergegangen ist
-- (dispose_after=true) — ansonsten gehoert die Resource weiter dem
-- urspruenglichen Slot bzw. wurde dort schon freigegeben.
local function clear_pending_image_hold()
if not pending_image_hold then return end
if pending_image_hold.dispose_after then
pcall(function() pending_image_hold.res:dispose() end)
end
pending_image_hold = nil
end
-- Image-Resource ist draw-ready, sobald der async Decoder fertig ist.
-- info-beamer 2.x liefert :state() == "loaded" fuer Image-Resources;
-- als Fallback (aeltere Builds, fehlende Methode) prueft :size() — eine
-- nicht dekodierte Textur meldet 0x0. Bei Lade-Fehlern (slot.failed)
-- gilt der Slot als "ready", damit der Render-Loop ueber kaputte
-- Folien nicht endlos haengt — draw_fit(nil) faellt dann auf reines
-- BG zurueck.
--
-- :state() == "error" (Decode-Fehler nach erfolgreichem Load-Aufruf,
-- z.B. korrupte Datei, unsupported PNG-Variante) markiert den Slot
-- als failed, gibt die GPU-Resource sofort frei (sonst weiter
-- belegtes GPU-RAM und Render-Pfade wuerden gegen die kaputte
-- Textur zeichnen) und liefert true zurueck — andernfalls bliebe der
-- Slot permanent "not ready" und wuerde den globalen
-- any_image_in_flight()-Gate dauerhaft blockieren.
-- Unbekannte States (zukuenftige info-beamer-Versionen, Race-
-- Conditions) fallen auf den :size()-Heuristik-Pfad zurueck, statt
-- fix mit false zu antworten.
local function image_ready(slot)
if not slot then return false end
if slot.failed then return true end
local res = slot.res
if not res then return false end
local ok, st = pcall(function() return res:state() end)
if ok and type(st) == "string" then
if st == "loaded" then return true end
if st == "error" then
slot.failed = true
print("Folie nicht dekodierbar: " .. tostring(slot.file))
dispose_slot_resource(slot)
return true
end
if st == "loading" then return false end
-- unbekannter State: defensiver Fallback ueber :size()
end
local ok2, w, h = pcall(function() return res:size() end)
return ok2
and (tonumber(w) or 0) > 0
and (tonumber(h) or 0) > 0
end
-- Reine Resource-Drawable-Pruefung ohne Slot-Bezug — fuer Stellen,
-- an denen wir nur ein nacktes resource-Handle haben (z.B.
-- outgoing.res im Cycle-Crossfade, das nach set_outgoing nicht mehr
-- mit einem Slot verknuepft ist). Im Gegensatz zu image_ready/
-- image_drawable: keine Seiteneffekte (kein slot.failed-Setting,
-- kein dispose). Logik mirror't den loaded/size-Pfad aus image_ready.
local function resource_drawable(res)
if not res then return false end
local ok, st = pcall(function() return res:state() end)
if ok and type(st) == "string" then
return st == "loaded"
end
local ok2, w, h = pcall(function() return res:size() end)
return ok2
and (tonumber(w) or 0) > 0
and (tonumber(h) or 0) > 0
end
-- Image-Slot ist konkret zeichenbar: Resource existiert, ist
-- erfolgreich dekodiert und nicht als failed markiert. Strenger
-- als image_ready() — letztere liefert aus Gate-Sicht true fuer
-- failed-Slots, damit der globale In-Flight-Gate nicht permanent
-- blockt; im Render-Pfad ist diese Sicht falsch, weil ein failed-
-- Slot keinen halben Crossfade zeigen soll, sondern auf
-- draw_fit(nil) -> reines BG zurueckfaellt. Wird bei Crossfade-
-- Entscheidungen genutzt (Cycle-Fade can_fade, Out-Fade-Branch).
--
-- Reihenfolge: image_ready ZUERST aufrufen — der Aufruf kann
-- slot.failed=true setzen und slot.res=nil disposen (state()=="error"-
-- Branch). Wenn wir failed/res VOR image_ready pruefen, sehen wir
-- den State VOR dem Uebergang und liefern faelschlich true; das
-- wuerde draw_crossfade mit nil-Resource aufrufen.
local function image_drawable(slot)
if not slot or slot.kind ~= "image" then return false end
if not image_ready(slot) then return false end
return slot.res ~= nil and not slot.failed
end
-- Asynchron einen Image-Slot laden. Idempotent: wiederholte Aufrufe
-- waehrend des Decodes sind no-ops. Video-Slots sind explizit
-- ausgenommen — sie laufen ueber fg_video_load (eigener Lifecycle,
-- nur ein Decoder gleichzeitig auf Pi 3B).
local function preload_slot(slot)
if not slot then return end
if slot.kind ~= "image" then return end
if slot.res or slot.failed then return end
local ok, res = pcall(resource.load_image, {file = slot.file})
if ok and res then
slot.res = res
else
slot.failed = true
print("Folie nicht ladbar: " .. tostring(slot.file))
end
end
-- Image-Slot disposen, wenn er aus dem Vorlade-Fenster faellt.
-- dispose_slot_resource uebernimmt das Outgoing-Handoff fuer
-- Resources, die gerade vom Cycle-Crossfade gehalten werden.
local function unload_slot(slot)
if not slot or not slot.res then return end
if slot.kind ~= "image" then return end
dispose_slot_resource(slot)
end
-- Returns true wenn aktuell irgendwo ein Image-Decode in Flight ist
-- (s.res gesetzt, aber noch nicht "loaded"). Geprueft wird sowohl
-- das aktuelle (zyklische) slides-Window als auch pending_slides[1]
-- — beide teilen sich den globalen In-Flight-Gate, damit auf Pi 3B
-- nie zwei PNG-Decodes parallel laufen.
local function any_image_in_flight(start_idx)
local n = #slides
if n == 0 then
if pending_slides and pending_slides[1] then
local p = pending_slides[1]
if p.kind == "image" and p.res and not image_ready(p) then
return true
end
end
return false
end
for _, idx in ipairs(window_indices(start_idx, n)) do
local s = slides[idx]
if s and s.kind == "image" and s.res and not image_ready(s) then
return true
end
end
if pending_slides and pending_slides[1] then
local p = pending_slides[1]
if p.kind == "image" and p.res and not image_ready(p) then
return true
end
end
return false
end
-- Stellt sicher, dass das zyklische Window geladen ist und alle
-- anderen Slots disposed. Aufruf am Frame-Ende.
--
-- Loads laufen sequenziell: pro Aufruf wird hoechstens EIN neuer
-- Decode-Task an den Threadpool gegeben, und nur wenn weder im
-- Window noch in pending_slides[1] ein Decode in Flight ist.
-- Reihenfolge: aktuelles Window zuerst (sichtbarer Bedarf in
-- monotoner Slide-Reihenfolge inkl. Wrap-Around), dann
-- pending_slides[1] (wird erst beim naechsten Cycle-Wrap als
-- Crossfade-Target gebraucht). Auf Pi 3B verhindert das
-- Lastpeaks (parallele PNG-Decodes konkurrieren um CPU, RAM und
-- GEM-Allokationen — derselbe Druck, der den urspruenglichen OOM
-- ausgeloest hat).
--
-- Performance: die Unload-Phase iteriert ueber alle n Slides und
-- aendert sich nur, wenn sich das Window (start, n) oder die
-- slides-Liste selbst aendern. Bei langer Playlist und 50 fps
-- waere O(n) pro Frame unnoetig teuer auf Pi 3B — daher cachen
-- wir (start, n, slides_id) und ueberspringen die Unload-Phase,
-- wenn sich nichts geaendert hat. swap_slides invalidiert den
-- Cache via reconcile_window_invalidate().
local last_window_start, last_window_n, last_window_id = nil, nil, nil
local function reconcile_window_invalidate()
last_window_start, last_window_n, last_window_id = nil, nil, nil
end
-- Pendant zum normalen Window-Reconcile fuer den IDLE-State: laedt
-- ausschliesslich pending_slides[1], damit der IDLE->PLAYING-
-- Uebergang ohne sichtbare Decode-Pause erfolgen kann. Bewusst KEINE
-- Phase-1-Preloads fuer das alte slides-Window — slides wird in IDLE
-- ohnehin nicht gerendert; ein Phase-1-Decode wuerde via globalem
-- In-Flight-Gate den pending[1]-Decode aufschieben (auf Pi 3B
-- mehrere Frames pro Slot) und damit den Uebergang verzoegern.
-- Auch keine Unload-Phase: bestehendes slides-Window bleibt geladen,
-- damit ein spaeteres Re-PLAYING ueber swap_slides via File-Keyed-
-- Cache effizient bleibt.
--
-- any_image_in_flight deckt sowohl bereits-laufende slides- als auch
-- pending-Decodes ab — damit bleibt die Sequentialitaets-Garantie
-- (kein paralleler Decode auf Pi 3B) auch dann erhalten, wenn slides
-- aus einer vorigen PLAYING-Phase noch einen Decode in Flight hat.
local function preload_pending_first()
if not pending_slides or not pending_slides[1] then return end
local p = pending_slides[1]
if p.kind ~= "image" then return end
if p.res or p.failed then return end
if any_image_in_flight(1) then return end
preload_slot(p)
end
local function reconcile_window(start_idx)
local n = #slides
if n == 0 then
reconcile_window_invalidate()
-- Keine slides → ausschliesslich pending_slides[1] preloaden.
preload_pending_first()
return
end
local start = math.max(1, start_idx or 1)
local indices = window_indices(start, n)
if last_window_start ~= start or last_window_n ~= n
or last_window_id ~= slides then
local in_window = {}
for _, idx in ipairs(indices) do in_window[idx] = true end
for i = 1, n do
if not in_window[i] then
unload_slot(slides[i])
end
end
last_window_start, last_window_n, last_window_id = start, n, slides
end
if any_image_in_flight(start_idx) then return end
-- Phase 1: Aktuelles Window auffuellen (sichtbarer Bedarf zuerst,
-- in monotoner Slide-Reihenfolge mit Wrap-Around).
for _, idx in ipairs(indices) do
local s = slides[idx]
if s and s.kind == "image" and not s.res and not s.failed then
preload_slot(s)
return
end
end
-- Phase 2: pending_slides[1] vorladen — wird beim naechsten
-- Cycle-Wrap als Crossfade-Target gebraucht (nur wenn ein
-- Manifest-Update aktiv ist; sonst greift der Wrap-Around des
-- aktiven Windows). Erst hier, weil der aktuelle Bedarf Vorrang
-- hat. Slot 2..N von pending werden erst nach dem Swap durch
-- reconcile in Folgeframes nachgezogen.
if pending_slides and pending_slides[1] then
local p = pending_slides[1]
if p.kind == "image" and not p.res and not p.failed then
preload_slot(p)
end
end
end
-- Dezibel → linearer Faktor [0..1] für info-beamers :volume()-API.
-- 0 dB = 1.0 (unity), -6 dB ≈ 0.5 (halbe Amplitude), -20 dB = 0.1.
-- Werte ≤ -60 dB auf 0 clampen (praktisch stumm), Werte ≥ 0 dB auf
-- 1 (Stream-Audio kann von info-beamer nicht verstärkt, nur
-- abgesenkt werden).
local function db_to_linear(db)
if db == nil then return 1 end
if db <= -60 then return 0 end
if db >= 0 then return 1 end
return 10 ^ (db / 20)
end
-- dB-Wert auf [-60, 0] clampen. Stream-Audio kann von info-beamer
-- nicht verstaerkt werden, und unterhalb von -60 dB ist alles
-- praktisch stumm. Default 0 fuer nil (kein Pegelversatz).
local function clamp_db(db)
if type(db) ~= "number" then return 0 end
if db > 0 then return 0 end
if db < -60 then return -60 end
return db
end
-- URL-Sanitizer: percent-kodiert alle Bytes >= 0x80 (Non-ASCII).
-- Hintergrund: wenn die im Setup hinterlegte Stream-URL roh-UTF-8
-- enthaelt (z. B. "ß" als 0xC3 0x9F), liefert der HTTP-Server fuer
-- den so kodierten Pfad meist 404 — und ein bekannter SIGSEGV im
-- info-beamer-Audio-Worker beim Verarbeiten dieses 404 reisst den
-- gesamten Node samt Watchdog im Sekundentakt mit. Idempotent: bereits
-- prozentkodierte URLs (rein ASCII) bleiben unveraendert, doppelte
-- Kodierung kann also nicht auftreten.
local function sanitize_url(url)
if type(url) ~= "string" or url == "" then return url end
return (url:gsub("[\128-\255]", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
-- Englische Wochentag-/Monatsnamen durch deutsche ersetzen. Wird auf
-- die Ausgabe von os.date() angewendet, weil info-beamer-Lua mit
-- C-Locale läuft (=> englische %A/%B/%a/%b). Frontier-Patterns
-- (%f[%a]…%f[%A]) sorgen für Wortgrenzen — sonst würde "Mon" im
-- bereits ersetzten "Montag" wieder zu "Mo" werden.
--
-- Reihenfolge: vollständige Namen ZUERST, dann Abkürzungen. Sonst
-- könnte z. B. "Mar" das "Mar"-Präfix von "March" ersetzen, bevor
-- die volle Form drankommt.
local DE_REPLACEMENTS = {
-- Vollständige Wochentage
{"Wednesday", "Mittwoch"},
{"Thursday", "Donnerstag"},
{"Saturday", "Samstag"},
{"Tuesday", "Dienstag"},
{"Monday", "Montag"},
{"Friday", "Freitag"},
{"Sunday", "Sonntag"},
-- Vollständige Monate
{"September", "September"},
{"February", "Februar"},
{"November", "November"},
{"December", "Dezember"},
{"October", "Oktober"},
{"January", "Januar"},
{"August", "August"},
{"March", "März"},
{"April", "April"},
{"June", "Juni"},
{"July", "Juli"},
-- Abgekürzte Wochentage
{"Mon", "Mo"},
{"Tue", "Di"},
{"Wed", "Mi"},
{"Thu", "Do"},
{"Fri", "Fr"},
{"Sat", "Sa"},
{"Sun", "So"},
-- Abgekürzte Monate (nur die, die sich vom Englischen unterscheiden)
{"Mar", "Mär"},
{"May", "Mai"},
{"Oct", "Okt"},
{"Dec", "Dez"},
}
local function localize_de(text)
for _, pair in ipairs(DE_REPLACEMENTS) do
text = text:gsub("%f[%a]" .. pair[1] .. "%f[%A]", pair[2])
end
return text
end
-- Zeit-Overlay-Schrift bei Asset-Wechsel neu laden.
local function update_time_font(name)
if name == time_overlay.font_file then return end
if time_overlay.font_res then
pcall(function() time_overlay.font_res:dispose() end)
end
time_overlay.font_res = nil
time_overlay.font_file = name
if not name then return end
local ok, f = pcall(resource.load_font, name)
if ok and f then
time_overlay.font_res = f
else
print("Zeit-Schrift nicht ladbar: " .. name)
end
end
-- Cornerlogo-Asset bei Wechsel neu laden.
local function update_corner_logo(name)
if name == corner_logo.file then return end
if corner_logo.res then
pcall(function() corner_logo.res:dispose() end)
end
corner_logo.res = nil
corner_logo.file = name
if not name then return end
local ok, r = pcall(resource.load_image, {file = name})
if ok and r then
corner_logo.res = r
else
print("Cornerlogo nicht ladbar: " .. name)
end
end
------------------------------------------------------------
-- Crossfade-Shader (prämultiplizierte-Alpha-Lerp)
------------------------------------------------------------
-- Standard-Alphablending mit zwei :draw-Aufrufen produziert keine
-- saubere Lerp zwischen zwei RGBA-Texturen — zweiter Draw = src*p +
-- (1-p) * (1-p)*A statt p*B + (1-p)*A. Die Folge: an Stellen, wo
-- Folie A opak und Folie B transparent ist (oder umgekehrt), gibt es
-- entweder einen abrupten Wechsel am Fade-Ende oder ein Helligkeits-
-- Loch in der Mitte des Fades.
--
-- Lösung: fragment-shader, der beide Texturen sampelt, in
-- prämultipliziertem Alpha-Raum lerpt (sodass transparente Pixel
-- wirklich keinen Color-Beitrag haben) und das Ergebnis als
-- straight-alpha herausgibt. Der nachgelagerte Standard-Composite
-- ueber den Hintergrund-Layer ist dann mathematisch korrekt fuer
-- alle Transparenz-Kombinationen.
--
-- WICHTIG: info-beamer liefert PNG-Texturen bereits prämultipliziert
-- (tex.rgb = color*A). Daher KEIN nochmaliges `a.rgb*a.a` — sonst
-- waere der Foreground-Beitrag im Shader-Pfad um Faktor A dunkler
-- als im :draw-Pfad (sichtbar als plötzlicher Helligkeitsabfall an
-- Fade-Beginn in halbtransparenten Folienbereichen).
local crossfade_shader
do
local ok, sh = pcall(resource.create_shader, [[
uniform sampler2D from_tex;
uniform sampler2D to_tex;
uniform float progress;
varying vec2 TexCoord;
void main() {
vec4 a = texture2D(from_tex, TexCoord);
vec4 b = texture2D(to_tex, TexCoord);
vec4 r_pre = mix(a, b, progress);
if (r_pre.a > 0.0) {
gl_FragColor = vec4(r_pre.rgb / r_pre.a, r_pre.a);
} else {
gl_FragColor = vec4(0.0);
}
}
]])
if ok then
crossfade_shader = sh
else
print("Crossfade-Shader konnte nicht kompiliert werden — fallback auf zwei-Draw-Compositing mit leichten Artefakten an Transparenz-Kanten.")
end
end
-- Mathematisch sauberer Crossfade zwischen zwei Folien-Ressourcen.
-- Die Geometrie kommt vom :draw der ersten Textur, der Shader
-- ersetzt jedoch die Fragment-Farbe — beide Texturen werden über
-- die Uniforms gesampelt.
local function draw_crossfade(from_res, to_res, progress)
if not from_res or not to_res then return end
if crossfade_shader then
crossfade_shader:use{
from_tex = from_res,
to_tex = to_res,
progress = progress,
}
from_res:draw(0, 0, WIDTH, HEIGHT)
crossfade_shader:deactivate()
else
-- Fallback (Compositing-Artefakte an Transparenz-Kanten).
from_res:draw(0, 0, WIDTH, HEIGHT, 1)
to_res:draw(0, 0, WIDTH, HEIGHT, progress)
end
end
------------------------------------------------------------
-- Audio-Routing
------------------------------------------------------------
-- info-beamer setzt audio=true beim Laden — runtime gibt's keinen
-- Mute-Toggle. pause/start eines Videos hält Decoder + Audio an.
-- Wir laden beide Videos initial paused, und genau eines wird per
-- :start() aktiv — entweder das Hintergrund- oder das Backup-Video.
local function video_pause(slot)
if slot.kind == "video" and slot.res then
pcall(function() slot.res:stop() end)
end
end
local function video_play(slot)
if slot.kind == "video" and slot.res then
pcall(function() slot.res:start() end)
end
end
-- (Re)load des Audio-Streams via resource.load_audio. paused=true,
-- damit update_audio_routing im nächsten Frame über :volume(0/1)
-- entscheidet. last_attempt setzt die Cooldown-Schranke für den
-- Watchdog, damit fehlerhafte Streams nicht in einer Reconnect-
-- Schleife landen. Vor dem Load wird die URL via sanitize_url percent-
-- kodiert (s. dort) — der Vergleich loaded_url ↔ audio_stream.url
-- bleibt absichtlich gegen die Roh-Form, damit Setup-Aenderungen
-- erkannt werden.
local function load_audio_stream()
audio_stream.last_attempt = sys.now()
local request_url = sanitize_url(audio_stream.url)
local ok, r = pcall(resource.load_audio, {
file = request_url,
buffer = audio_stream.buffer,
paused = true,
})
if ok and r then
audio_stream.res = r
audio_stream.loaded_url = audio_stream.url
pcall(function() r:volume(0) end) -- gemutet starten
pcall(function() r:start() end) -- Decoder anwerfen
audio_active = nil -- Routing-Neuevaluation
if request_url ~= audio_stream.url then
print("Audio-Stream geladen (URL prozentkodiert): " .. request_url)
else
print("Audio-Stream geladen: " .. request_url)
end
else
audio_stream.res = nil
audio_stream.loaded_url = nil
print(string.format(
"Audio-Stream nicht ladbar: %s (Fehler: %s)",
request_url, tostring(r)
))
end
end
-- Wird pro Frame aufgerufen. Disposed den Stream, wenn er deaktiviert,
-- die URL geändert oder der Decoder in "error"/"finished" gelandet ist;
-- lädt ihn neu, sobald die Cooldown-Periode (retry_after) abgelaufen ist.
local function check_audio_stream_health()
-- Audio-Capability nicht verfügbar (z. B. Pi ohne Sound-Hardware
-- oder info-beamer-Build ohne Audio-Support) → Feature stillgelegt.
if not audio_stream.available then return end
if not audio_stream.enabled or audio_stream.url == "" then
if audio_stream.res then
pcall(function() audio_stream.res:dispose() end)
audio_stream.res = nil
audio_stream.loaded_url = nil
end
return
end
-- URL hat sich seit dem Load geändert → bestehenden Stream killen.
if audio_stream.res and audio_stream.loaded_url ~= audio_stream.url then
pcall(function() audio_stream.res:dispose() end)
audio_stream.res = nil
end