Add persistent live controls and interaction notes
This commit is contained in:
1 parent
b1474bc33d
commit
fa17187af4
16 files changed
+882
-62
No files matched your search
@@ -463,3 +463,20 @@
|
|||||||
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
- `bash -n start.sh` und `git diff --check`: erfolgreich
|
||||||
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
||||||
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
||||||
|
|
||||||
|
## Erweiterte Live-Steuerung 0.5.12
|
||||||
|
|
||||||
|
| Kriterium | Stand |
|
||||||
|
| ---------------------------------------------- | ----------------------------- |
|
||||||
|
| Gemeinsame persistente Pause | implementiert, nicht geprüft |
|
||||||
|
| Abschnitts- und Gesamtreset bleiben unabhängig | implementiert, nicht geprüft |
|
||||||
|
| Gesamt-/Restzeit ohne Doppelerfassung | implementiert, nicht geprüft |
|
||||||
|
| Ende, Wiederaufnahme und neuer Lauf getrennt | implementiert, nicht geprüft |
|
||||||
|
| Privates Feld „Interaktion“ durch alle Kopien | implementiert, nicht geprüft |
|
||||||
|
| Öffentliche Positivliste unverändert | implementiert, nicht geprüft |
|
||||||
|
| Classic-Regular-Icons und Drawer-Schließen | implementiert, nicht geprüft |
|
||||||
|
|
||||||
|
Für diese Erweiterung wurden auf ausdrücklichen Auftrag keine Tests erstellt und
|
||||||
|
keine Typ-, Test-, Build-, Migrations- oder Browserläufe ausgeführt. Die oben
|
||||||
|
dokumentierten früheren Prüfungen von 0.5.12 wurden nicht als Nachweis für die
|
||||||
|
neuen Funktionen wiederverwendet.
|
||||||
+18
-3
@@ -43,7 +43,7 @@ und spätere Bereiche.
|
|||||||
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
|
| Taxonomie | getrennte `section_categories`, `formats`, `topics` samt optionalem Themenicon; Pieces und Vorschläge mehrfach verknüpft |
|
||||||
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
|
| Inhalt | `pieces` mit Status, Soft Delete, Favorit, Revision, Texten, Quellen, Werkzeugen und privaten Medien; Vorlagen speichern Snapshots |
|
||||||
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
|
| Sendungsbau | `shows`, geordnete `show_sections`, `piece_usages` samt Quellen-/Werkzeug-/Themen-/Mediensnapshots, Reservebank und Vorlagen |
|
||||||
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnittstimer und getrennte Präsentationsausgabe |
|
| Live | expliziter Live-Stand, aktuelles Element, tatsächliche Piece-Zeiten, persistente Abschnitts-/Gesamttimer und getrennte Präsentationsausgabe |
|
||||||
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
| Historie | unveränderliche Abschluss-Snapshots und Verwendungshistorie; Übersprungenes zählt nicht als gesendet |
|
||||||
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
| Freigaben | widerrufbare, gehashte Lesetokens mit Sendung, Umfang, Passwortoption und Ablaufzeit |
|
||||||
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
| RSS | Feedquellen, Abrufläufe und externe Einträge mit stabiler Herkunfts-ID sowie Status neu/übernommen/verworfen |
|
||||||
@@ -68,6 +68,10 @@ Ein Live-Abschnittstimer entsteht ausschließlich beim tatsächlichen Eintritt i
|
|||||||
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
|
einen Abschnitt mit positiver expliziter Dauer. Er speichert dieses Soll als
|
||||||
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
|
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
|
||||||
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
|
selben Abschnitt erzeugen deshalb weder Reset noch doppelte Piece-Laufzeit.
|
||||||
|
Der Gesamttimer speichert ebenfalls mehrere Laufintervalle. Eine gemeinsame
|
||||||
|
Pause schließt die offenen Piece-, Abschnitts- und Gesamtintervalle; Fortsetzen
|
||||||
|
öffnet neue Intervalle, ohne die Pause als Laufzeit zu zählen. Abschnitts- und
|
||||||
|
Gesamtreset ersetzen ausschließlich die jeweilige Zeitbasis.
|
||||||
|
|
||||||
## Anmeldung und Rechte
|
## Anmeldung und Rechte
|
||||||
|
|
||||||
@@ -156,7 +160,7 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
|
|||||||
weitere Bearbeitung geschützt.
|
weitere Bearbeitung geschützt.
|
||||||
|
|
||||||
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
Beim Einplanen werden Titel, Dauer, Einleitung, Recherche, Moderation,
|
||||||
Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
Interaktion, Überleitung und der vollständige Präsentationszustand aus dem sendefertigen
|
||||||
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
Piece kopiert. Dazu gehören Modus, ausgewählte Quelladresse samt Zeitmarke,
|
||||||
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
|
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
|
||||||
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
|
Tabellen alle Quellen, Werkzeugnamen samt Start-/Arbeitslink und Notiz, die
|
||||||
@@ -233,6 +237,15 @@ liegen als mehrere Start-/Endintervalle je Verwendung vor. Damit bleiben
|
|||||||
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
|
Zurückgehen, Wiederaufnahme und bereits abgeschlossene Laufzeit getrennt von der
|
||||||
geplanten Dauer nachvollziehbar.
|
geplanten Dauer nachvollziehbar.
|
||||||
|
|
||||||
|
`live_show_timer_runs` hält die unabhängige Gesamtzeit. Pause und Fortsetzen
|
||||||
|
behandeln Gesamt-, Abschnitts- und Piece-Intervalle gemeinsam. Beenden setzt den
|
||||||
|
Sendungsstatus auf `completed`, schließt laufende Intervalle, markiert offene
|
||||||
|
Pieces aber nicht als erledigt. Eine ausdrückliche Wiederaufnahme bewahrt Zeiten
|
||||||
|
und Fortschritt; nur „Neuer Durchlauf“ verwirft Laufintervalle und setzt die
|
||||||
|
Piece-Status zurück. Die Restzeitschätzung verwendet je Abschnitt entweder die
|
||||||
|
positive Abschnittsdauer oder die verbleibenden Piece-Dauern und zählt beide nie
|
||||||
|
zusammen.
|
||||||
|
|
||||||
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
|
Die interne Route `/live` benötigt weiterhin eine aktive Sitzung und darf
|
||||||
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
|
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
|
||||||
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
|
den stabilen Kanal `/heinzlive/default` in einem reduzierten Layout ohne
|
||||||
@@ -243,7 +256,9 @@ rohen Schlüssel, weil die angemeldete Steuerung dieselbe stabile OBS-Adresse
|
|||||||
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
|
erneut ausgeben können muss. Datenbank und Sicherungen sind deshalb Teil der
|
||||||
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
|
||||||
erneutes Öffnen behält ihn bei.
|
erneutes Öffnen behält ihn bei.
|
||||||
Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
Das private Markdownfeld „Interaktion“ wird am Original und als
|
||||||
|
Verwendungssnapshot gespeichert und ausschließlich in der authentifizierten
|
||||||
|
Live-Regie gerendert. Page, Polling-JSON und Proxyrouten leiten ihre Daten aus einer eigenen
|
||||||
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
|
Positivliste ab. Private Quellen-/Werkzeug-Snapshots, Arbeitslinks, Notizen und
|
||||||
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
|
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
|
||||||
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
|
gewählte Link- oder Videoquelle und deren erlaubte Vorschau-URL können die
|
||||||
|
|||||||
@@ -402,3 +402,26 @@ kleines geprüftes Feld an `users` gespeichert. Sie ist weder Bestandteil einer
|
|||||||
Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält
|
Sendung noch Browserzustand. Der Kalender dient nur der Navigation und erhält
|
||||||
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
|
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
|
||||||
bestehende serverseitig validierte Sendungsbearbeitung erfolgt.
|
bestehende serverseitig validierte Sendungsbearbeitung erfolgt.
|
||||||
|
|
||||||
|
## D-026: Gemeinsame Live-Pause und getrennte Zeitbasen
|
||||||
|
|
||||||
|
Piece-Laufzeit, Abschnittszeit und Gesamtzeit bleiben getrennte persistente
|
||||||
|
Intervallmengen. Pause schließt in einer Live-Transaktion alle offenen
|
||||||
|
Intervalle, Fortsetzen eröffnet sie am gespeicherten Fortschritt erneut. Dadurch
|
||||||
|
zählt weder Browserwechsel noch eine beliebig lange Pause zur Sendezeit. Ein
|
||||||
|
Abschnittsreset ersetzt nur den aktiven Abschnittstimer; ein Gesamtreset löscht
|
||||||
|
nur die Gesamtlaufintervalle. Beide ändern weder Piece-Status noch Reihenfolge,
|
||||||
|
Restplanung oder Präsentationsstand.
|
||||||
|
|
||||||
|
Die Restzeit ist eine Ableitung, kein zweiter Ablaufplan. Für jeden verbleibenden
|
||||||
|
Abschnitt gilt entweder dessen positive explizite Dauer oder die Summe seiner
|
||||||
|
verbleibenden Piece-Dauern. Bereits abgeschlossene Abschnitte werden ausgelassen,
|
||||||
|
ungeplante Abschnitte werden sichtbar gezählt und eine Überziehung reduziert
|
||||||
|
nicht die geplante Zeit kommender Inhalte.
|
||||||
|
|
||||||
|
`completed` beendet einen Durchlauf, nicht automatisch dessen offene Pieces.
|
||||||
|
Wiederaufnahme und neuer Durchlauf sind deshalb getrennte Befehle: Erstere
|
||||||
|
bewahrt Intervalle und Fortschritt, letzterer setzt beides ausdrücklich zurück.
|
||||||
|
Das private Markdownfeld „Interaktion“ folgt derselben Original-/Snapshotgrenze
|
||||||
|
wie Moderation und Überleitung, ist aber nicht Teil der öffentlichen
|
||||||
|
Präsentations-Positivliste.
|
||||||
+29
-1
@@ -45,6 +45,17 @@ Livezustände bleiben technisch getrennt.
|
|||||||
|
|
||||||
### Ablauf und Liveverhalten
|
### Ablauf und Liveverhalten
|
||||||
|
|
||||||
|
- Pause und Fortsetzen steuern Piece-, Abschnitts- und Gesamtzeit gemeinsam und
|
||||||
|
zählen Pausen auch nach Reload oder Navigation nicht mit.
|
||||||
|
- Gelaufene Gesamtzeit und geschätzte Restzeit lassen sich direkt umschalten;
|
||||||
|
die Auswahl bleibt lokal erhalten. Ungeplante verbleibende Abschnitte sind
|
||||||
|
sichtbar, ohne Abschnitts- und Piece-Dauern doppelt zu zählen.
|
||||||
|
- Abschnitts- und Gesamtzeit lassen sich getrennt nach Bestätigung zurücksetzen.
|
||||||
|
Ablaufposition, Piece-Status, Restplanung und Präsentation bleiben dabei
|
||||||
|
unverändert.
|
||||||
|
- Sendungen lassen sich ausdrücklich vorbereiten, starten, pausieren und
|
||||||
|
beenden. Nach dem Ende sind „Lauf fortsetzen“ und „Neuer Durchlauf“ getrennte
|
||||||
|
Aktionen; offene Pieces werden beim Beenden nicht automatisch erledigt.
|
||||||
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
|
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
|
||||||
das aktive Piece bleibt in der Ablaufspalte sichtbar.
|
das aktive Piece bleibt in der Ablaufspalte sichtbar.
|
||||||
- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
|
- Pieces kennzeichnen tatsächliches Live-Piece, lokal betrachtetes Piece und
|
||||||
@@ -72,6 +83,15 @@ Livezustände bleiben technisch getrennt.
|
|||||||
Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt
|
Glasflächen. Es bleibt in kleinen Fenstern scrollbar erreichbar und schließt
|
||||||
mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück.
|
mit Escape oder Außenklick; der Fokus kehrt zum Menüauslöser zurück.
|
||||||
|
|
||||||
|
### Interaktion
|
||||||
|
|
||||||
|
- „Interaktion“ ergänzt Piece und Sendungsfassung als optionales privates
|
||||||
|
Markdownfeld zwischen Moderationsstichpunkten und Überleitung.
|
||||||
|
- Vorlagen, Duplikate, Einplanungen und die bewusste Aktualisierung aus dem
|
||||||
|
Fundus übernehmen das Feld jeweils als unabhängigen Snapshot.
|
||||||
|
- Die private Live-Regie zeigt das Feld nur bei Inhalt. Die öffentliche
|
||||||
|
heinzlive-Ausgabe übernimmt es nicht.
|
||||||
|
|
||||||
### Sendungsübersicht
|
### Sendungsübersicht
|
||||||
|
|
||||||
- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer
|
- „Liste | Kalender“ schaltet zwischen der bekannten Sendungsliste und einer
|
||||||
@@ -92,12 +112,20 @@ Livezustände bleiben technisch getrennt.
|
|||||||
- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
|
- `0012_show_overview_view.sql` ergänzt ausschließlich die kontobezogene
|
||||||
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
|
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
|
||||||
- Es ist keine Änderung der Konfiguration erforderlich.
|
- Es ist keine Änderung der Konfiguration erforderlich.
|
||||||
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
|
- `0013_live_controls_and_interaction.sql` ergänzt das private Interaktionsfeld
|
||||||
|
und persistente Gesamtzeitintervalle. Bestehende Piece-Laufintervalle werden
|
||||||
|
als Gesamtzeit übernommen.
|
||||||
|
- Bestehende Sendungen, Livezustände, Abschnittstimer, Ausgabekanäle und
|
||||||
Präsentationssnapshots bleiben erhalten.
|
Präsentationssnapshots bleiben erhalten.
|
||||||
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
|
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
|
||||||
|
|
||||||
### Prüfung
|
### Prüfung
|
||||||
|
|
||||||
|
- Für die Timer-, Sendungssteuerungs- und Interaktionserweiterung wurden auf
|
||||||
|
ausdrücklichen Auftrag keine Tests, Builds, Migrationen oder Browserprüfungen
|
||||||
|
ausgeführt.
|
||||||
|
- Die folgenden Nachweise stammen vom vorherigen 0.5.12-Stand und decken diese
|
||||||
|
Ergänzungen nicht ab.
|
||||||
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
|
- 44 Unit- und Verhaltenstests sowie 17 Chromium-E2E-Abläufe sind erfolgreich.
|
||||||
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
|
- Die Live-Regie wurde bei 1440 × 900, 900 × 450, 670 × 450 und 390 × 700 Pixeln
|
||||||
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
|
mit langen Titeln, sichtbarem Regiemenü, unabhängigen Scrollflächen und
|
||||||
|
|||||||
+18
-1
@@ -9,6 +9,20 @@ Etappe 6 wurde nicht begonnen.
|
|||||||
|
|
||||||
## Live-Regie und Sendungskalender 0.5.12
|
## Live-Regie und Sendungskalender 0.5.12
|
||||||
|
|
||||||
|
- gemeinsame persistente Pause für Piece-, Abschnitts- und Gesamttimer;
|
||||||
|
Fortsetzen zählt Pausenzeit weder nach Navigation noch nach Reload mit
|
||||||
|
- getrennte bestätigungspflichtige Resets für Abschnitts- und Gesamtzeit, ohne
|
||||||
|
Piece-Status, Ablaufposition, Restplanung oder Präsentationsstand zu verändern
|
||||||
|
- umschaltbare, lokal gemerkte Anzeige von gelaufener Gesamtzeit und geschätzter
|
||||||
|
Restzeit; Abschnitte ohne eigene Planung werden kenntlich gemacht und
|
||||||
|
Abschnitts- sowie Piece-Dauern nie doppelt addiert
|
||||||
|
- explizite Sendungssteuerung für Entwurf, Bereit, Start, Pause, Ende,
|
||||||
|
Wiederaufnahme eines beendeten Laufs und klar getrennten neuen Durchlauf
|
||||||
|
- optionales privates Markdownfeld „Interaktion“ zwischen Moderation und
|
||||||
|
Überleitung; Original, Vorlage, Sendungsfassung, Duplikation und bewusste
|
||||||
|
Fundus-Aktualisierung führen jeweils unabhängige Snapshots
|
||||||
|
- „Interaktion“, Timer- und Sendungssteuerung bleiben aus der öffentlichen
|
||||||
|
heinzlive-Projektion ausgeschlossen
|
||||||
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
|
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
|
||||||
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
|
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
|
||||||
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
|
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
|
||||||
@@ -53,7 +67,10 @@ Etappe 6 wurde nicht begonnen.
|
|||||||
|
|
||||||
Etappe 6 bleibt unverändert unbegonnen.
|
Etappe 6 bleibt unverändert unbegonnen.
|
||||||
|
|
||||||
Die lokale Prüfmatrix umfasst 44 Verhaltenstests und 17 Chromium-Abläufe.
|
Die bestehende lokale Prüfmatrix umfasst 44 Verhaltenstests und 17
|
||||||
|
Chromium-Abläufe. Für die Timer-, Sendungssteuerungs- und Interaktionserweiterung
|
||||||
|
wurden auf ausdrücklichen Auftrag keine Tests, Builds oder Browserprüfungen
|
||||||
|
ausgeführt; dieser neue Stand ist daher noch nicht verifiziert.
|
||||||
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
|
Reales OBS, echte YouTube-/TikTok-Wiedergabe sowie Safari auf einem iOS-Gerät
|
||||||
bleiben mangels verfügbarer Umgebung offen.
|
bleiben mangels verfügbarer Umgebung offen.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE pieces ADD COLUMN interaction_text TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE piece_usages ADD COLUMN interaction_snapshot TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
CREATE TABLE live_show_timer_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
show_id TEXT NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
started_at_utc TEXT NOT NULL,
|
||||||
|
ended_at_utc TEXT,
|
||||||
|
CHECK (ended_at_utc IS NULL OR ended_at_utc >= started_at_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_one_open_show_timer_run
|
||||||
|
ON live_show_timer_runs(show_id)
|
||||||
|
WHERE ended_at_utc IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_live_show_timer_runs_show
|
||||||
|
ON live_show_timer_runs(show_id, started_at_utc);
|
||||||
|
|
||||||
|
INSERT INTO live_show_timer_runs (id, show_id, started_at_utc, ended_at_utc)
|
||||||
|
SELECT 'migrated-' || id, show_id, started_at_utc, ended_at_utc
|
||||||
|
FROM live_usage_runs;
|
||||||
+37
-8
@@ -1025,7 +1025,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-status-row {
|
.live-status-row {
|
||||||
grid-template-columns: minmax(12rem, 1fr) auto auto;
|
grid-template-columns: minmax(8rem, 1fr) auto auto auto;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -1047,7 +1047,9 @@ textarea:focus {
|
|||||||
|
|
||||||
.live-output-state small,
|
.live-output-state small,
|
||||||
.live-next-inline small,
|
.live-next-inline small,
|
||||||
.section-countdown span {
|
.section-countdown span,
|
||||||
|
.total-countdown span,
|
||||||
|
.total-countdown small {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 0.59rem;
|
font-size: 0.59rem;
|
||||||
font-weight: 720;
|
font-weight: 720;
|
||||||
@@ -1132,7 +1134,13 @@ textarea:focus {
|
|||||||
box-shadow: 0 0 0 0.25rem color-mix(in srgb, #d65c55 15%, transparent);
|
box-shadow: 0 0 0 0.25rem color-mix(in srgb, #d65c55 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-countdown {
|
.live-pulse.paused {
|
||||||
|
background: #d69b3e;
|
||||||
|
box-shadow: 0 0 0 0.25rem color-mix(in srgb, #d69b3e 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-countdown,
|
||||||
|
.total-countdown {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 6.8rem;
|
min-width: 6.8rem;
|
||||||
gap: 0.08rem;
|
gap: 0.08rem;
|
||||||
@@ -1143,11 +1151,30 @@ textarea:focus {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-countdown strong {
|
.total-countdown {
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-countdown:hover,
|
||||||
|
.total-countdown:focus-visible {
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-countdown strong,
|
||||||
|
.total-countdown strong {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.total-countdown small {
|
||||||
|
color: var(--gold-strong);
|
||||||
|
font-size: 0.5rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.section-countdown.warning {
|
.section-countdown.warning {
|
||||||
border-color: #f3ba63;
|
border-color: #f3ba63;
|
||||||
color: #b87600;
|
color: #b87600;
|
||||||
@@ -1893,7 +1920,8 @@ textarea:focus {
|
|||||||
padding: 0.4rem 0.55rem;
|
padding: 0.4rem 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-countdown {
|
.section-countdown,
|
||||||
|
.total-countdown {
|
||||||
padding: 0.25rem 0.45rem;
|
padding: 0.25rem 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1946,7 +1974,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-status-row {
|
.live-status-row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding-top: 0.2rem;
|
padding-top: 0.2rem;
|
||||||
}
|
}
|
||||||
@@ -1963,8 +1991,9 @@ textarea:focus {
|
|||||||
padding-inline: 0.35rem;
|
padding-inline: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-countdown {
|
.section-countdown,
|
||||||
min-width: 5.5rem;
|
.total-countdown {
|
||||||
|
min-width: 4.8rem;
|
||||||
padding: 0.28rem 0.4rem;
|
padding: 0.28rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,13 @@
|
|||||||
rows={10}
|
rows={10}
|
||||||
bind:value={draft.moderation}
|
bind:value={draft.moderation}
|
||||||
/>
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="interaction"
|
||||||
|
label="Interaktion"
|
||||||
|
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
||||||
|
privateField
|
||||||
|
bind:value={draft.interaction}
|
||||||
|
/>
|
||||||
<MarkdownEditorField
|
<MarkdownEditorField
|
||||||
name="transition"
|
name="transition"
|
||||||
label="Überleitung"
|
label="Überleitung"
|
||||||
|
|||||||
+30
-10
@@ -46,6 +46,7 @@ export type PieceInput = {
|
|||||||
intro: string;
|
intro: string;
|
||||||
researchText: string;
|
researchText: string;
|
||||||
moderationNotes: string;
|
moderationNotes: string;
|
||||||
|
interactionText?: string;
|
||||||
transitionText: string;
|
transitionText: string;
|
||||||
presentationText: string;
|
presentationText: string;
|
||||||
presentationMode: PresentationMode;
|
presentationMode: PresentationMode;
|
||||||
@@ -83,7 +84,10 @@ export type PieceListItem = {
|
|||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
|
export type PieceEditor = Omit<
|
||||||
|
PieceInput,
|
||||||
|
'status' | 'tools' | 'presentationMediaId' | 'interactionText'
|
||||||
|
> & {
|
||||||
id: string;
|
id: string;
|
||||||
status: PieceStatus;
|
status: PieceStatus;
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -92,9 +96,12 @@ export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMed
|
|||||||
media: PieceMedia[];
|
media: PieceMedia[];
|
||||||
presentationMediaId: string | null;
|
presentationMediaId: string | null;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
|
interactionText: string;
|
||||||
tools: PieceTool[];
|
tools: PieceTool[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ValidatedPieceInput = PieceInput;
|
||||||
|
|
||||||
export type PieceMedia = {
|
export type PieceMedia = {
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
@@ -187,7 +194,7 @@ function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories',
|
|||||||
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
|
if (count.count !== ids.length) throw new Error('INVALID_TAXONOMY');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePieceInput(input: PieceInput): PieceInput {
|
function validatePieceInput(input: PieceInput): ValidatedPieceInput {
|
||||||
const title = cleanText(input.title, 200, 'title');
|
const title = cleanText(input.title, 200, 'title');
|
||||||
if (!title) throw new Error('INVALID_TITLE');
|
if (!title) throw new Error('INVALID_TITLE');
|
||||||
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
|
if (!['inbox', 'preparing', 'ready'].includes(input.status)) throw new Error('INVALID_STATUS');
|
||||||
@@ -252,6 +259,10 @@ function validatePieceInput(input: PieceInput): PieceInput {
|
|||||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||||
researchText: cleanText(input.researchText, 50_000, 'research'),
|
researchText: cleanText(input.researchText, 50_000, 'research'),
|
||||||
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
|
moderationNotes: cleanText(input.moderationNotes, 50_000, 'moderation'),
|
||||||
|
interactionText:
|
||||||
|
input.interactionText === undefined
|
||||||
|
? undefined
|
||||||
|
: cleanText(input.interactionText, 50_000, 'interaction'),
|
||||||
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
|
transitionText: cleanText(input.transitionText, 50_000, 'transition'),
|
||||||
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
|
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
|
||||||
presentationMode: input.presentationMode,
|
presentationMode: input.presentationMode,
|
||||||
@@ -402,6 +413,7 @@ type PieceRow = {
|
|||||||
intro: string;
|
intro: string;
|
||||||
research_text: string;
|
research_text: string;
|
||||||
moderation_notes: string;
|
moderation_notes: string;
|
||||||
|
interaction_text: string;
|
||||||
transition_text: string;
|
transition_text: string;
|
||||||
presentation_text: string;
|
presentation_text: string;
|
||||||
presentation_mode: PresentationMode;
|
presentation_mode: PresentationMode;
|
||||||
@@ -428,7 +440,7 @@ export function getPiece(id: string): PieceEditor | null {
|
|||||||
const row = database
|
const row = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
|
`SELECT id, title, output_title, summary, format_id, status, duration_seconds, intro, research_text,
|
||||||
moderation_notes, transition_text, presentation_text, presentation_mode,
|
moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
|
||||||
presentation_source_position, presentation_title, presentation_description,
|
presentation_source_position, presentation_title, presentation_description,
|
||||||
presentation_image_url, presentation_site_name, presentation_provider,
|
presentation_image_url, presentation_site_name, presentation_provider,
|
||||||
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
presentation_provider_id, presentation_title_manual, presentation_description_manual,
|
||||||
@@ -452,6 +464,7 @@ export function getPiece(id: string): PieceEditor | null {
|
|||||||
intro: row.intro,
|
intro: row.intro,
|
||||||
researchText: row.research_text,
|
researchText: row.research_text,
|
||||||
moderationNotes: row.moderation_notes,
|
moderationNotes: row.moderation_notes,
|
||||||
|
interactionText: row.interaction_text,
|
||||||
transitionText: row.transition_text,
|
transitionText: row.transition_text,
|
||||||
presentationText: row.presentation_text,
|
presentationText: row.presentation_text,
|
||||||
presentationMode: row.presentation_mode,
|
presentationMode: row.presentation_mode,
|
||||||
@@ -600,7 +613,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
|||||||
const result = database
|
const result = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
|
`UPDATE pieces SET title = ?, output_title = ?, summary = ?, format_id = ?, status = ?, duration_seconds = ?,
|
||||||
intro = ?, research_text = ?, moderation_notes = ?, transition_text = ?, presentation_text = ?,
|
intro = ?, research_text = ?, moderation_notes = ?, interaction_text = COALESCE(?, interaction_text), transition_text = ?, presentation_text = ?,
|
||||||
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
|
presentation_mode = ?, presentation_source_position = ?, presentation_title = ?,
|
||||||
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
|
presentation_description = ?, presentation_image_url = ?, presentation_site_name = ?,
|
||||||
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
|
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
|
||||||
@@ -619,6 +632,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
|||||||
input.intro,
|
input.intro,
|
||||||
input.researchText,
|
input.researchText,
|
||||||
input.moderationNotes,
|
input.moderationNotes,
|
||||||
|
input.interactionText ?? null,
|
||||||
input.transitionText,
|
input.transitionText,
|
||||||
input.presentationText,
|
input.presentationText,
|
||||||
input.presentationMode,
|
input.presentationMode,
|
||||||
@@ -650,7 +664,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
|
|||||||
return { revision: expectedRevision + 1, updatedAtUtc };
|
return { revision: expectedRevision + 1, updatedAtUtc };
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankPieceInput(): PieceInput {
|
function blankPieceInput(): ValidatedPieceInput {
|
||||||
return {
|
return {
|
||||||
title: 'Neues Piece',
|
title: 'Neues Piece',
|
||||||
outputTitle: '',
|
outputTitle: '',
|
||||||
@@ -661,6 +675,7 @@ function blankPieceInput(): PieceInput {
|
|||||||
intro: '',
|
intro: '',
|
||||||
researchText: '',
|
researchText: '',
|
||||||
moderationNotes: '',
|
moderationNotes: '',
|
||||||
|
interactionText: '',
|
||||||
transitionText: '',
|
transitionText: '',
|
||||||
presentationText: '',
|
presentationText: '',
|
||||||
presentationMode: 'text',
|
presentationMode: 'text',
|
||||||
@@ -682,7 +697,7 @@ function blankPieceInput(): PieceInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateInput(templateId: string): PieceInput {
|
function templateInput(templateId: string): ValidatedPieceInput {
|
||||||
if (templateId === 'blank') return blankPieceInput();
|
if (templateId === 'blank') return blankPieceInput();
|
||||||
const row = getDatabase()
|
const row = getDatabase()
|
||||||
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
|
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
|
||||||
@@ -739,8 +754,8 @@ export function createPiece(template = 'blank'): string {
|
|||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
||||||
research_text, moderation_notes, transition_text, presentation_text, created_at_utc,
|
research_text, moderation_notes, interaction_text, transition_text, presentation_text, created_at_utc,
|
||||||
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -753,6 +768,7 @@ export function createPiece(template = 'blank'): string {
|
|||||||
input.intro,
|
input.intro,
|
||||||
input.researchText,
|
input.researchText,
|
||||||
input.moderationNotes,
|
input.moderationNotes,
|
||||||
|
input.interactionText ?? '',
|
||||||
input.transitionText,
|
input.transitionText,
|
||||||
input.presentationText,
|
input.presentationText,
|
||||||
now,
|
now,
|
||||||
@@ -902,13 +918,16 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
|
|||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
`INSERT INTO pieces (id, title, output_title, summary, format_id, status, duration_seconds, intro,
|
||||||
research_text, moderation_notes, transition_text, presentation_text, presentation_mode,
|
research_text, moderation_notes, interaction_text, transition_text, presentation_text, presentation_mode,
|
||||||
presentation_source_position, presentation_title, presentation_description,
|
presentation_source_position, presentation_title, presentation_description,
|
||||||
presentation_image_url, presentation_site_name, presentation_provider,
|
presentation_image_url, presentation_site_name, presentation_provider,
|
||||||
presentation_provider_id, presentation_title_manual,
|
presentation_provider_id, presentation_title_manual,
|
||||||
presentation_description_manual, presentation_image_manual,
|
presentation_description_manual, presentation_image_manual,
|
||||||
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
|
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
|
||||||
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
VALUES (
|
||||||
|
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
|
||||||
|
)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -920,6 +939,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
|
|||||||
source.intro,
|
source.intro,
|
||||||
source.researchText,
|
source.researchText,
|
||||||
source.moderationNotes,
|
source.moderationNotes,
|
||||||
|
source.interactionText,
|
||||||
source.transitionText,
|
source.transitionText,
|
||||||
source.presentationText,
|
source.presentationText,
|
||||||
source.presentationMode,
|
source.presentationMode,
|
||||||
|
|||||||
+411
-17
@@ -11,6 +11,7 @@ import {
|
|||||||
movePieceUsage,
|
movePieceUsage,
|
||||||
type MovePieceUsageInput,
|
type MovePieceUsageInput,
|
||||||
type PieceUsage,
|
type PieceUsage,
|
||||||
|
type ShowStatus,
|
||||||
type ShowSection
|
type ShowSection
|
||||||
} from './shows.js';
|
} from './shows.js';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export type LiveUsage = PieceUsage & {
|
|||||||
introHtml: string;
|
introHtml: string;
|
||||||
researchHtml: string;
|
researchHtml: string;
|
||||||
moderationHtml: string;
|
moderationHtml: string;
|
||||||
|
interactionHtml: string;
|
||||||
transitionHtml: string;
|
transitionHtml: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +57,10 @@ export type LiveView = {
|
|||||||
totalDurationSeconds: number;
|
totalDurationSeconds: number;
|
||||||
};
|
};
|
||||||
liveRevision: number;
|
liveRevision: number;
|
||||||
|
calculatedAtUtc: string;
|
||||||
startedAtUtc: string | null;
|
startedAtUtc: string | null;
|
||||||
|
endedAtUtc: string | null;
|
||||||
|
isPaused: boolean;
|
||||||
updatedAtUtc: string;
|
updatedAtUtc: string;
|
||||||
presentationUsageId: string | null;
|
presentationUsageId: string | null;
|
||||||
presentationRevision: number;
|
presentationRevision: number;
|
||||||
@@ -72,6 +77,15 @@ export type LiveView = {
|
|||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
runningSinceUtc: string | null;
|
runningSinceUtc: string | null;
|
||||||
};
|
};
|
||||||
|
totalTimer: {
|
||||||
|
elapsedSeconds: number;
|
||||||
|
runningSinceUtc: string | null;
|
||||||
|
};
|
||||||
|
remainingEstimate: {
|
||||||
|
seconds: number;
|
||||||
|
activeCountdownSeconds: number;
|
||||||
|
unplannedSectionCount: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PresentationView = {
|
export type PresentationView = {
|
||||||
@@ -152,6 +166,55 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
|
|||||||
.run(now, showId);
|
.run(now, showId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeShowTimerRun(database: Database.Database, showId: string, now: string) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE live_show_timer_runs SET ended_at_utc = ?
|
||||||
|
WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.run(now, showId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openShowTimerRun(database: Database.Database, showId: string, now: string) {
|
||||||
|
const active = database
|
||||||
|
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||||
|
.get(showId);
|
||||||
|
if (!active) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_show_timer_runs (id, show_id, started_at_utc)
|
||||||
|
VALUES (?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), showId, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTimerRunning(database: Database.Database, showId: string) {
|
||||||
|
return Boolean(
|
||||||
|
database
|
||||||
|
.prepare('SELECT 1 FROM live_show_timer_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||||
|
.get(showId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsedRuns(
|
||||||
|
runs: { started_at_utc: string; ended_at_utc: string | null }[],
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
return runs.reduce(
|
||||||
|
(total, run) =>
|
||||||
|
total +
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
||||||
|
1_000
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
|
function closeSectionTimer(database: Database.Database, showId: string, now: string) {
|
||||||
const timer = database
|
const timer = database
|
||||||
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||||
@@ -167,6 +230,26 @@ function closeSectionTimer(database: Database.Database, showId: string, now: str
|
|||||||
.run(now, timer.id);
|
.run(now, timer.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resumeSectionTimerRun(database: Database.Database, showId: string, now: string) {
|
||||||
|
const timer = database
|
||||||
|
.prepare('SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||||
|
.get(showId) as { id: string } | undefined;
|
||||||
|
if (!timer) return;
|
||||||
|
const running = database
|
||||||
|
.prepare(
|
||||||
|
'SELECT 1 FROM live_section_timer_runs WHERE timer_id = ? AND ended_at_utc IS NULL'
|
||||||
|
)
|
||||||
|
.get(timer.id);
|
||||||
|
if (!running) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||||
|
VALUES (?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), timer.id, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncSectionTimer(
|
function syncSectionTimer(
|
||||||
database: Database.Database,
|
database: Database.Database,
|
||||||
showId: string,
|
showId: string,
|
||||||
@@ -237,7 +320,8 @@ function executeCommand(
|
|||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
action: string,
|
action: string,
|
||||||
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
|
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
|
||||||
nowValue?: Date
|
nowValue?: Date,
|
||||||
|
allowCompleted = false
|
||||||
) {
|
) {
|
||||||
requireCommandId(commandId);
|
requireCommandId(commandId);
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
@@ -251,7 +335,7 @@ function executeCommand(
|
|||||||
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
|
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
|
if (show.status === 'completed' && !allowCompleted) throw new Error('SHOW_COMPLETED');
|
||||||
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
|
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
|
||||||
operation(database, state, now);
|
operation(database, state, now);
|
||||||
const updated = database
|
const updated = database
|
||||||
@@ -307,6 +391,7 @@ function liveUsage(
|
|||||||
introHtml: renderMarkdown(usage.intro),
|
introHtml: renderMarkdown(usage.intro),
|
||||||
researchHtml: renderMarkdown(usage.research),
|
researchHtml: renderMarkdown(usage.research),
|
||||||
moderationHtml: renderMarkdown(usage.moderation),
|
moderationHtml: renderMarkdown(usage.moderation),
|
||||||
|
interactionHtml: renderMarkdown(usage.interaction),
|
||||||
transitionHtml: renderMarkdown(usage.transition)
|
transitionHtml: renderMarkdown(usage.transition)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -351,18 +436,7 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
)
|
)
|
||||||
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
|
.all(timer.id) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||||
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
|
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
|
||||||
const elapsedSeconds = runs.reduce(
|
const elapsedSeconds = elapsedRuns(runs, now);
|
||||||
(total, run) =>
|
|
||||||
total +
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor(
|
|
||||||
(new Date(run.ended_at_utc ?? now).getTime() - new Date(run.started_at_utc).getTime()) /
|
|
||||||
1_000
|
|
||||||
)
|
|
||||||
),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
sectionTimer = {
|
sectionTimer = {
|
||||||
sectionId: timer.section_id,
|
sectionId: timer.section_id,
|
||||||
sectionName: timer.section_name,
|
sectionName: timer.section_name,
|
||||||
@@ -371,6 +445,50 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
runningSinceUtc: running?.started_at_utc ?? null
|
runningSinceUtc: running?.started_at_utc ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const totalTimerRuns = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT started_at_utc, ended_at_utc FROM live_show_timer_runs
|
||||||
|
WHERE show_id = ? ORDER BY started_at_utc`
|
||||||
|
)
|
||||||
|
.all(showId) as { started_at_utc: string; ended_at_utc: string | null }[];
|
||||||
|
const runningTotalTimer = totalTimerRuns.find((run) => run.ended_at_utc === null) ?? null;
|
||||||
|
const totalElapsedSeconds = elapsedRuns(totalTimerRuns, now);
|
||||||
|
const remainingAnchor = current ?? next;
|
||||||
|
let reachedRemaining = !remainingAnchor;
|
||||||
|
let remainingSeconds = 0;
|
||||||
|
let activeCountdownSeconds = 0;
|
||||||
|
let unplannedSectionCount = 0;
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.id === remainingAnchor?.sectionId) reachedRemaining = true;
|
||||||
|
if (!reachedRemaining) continue;
|
||||||
|
const remainingUsages = section.usages.filter(
|
||||||
|
(usage) => usage.status === 'open' || usage.status === 'current'
|
||||||
|
);
|
||||||
|
if (remainingUsages.length === 0) continue;
|
||||||
|
|
||||||
|
if (section.id === current?.sectionId) {
|
||||||
|
if (section.durationSeconds > 0 && sectionTimer?.sectionId === section.id) {
|
||||||
|
activeCountdownSeconds = Math.max(0, section.durationSeconds - sectionTimer.elapsedSeconds);
|
||||||
|
remainingSeconds += activeCountdownSeconds;
|
||||||
|
} else {
|
||||||
|
remainingSeconds += remainingUsages.reduce((total, usage) => {
|
||||||
|
const elapsed = usage.id === current?.id ? usage.actualDurationSeconds : 0;
|
||||||
|
const remainder = Math.max(0, usage.durationSeconds - elapsed);
|
||||||
|
if (usage.id === current?.id) activeCountdownSeconds = remainder;
|
||||||
|
return total + remainder;
|
||||||
|
}, 0);
|
||||||
|
if (section.durationSeconds <= 0) unplannedSectionCount += 1;
|
||||||
|
}
|
||||||
|
} else if (section.durationSeconds > 0) {
|
||||||
|
remainingSeconds += section.durationSeconds;
|
||||||
|
} else {
|
||||||
|
remainingSeconds += remainingUsages.reduce(
|
||||||
|
(total, usage) => total + usage.durationSeconds,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
unplannedSectionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
show: {
|
show: {
|
||||||
id: show.id,
|
id: show.id,
|
||||||
@@ -380,7 +498,10 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
totalDurationSeconds: show.totalDurationSeconds
|
totalDurationSeconds: show.totalDurationSeconds
|
||||||
},
|
},
|
||||||
liveRevision: state.revision,
|
liveRevision: state.revision,
|
||||||
|
calculatedAtUtc: now,
|
||||||
startedAtUtc: state.started_at_utc,
|
startedAtUtc: state.started_at_utc,
|
||||||
|
endedAtUtc: state.ended_at_utc,
|
||||||
|
isPaused: Boolean(state.started_at_utc && !state.ended_at_utc && !runningTotalTimer),
|
||||||
updatedAtUtc: state.updated_at_utc,
|
updatedAtUtc: state.updated_at_utc,
|
||||||
presentationUsageId: state.presentation_usage_id,
|
presentationUsageId: state.presentation_usage_id,
|
||||||
presentationRevision: state.presentation_revision,
|
presentationRevision: state.presentation_revision,
|
||||||
@@ -391,7 +512,16 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
|||||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||||
.length,
|
.length,
|
||||||
totalCount: usages.length,
|
totalCount: usages.length,
|
||||||
sectionTimer
|
sectionTimer,
|
||||||
|
totalTimer: {
|
||||||
|
elapsedSeconds: totalElapsedSeconds,
|
||||||
|
runningSinceUtc: runningTotalTimer?.started_at_utc ?? null
|
||||||
|
},
|
||||||
|
remainingEstimate: {
|
||||||
|
seconds: remainingSeconds,
|
||||||
|
activeCountdownSeconds,
|
||||||
|
unplannedSectionCount
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +539,7 @@ export function startOrResumeLive(
|
|||||||
(database, state, now) => {
|
(database, state, now) => {
|
||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE shows SET status = 'live', updated_at_utc = ?
|
`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ?
|
||||||
WHERE id = ? AND status != 'completed'`
|
WHERE id = ? AND status != 'completed'`
|
||||||
)
|
)
|
||||||
.run(now, showId);
|
.run(now, showId);
|
||||||
@@ -419,10 +549,14 @@ export function startOrResumeLive(
|
|||||||
WHERE show_id = ?`
|
WHERE show_id = ?`
|
||||||
)
|
)
|
||||||
.run(now, showId);
|
.run(now, showId);
|
||||||
|
openShowTimerRun(database, showId, now);
|
||||||
const activeRun = database
|
const activeRun = database
|
||||||
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||||
.get(showId);
|
.get(showId);
|
||||||
if (state.current_usage_id && activeRun) return;
|
if (state.current_usage_id && activeRun) {
|
||||||
|
resumeSectionTimerRun(database, showId, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const current = state.current_usage_id
|
const current = state.current_usage_id
|
||||||
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
|
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -434,6 +568,263 @@ export function startOrResumeLive(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pauseLive(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'pause',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||||
|
if (!showTimerRunning(database, showId)) throw new Error('ALREADY_PAUSED');
|
||||||
|
closeShowTimerRun(database, showId, now);
|
||||||
|
closeActiveRun(database, showId, now);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE live_section_timer_runs SET ended_at_utc = ?
|
||||||
|
WHERE timer_id IN (
|
||||||
|
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
|
||||||
|
) AND ended_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.run(now, showId);
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeLive(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'resume',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||||
|
if (showTimerRunning(database, showId)) throw new Error('ALREADY_RUNNING');
|
||||||
|
openShowTimerRun(database, showId, now);
|
||||||
|
if (state.current_usage_id) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), showId, state.current_usage_id, now);
|
||||||
|
}
|
||||||
|
resumeSectionTimerRun(database, showId, now);
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSectionTimer(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'reset-section-timer',
|
||||||
|
(database, _state, now) => {
|
||||||
|
const timer = database
|
||||||
|
.prepare(
|
||||||
|
`SELECT section_id, planned_duration_seconds
|
||||||
|
FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.get(showId) as
|
||||||
|
| { section_id: string; planned_duration_seconds: number }
|
||||||
|
| undefined;
|
||||||
|
if (!timer) throw new Error('NO_SECTION_TIMER');
|
||||||
|
const running = showTimerRunning(database, showId);
|
||||||
|
closeSectionTimer(database, showId, now);
|
||||||
|
const timerId = randomUUID();
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_section_timers
|
||||||
|
(id, show_id, section_id, planned_duration_seconds, started_at_utc)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(timerId, showId, timer.section_id, timer.planned_duration_seconds, now);
|
||||||
|
if (running) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_section_timer_runs (id, timer_id, started_at_utc)
|
||||||
|
VALUES (?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), timerId, now);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTotalTimer(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'reset-total-timer',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||||
|
const running = showTimerRunning(database, showId);
|
||||||
|
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
|
||||||
|
if (running) openShowTimerRun(database, showId, now);
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShowStatus(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
expectedShowRevision: number,
|
||||||
|
status: Extract<ShowStatus, 'draft' | 'ready'>,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
`status-${status}`,
|
||||||
|
(database, state, now) => {
|
||||||
|
if (state.started_at_utc) throw new Error('ALREADY_STARTED');
|
||||||
|
const result = database
|
||||||
|
.prepare(
|
||||||
|
'UPDATE shows SET status = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ?'
|
||||||
|
)
|
||||||
|
.run(status, now, showId, expectedShowRevision);
|
||||||
|
if (result.changes !== 1) throw new Error('CONFLICT');
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endLive(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'end',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.started_at_utc || state.ended_at_utc) throw new Error('NOT_LIVE');
|
||||||
|
closeShowTimerRun(database, showId, now);
|
||||||
|
closeActiveRun(database, showId, now);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE live_section_timer_runs SET ended_at_utc = ?
|
||||||
|
WHERE timer_id IN (
|
||||||
|
SELECT id FROM live_section_timers WHERE show_id = ? AND ended_at_utc IS NULL
|
||||||
|
) AND ended_at_utc IS NULL`
|
||||||
|
)
|
||||||
|
.run(now, showId);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE show_live_states SET ended_at_utc = ? WHERE show_id = ?`
|
||||||
|
)
|
||||||
|
.run(now, showId);
|
||||||
|
database
|
||||||
|
.prepare(`UPDATE shows SET status = 'completed', revision = revision + 1, updated_at_utc = ? WHERE id = ?`)
|
||||||
|
.run(now, showId);
|
||||||
|
},
|
||||||
|
nowValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeEndedLive(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'resume-ended',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
|
||||||
|
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
|
||||||
|
database
|
||||||
|
.prepare('UPDATE show_live_states SET ended_at_utc = NULL WHERE show_id = ?')
|
||||||
|
.run(showId);
|
||||||
|
openShowTimerRun(database, showId, now);
|
||||||
|
if (state.current_usage_id) {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(randomUUID(), showId, state.current_usage_id, now);
|
||||||
|
resumeSectionTimerRun(database, showId, now);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nowValue,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startNewLiveRun(
|
||||||
|
showId: string,
|
||||||
|
commandId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
nowValue?: Date
|
||||||
|
) {
|
||||||
|
return executeCommand(
|
||||||
|
showId,
|
||||||
|
commandId,
|
||||||
|
expectedRevision,
|
||||||
|
'start-new-run',
|
||||||
|
(database, state, now) => {
|
||||||
|
if (!state.ended_at_utc) throw new Error('NOT_ENDED');
|
||||||
|
database.prepare('DELETE FROM live_usage_runs WHERE show_id = ?').run(showId);
|
||||||
|
database.prepare('DELETE FROM live_show_timer_runs WHERE show_id = ?').run(showId);
|
||||||
|
database.prepare('DELETE FROM live_section_timers WHERE show_id = ?').run(showId);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE piece_usages SET status = 'open'
|
||||||
|
WHERE show_section_id IN (SELECT id FROM show_sections WHERE show_id = ?)`
|
||||||
|
)
|
||||||
|
.run(showId);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`UPDATE show_live_states
|
||||||
|
SET current_usage_id = NULL, started_at_utc = ?, ended_at_utc = NULL
|
||||||
|
WHERE show_id = ?`
|
||||||
|
)
|
||||||
|
.run(now, showId);
|
||||||
|
database.prepare(`UPDATE shows SET status = 'live', revision = revision + 1, updated_at_utc = ? WHERE id = ?`).run(now, showId);
|
||||||
|
openShowTimerRun(database, showId, now);
|
||||||
|
const target = orderedUsages(database, showId).find((usage) => usage.status === 'open');
|
||||||
|
if (target) activateUsage(database, showId, target.id, now);
|
||||||
|
},
|
||||||
|
nowValue,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function advanceCurrent(
|
function advanceCurrent(
|
||||||
showId: string,
|
showId: string,
|
||||||
commandId: string,
|
commandId: string,
|
||||||
@@ -447,6 +838,7 @@ function advanceCurrent(
|
|||||||
expectedRevision,
|
expectedRevision,
|
||||||
status,
|
status,
|
||||||
(database, state, now) => {
|
(database, state, now) => {
|
||||||
|
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||||
if (!state.current_usage_id) throw new Error('NO_CURRENT');
|
if (!state.current_usage_id) throw new Error('NO_CURRENT');
|
||||||
const ordered = orderedUsages(database, showId);
|
const ordered = orderedUsages(database, showId);
|
||||||
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
|
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
|
||||||
@@ -498,6 +890,7 @@ export function goBack(
|
|||||||
expectedRevision,
|
expectedRevision,
|
||||||
'back',
|
'back',
|
||||||
(database, state, now) => {
|
(database, state, now) => {
|
||||||
|
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||||
const ordered = orderedUsages(database, showId);
|
const ordered = orderedUsages(database, showId);
|
||||||
const currentIndex = state.current_usage_id
|
const currentIndex = state.current_usage_id
|
||||||
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
|
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
|
||||||
@@ -533,6 +926,7 @@ export function selectUsage(
|
|||||||
'select',
|
'select',
|
||||||
(database, state, now) => {
|
(database, state, now) => {
|
||||||
if (!state.started_at_utc) throw new Error('NOT_STARTED');
|
if (!state.started_at_utc) throw new Error('NOT_STARTED');
|
||||||
|
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
|
||||||
usageBelongsToShow(database, showId, usageId);
|
usageBelongsToShow(database, showId, usageId);
|
||||||
if (state.current_usage_id === usageId) return;
|
if (state.current_usage_id === usageId) return;
|
||||||
if (state.current_usage_id) {
|
if (state.current_usage_id) {
|
||||||
|
|||||||
+22
-10
@@ -47,6 +47,7 @@ export type PieceUsage = {
|
|||||||
intro: string;
|
intro: string;
|
||||||
research: string;
|
research: string;
|
||||||
moderation: string;
|
moderation: string;
|
||||||
|
interaction: string;
|
||||||
transition: string;
|
transition: string;
|
||||||
presentation: string;
|
presentation: string;
|
||||||
presentationMode: PresentationMode;
|
presentationMode: PresentationMode;
|
||||||
@@ -157,6 +158,7 @@ export type PieceUsageUpdate = {
|
|||||||
intro: string;
|
intro: string;
|
||||||
research: string;
|
research: string;
|
||||||
moderation: string;
|
moderation: string;
|
||||||
|
interaction?: string;
|
||||||
transition: string;
|
transition: string;
|
||||||
presentation: string;
|
presentation: string;
|
||||||
presentationMode: PresentationMode;
|
presentationMode: PresentationMode;
|
||||||
@@ -216,6 +218,7 @@ type UsageRow = {
|
|||||||
intro_snapshot: string;
|
intro_snapshot: string;
|
||||||
research_snapshot: string;
|
research_snapshot: string;
|
||||||
moderation_snapshot: string;
|
moderation_snapshot: string;
|
||||||
|
interaction_snapshot: string;
|
||||||
transition_snapshot: string;
|
transition_snapshot: string;
|
||||||
presentation_snapshot: string;
|
presentation_snapshot: string;
|
||||||
presentation_mode_snapshot: PresentationMode;
|
presentation_mode_snapshot: PresentationMode;
|
||||||
@@ -445,6 +448,7 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
|
|||||||
intro: row.intro_snapshot,
|
intro: row.intro_snapshot,
|
||||||
research: row.research_snapshot,
|
research: row.research_snapshot,
|
||||||
moderation: row.moderation_snapshot,
|
moderation: row.moderation_snapshot,
|
||||||
|
interaction: row.interaction_snapshot,
|
||||||
transition: row.transition_snapshot,
|
transition: row.transition_snapshot,
|
||||||
presentation: row.presentation_snapshot,
|
presentation: row.presentation_snapshot,
|
||||||
presentationMode: row.presentation_mode_snapshot,
|
presentationMode: row.presentation_mode_snapshot,
|
||||||
@@ -793,7 +797,7 @@ export function getShow(id: string): Show | null {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
|
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
|
||||||
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
|
u.duration_seconds, u.title_snapshot, u.output_title_snapshot, u.intro_snapshot, u.research_snapshot,
|
||||||
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot,
|
u.moderation_snapshot, u.interaction_snapshot, u.transition_snapshot, u.presentation_snapshot,
|
||||||
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
|
u.presentation_mode_snapshot, u.presentation_source_label_snapshot,
|
||||||
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
|
u.presentation_source_url_snapshot, u.presentation_source_timecode_snapshot,
|
||||||
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
|
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
|
||||||
@@ -1126,7 +1130,7 @@ export function addPieceUsage(
|
|||||||
const piece = database
|
const piece = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
|
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text, p.moderation_notes,
|
||||||
p.transition_text, p.presentation_text, p.status, p.presentation_mode,
|
p.interaction_text, p.transition_text, p.presentation_text, p.status, p.presentation_mode,
|
||||||
COALESCE(ps.label, '') AS presentation_source_label,
|
COALESCE(ps.label, '') AS presentation_source_label,
|
||||||
COALESCE(ps.url, '') AS presentation_source_url,
|
COALESCE(ps.url, '') AS presentation_source_url,
|
||||||
ps.timecode_seconds AS presentation_source_timecode,
|
ps.timecode_seconds AS presentation_source_timecode,
|
||||||
@@ -1143,6 +1147,7 @@ export function addPieceUsage(
|
|||||||
intro: string;
|
intro: string;
|
||||||
research_text: string;
|
research_text: string;
|
||||||
moderation_notes: string;
|
moderation_notes: string;
|
||||||
|
interaction_text: string;
|
||||||
transition_text: string;
|
transition_text: string;
|
||||||
presentation_text: string;
|
presentation_text: string;
|
||||||
presentation_mode: PresentationMode;
|
presentation_mode: PresentationMode;
|
||||||
@@ -1178,13 +1183,13 @@ export function addPieceUsage(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||||
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
||||||
moderation_snapshot, transition_snapshot, presentation_snapshot,
|
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -1197,6 +1202,7 @@ export function addPieceUsage(
|
|||||||
piece.intro,
|
piece.intro,
|
||||||
piece.research_text,
|
piece.research_text,
|
||||||
piece.moderation_notes,
|
piece.moderation_notes,
|
||||||
|
piece.interaction_text,
|
||||||
piece.transition_text,
|
piece.transition_text,
|
||||||
piece.presentation_text,
|
piece.presentation_text,
|
||||||
piece.presentation_mode,
|
piece.presentation_mode,
|
||||||
@@ -1284,7 +1290,7 @@ export function duplicatePieceUsage(
|
|||||||
const source = database
|
const source = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
|
`SELECT original_piece_id, status, duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot,
|
||||||
research_snapshot, moderation_snapshot, transition_snapshot, presentation_snapshot,
|
research_snapshot, moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||||
@@ -1307,13 +1313,13 @@ export function duplicatePieceUsage(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||||
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
duration_seconds, title_snapshot, output_title_snapshot, intro_snapshot, research_snapshot,
|
||||||
moderation_snapshot, transition_snapshot, presentation_snapshot,
|
moderation_snapshot, interaction_snapshot, transition_snapshot, presentation_snapshot,
|
||||||
presentation_mode_snapshot, presentation_source_label_snapshot,
|
presentation_mode_snapshot, presentation_source_label_snapshot,
|
||||||
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
presentation_source_url_snapshot, presentation_source_timecode_snapshot,
|
||||||
presentation_title_meta_snapshot, presentation_description_snapshot,
|
presentation_title_meta_snapshot, presentation_description_snapshot,
|
||||||
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
presentation_image_url_snapshot, presentation_site_name_snapshot,
|
||||||
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
|
||||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -1326,6 +1332,7 @@ export function duplicatePieceUsage(
|
|||||||
source.intro_snapshot,
|
source.intro_snapshot,
|
||||||
source.research_snapshot,
|
source.research_snapshot,
|
||||||
source.moderation_snapshot,
|
source.moderation_snapshot,
|
||||||
|
source.interaction_snapshot,
|
||||||
source.transition_snapshot,
|
source.transition_snapshot,
|
||||||
source.presentation_snapshot,
|
source.presentation_snapshot,
|
||||||
source.presentation_mode_snapshot,
|
source.presentation_mode_snapshot,
|
||||||
@@ -1360,7 +1367,7 @@ export function refreshPieceUsageFromOriginal(
|
|||||||
const source = database
|
const source = database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
|
`SELECT p.title, p.output_title, p.duration_seconds, p.intro, p.research_text,
|
||||||
p.moderation_notes, p.transition_text, p.presentation_text, p.presentation_mode,
|
p.moderation_notes, p.interaction_text, p.transition_text, p.presentation_text, p.presentation_mode,
|
||||||
COALESCE(ps.label, '') AS presentation_source_label,
|
COALESCE(ps.label, '') AS presentation_source_label,
|
||||||
COALESCE(ps.url, '') AS presentation_source_url,
|
COALESCE(ps.url, '') AS presentation_source_url,
|
||||||
ps.timecode_seconds AS presentation_source_timecode,
|
ps.timecode_seconds AS presentation_source_timecode,
|
||||||
@@ -1379,6 +1386,7 @@ export function refreshPieceUsageFromOriginal(
|
|||||||
intro: string;
|
intro: string;
|
||||||
research_text: string;
|
research_text: string;
|
||||||
moderation_notes: string;
|
moderation_notes: string;
|
||||||
|
interaction_text: string;
|
||||||
transition_text: string;
|
transition_text: string;
|
||||||
presentation_text: string;
|
presentation_text: string;
|
||||||
presentation_mode: PresentationMode;
|
presentation_mode: PresentationMode;
|
||||||
@@ -1397,7 +1405,7 @@ export function refreshPieceUsageFromOriginal(
|
|||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
|
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, duration_seconds = ?, intro_snapshot = ?,
|
||||||
research_snapshot = ?, moderation_snapshot = ?, transition_snapshot = ?,
|
research_snapshot = ?, moderation_snapshot = ?, interaction_snapshot = ?, transition_snapshot = ?,
|
||||||
presentation_snapshot = ?, presentation_mode_snapshot = ?,
|
presentation_snapshot = ?, presentation_mode_snapshot = ?,
|
||||||
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
|
presentation_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
|
||||||
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
|
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
|
||||||
@@ -1412,6 +1420,7 @@ export function refreshPieceUsageFromOriginal(
|
|||||||
source.intro,
|
source.intro,
|
||||||
source.research_text,
|
source.research_text,
|
||||||
source.moderation_notes,
|
source.moderation_notes,
|
||||||
|
source.interaction_text,
|
||||||
source.transition_text,
|
source.transition_text,
|
||||||
source.presentation_text,
|
source.presentation_text,
|
||||||
source.presentation_mode,
|
source.presentation_mode,
|
||||||
@@ -1452,6 +1461,8 @@ export function updatePieceUsage(
|
|||||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||||
research: cleanText(input.research, 50_000, 'research'),
|
research: cleanText(input.research, 50_000, 'research'),
|
||||||
moderation: cleanText(input.moderation, 50_000, 'moderation'),
|
moderation: cleanText(input.moderation, 50_000, 'moderation'),
|
||||||
|
interaction:
|
||||||
|
input.interaction === undefined ? null : cleanText(input.interaction, 50_000, 'interaction'),
|
||||||
transition: cleanText(input.transition, 50_000, 'transition'),
|
transition: cleanText(input.transition, 50_000, 'transition'),
|
||||||
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
||||||
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
||||||
@@ -1473,7 +1484,7 @@ export function updatePieceUsage(
|
|||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
`UPDATE piece_usages SET title_snapshot = ?, output_title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
||||||
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
|
moderation_snapshot = ?, interaction_snapshot = COALESCE(?, interaction_snapshot), transition_snapshot = ?, presentation_snapshot = ?,
|
||||||
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
|
presentation_mode_snapshot = ?, presentation_source_label_snapshot = ?,
|
||||||
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
|
presentation_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
|
||||||
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
|
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
|
||||||
@@ -1487,6 +1498,7 @@ export function updatePieceUsage(
|
|||||||
values.intro,
|
values.intro,
|
||||||
values.research,
|
values.research,
|
||||||
values.moderation,
|
values.moderation,
|
||||||
|
values.interaction,
|
||||||
values.transition,
|
values.transition,
|
||||||
values.presentation,
|
values.presentation,
|
||||||
presentation.mode,
|
presentation.mode,
|
||||||
|
|||||||
@@ -24,13 +24,19 @@ import { listTools } from '#lib/server/tools.js';
|
|||||||
function renderedFields(
|
function renderedFields(
|
||||||
piece: Pick<
|
piece: Pick<
|
||||||
PieceInput,
|
PieceInput,
|
||||||
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
|
| 'intro'
|
||||||
|
| 'researchText'
|
||||||
|
| 'moderationNotes'
|
||||||
|
| 'interactionText'
|
||||||
|
| 'transitionText'
|
||||||
|
| 'presentationText'
|
||||||
>
|
>
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
intro: renderMarkdown(piece.intro),
|
intro: renderMarkdown(piece.intro),
|
||||||
research: renderMarkdown(piece.researchText),
|
research: renderMarkdown(piece.researchText),
|
||||||
moderation: renderMarkdown(piece.moderationNotes),
|
moderation: renderMarkdown(piece.moderationNotes),
|
||||||
|
interaction: renderMarkdown(piece.interactionText ?? ''),
|
||||||
transition: renderMarkdown(piece.transitionText),
|
transition: renderMarkdown(piece.transitionText),
|
||||||
presentation: renderMarkdown(piece.presentationText)
|
presentation: renderMarkdown(piece.presentationText)
|
||||||
};
|
};
|
||||||
@@ -73,6 +79,7 @@ function pieceInput(data: FormData): PieceInput {
|
|||||||
intro: text(data, 'intro'),
|
intro: text(data, 'intro'),
|
||||||
researchText: text(data, 'researchText'),
|
researchText: text(data, 'researchText'),
|
||||||
moderationNotes: text(data, 'moderationNotes'),
|
moderationNotes: text(data, 'moderationNotes'),
|
||||||
|
interactionText: text(data, 'interactionText'),
|
||||||
transitionText: text(data, 'transitionText'),
|
transitionText: text(data, 'transitionText'),
|
||||||
presentationText: text(data, 'presentationText'),
|
presentationText: text(data, 'presentationText'),
|
||||||
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
|
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
|
||||||
|
|||||||
@@ -308,6 +308,13 @@
|
|||||||
rows={10}
|
rows={10}
|
||||||
bind:value={draft.moderationNotes}
|
bind:value={draft.moderationNotes}
|
||||||
/>
|
/>
|
||||||
|
<MarkdownEditorField
|
||||||
|
name="interactionText"
|
||||||
|
label="Interaktion"
|
||||||
|
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
||||||
|
privateField
|
||||||
|
bind:value={draft.interactionText}
|
||||||
|
/>
|
||||||
<MarkdownEditorField
|
<MarkdownEditorField
|
||||||
name="transitionText"
|
name="transitionText"
|
||||||
label="Überleitung"
|
label="Überleitung"
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ import { requireUser } from '#lib/server/auth.js';
|
|||||||
import {
|
import {
|
||||||
completeCurrent,
|
completeCurrent,
|
||||||
clearPresentationChannel,
|
clearPresentationChannel,
|
||||||
|
endLive,
|
||||||
getLiveView,
|
getLiveView,
|
||||||
goBack,
|
goBack,
|
||||||
issuePresentationAccess,
|
issuePresentationAccess,
|
||||||
moveOpenUsage,
|
moveOpenUsage,
|
||||||
|
pauseLive,
|
||||||
presentUsage,
|
presentUsage,
|
||||||
rotatePresentationChannelKey,
|
rotatePresentationChannelKey,
|
||||||
|
resetSectionTimer,
|
||||||
|
resetTotalTimer,
|
||||||
|
resumeEndedLive,
|
||||||
|
resumeLive,
|
||||||
selectUsage,
|
selectUsage,
|
||||||
|
setShowStatus,
|
||||||
skipCurrent,
|
skipCurrent,
|
||||||
|
startNewLiveRun,
|
||||||
startOrResumeLive
|
startOrResumeLive
|
||||||
} from '#lib/server/live.js';
|
} from '#lib/server/live.js';
|
||||||
import { listShows } from '#lib/server/shows.js';
|
import { listShows } from '#lib/server/shows.js';
|
||||||
@@ -41,6 +49,13 @@ function actionError(cause: unknown) {
|
|||||||
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
|
return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' };
|
||||||
if (code === 'NOT_FOUND')
|
if (code === 'NOT_FOUND')
|
||||||
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
|
return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' };
|
||||||
|
if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' };
|
||||||
|
if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING')
|
||||||
|
return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' };
|
||||||
|
if (code === 'NO_SECTION_TIMER')
|
||||||
|
return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' };
|
||||||
|
if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED')
|
||||||
|
return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' };
|
||||||
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
|
if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' };
|
||||||
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
|
return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' };
|
||||||
}
|
}
|
||||||
@@ -75,6 +90,72 @@ export const actions = {
|
|||||||
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
pause: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('pause', () =>
|
||||||
|
pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resume: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resume', () =>
|
||||||
|
resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resetSectionTimer: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resetSectionTimer', () =>
|
||||||
|
resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resetTotalTimer: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resetTotalTimer', () =>
|
||||||
|
resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setStatus: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
const status = text(data, 'status');
|
||||||
|
if (status !== 'draft' && status !== 'ready') {
|
||||||
|
return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' });
|
||||||
|
}
|
||||||
|
return attempt('setStatus', () =>
|
||||||
|
setShowStatus(
|
||||||
|
text(data, 'showId'),
|
||||||
|
text(data, 'commandId'),
|
||||||
|
Number(data.get('revision')),
|
||||||
|
Number(data.get('showRevision')),
|
||||||
|
status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
end: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('end', () =>
|
||||||
|
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resumeEnded: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('resumeEnded', () =>
|
||||||
|
resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
startNewRun: async ({ locals, request }) => {
|
||||||
|
requireUser(locals.user);
|
||||||
|
const data = await request.formData();
|
||||||
|
return attempt('startNewRun', () =>
|
||||||
|
startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
|
||||||
|
);
|
||||||
|
},
|
||||||
complete: async ({ locals, request }) => {
|
complete: async ({ locals, request }) => {
|
||||||
requireUser(locals.user);
|
requireUser(locals.user);
|
||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
|
|||||||
+152
-11
@@ -13,6 +13,7 @@
|
|||||||
let mobileRailOpen = $state(false);
|
let mobileRailOpen = $state(false);
|
||||||
let isMobile = $state(false);
|
let isMobile = $state(false);
|
||||||
let showTimes = $state(true);
|
let showTimes = $state(true);
|
||||||
|
let timerDisplay = $state<'elapsed' | 'remaining'>('elapsed');
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let expandedContext = $state<'section' | 'next' | null>(null);
|
let expandedContext = $state<'section' | 'next' | null>(null);
|
||||||
let popupMessage = $state('');
|
let popupMessage = $state('');
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
let loadedAt = $state(Date.now());
|
let loadedAt = $state(Date.now());
|
||||||
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
||||||
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
||||||
|
let calculatedAtUtc = $state(untrack(() => data.live.calculatedAtUtc));
|
||||||
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
|
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
|
||||||
let pieceScroller = $state<HTMLElement>();
|
let pieceScroller = $state<HTMLElement>();
|
||||||
let railScroller = $state<HTMLElement>();
|
let railScroller = $state<HTMLElement>();
|
||||||
@@ -74,7 +76,9 @@
|
|||||||
);
|
);
|
||||||
let currentActualSeconds = $derived(
|
let currentActualSeconds = $derived(
|
||||||
(data.live.current?.actualDurationSeconds ?? 0) +
|
(data.live.current?.actualDurationSeconds ?? 0) +
|
||||||
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
(data.live.current && data.live.totalTimer.runningSinceUtc
|
||||||
|
? Math.max(0, Math.floor((now - loadedAt) / 1000))
|
||||||
|
: 0)
|
||||||
);
|
);
|
||||||
let viewedActualSeconds = $derived(
|
let viewedActualSeconds = $derived(
|
||||||
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
|
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
|
||||||
@@ -91,17 +95,34 @@
|
|||||||
let hasActiveSectionTimer = $derived(
|
let hasActiveSectionTimer = $derived(
|
||||||
Boolean(
|
Boolean(
|
||||||
data.live.sectionTimer &&
|
data.live.sectionTimer &&
|
||||||
|
data.live.sectionTimer.plannedDurationSeconds > 0 &&
|
||||||
data.live.current &&
|
data.live.current &&
|
||||||
data.live.sectionTimer.sectionId === data.live.current.sectionId
|
data.live.sectionTimer.sectionId === data.live.current.sectionId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
let runningOffsetSeconds = $derived(
|
||||||
|
data.live.totalTimer.runningSinceUtc ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0
|
||||||
|
);
|
||||||
|
let totalElapsedSeconds = $derived(data.live.totalTimer.elapsedSeconds + runningOffsetSeconds);
|
||||||
|
let remainingSeconds = $derived(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
data.live.remainingEstimate.seconds -
|
||||||
|
Math.min(runningOffsetSeconds, data.live.remainingEstimate.activeCountdownSeconds)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextCurrentId = data.live.current?.id ?? null;
|
const nextCurrentId = data.live.current?.id ?? null;
|
||||||
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
if (
|
||||||
|
nextCurrentId !== currentId ||
|
||||||
|
data.live.liveRevision !== loadedRevision ||
|
||||||
|
data.live.calculatedAtUtc !== calculatedAtUtc
|
||||||
|
) {
|
||||||
const changedPiece = nextCurrentId !== currentId;
|
const changedPiece = nextCurrentId !== currentId;
|
||||||
currentId = nextCurrentId;
|
currentId = nextCurrentId;
|
||||||
loadedRevision = data.live.liveRevision;
|
loadedRevision = data.live.liveRevision;
|
||||||
|
calculatedAtUtc = data.live.calculatedAtUtc;
|
||||||
loadedAt = Date.now();
|
loadedAt = Date.now();
|
||||||
if (changedPiece) {
|
if (changedPiece) {
|
||||||
viewedUsageId = nextCurrentId;
|
viewedUsageId = nextCurrentId;
|
||||||
@@ -130,6 +151,9 @@
|
|||||||
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
|
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
|
||||||
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
|
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
|
||||||
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
|
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
|
||||||
|
if (settings.timerDisplay === 'elapsed' || settings.timerDisplay === 'remaining') {
|
||||||
|
timerDisplay = settings.timerDisplay;
|
||||||
|
}
|
||||||
if (settings.scrollPositions && typeof settings.scrollPositions === 'object') {
|
if (settings.scrollPositions && typeof settings.scrollPositions === 'object') {
|
||||||
scrollPositions = Object.fromEntries(
|
scrollPositions = Object.fromEntries(
|
||||||
Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] =>
|
Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] =>
|
||||||
@@ -182,6 +206,7 @@
|
|||||||
promptSize,
|
promptSize,
|
||||||
showRail,
|
showRail,
|
||||||
showTimes,
|
showTimes,
|
||||||
|
timerDisplay,
|
||||||
scrollPositions
|
scrollPositions
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -296,6 +321,11 @@
|
|||||||
persistSettings();
|
persistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleTimerDisplay() {
|
||||||
|
timerDisplay = timerDisplay === 'elapsed' ? 'remaining' : 'elapsed';
|
||||||
|
persistSettings();
|
||||||
|
}
|
||||||
|
|
||||||
function formatCountdown(totalSeconds: number) {
|
function formatCountdown(totalSeconds: number) {
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
@@ -367,7 +397,7 @@
|
|||||||
<header class="live-command-header" inert={mobileRailOpen}>
|
<header class="live-command-header" inert={mobileRailOpen}>
|
||||||
<div class="live-context-row">
|
<div class="live-context-row">
|
||||||
<div class="live-context-summary">
|
<div class="live-context-summary">
|
||||||
<span class="live-pulse" aria-hidden="true"></span>
|
<span class:paused={data.live.isPaused} class="live-pulse" aria-hidden="true"></span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:expanded={expandedContext === 'section'}
|
class:expanded={expandedContext === 'section'}
|
||||||
@@ -425,10 +455,27 @@
|
|||||||
<input type="hidden" name="usageId" value="" />
|
<input type="hidden" name="usageId" value="" />
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button class="text-button" type="submit">Präsentation leeren</button>
|
<button class="text-button" type="submit" disabled={Boolean(data.live.endedAtUtc)}>Präsentation leeren</button>
|
||||||
</form>{/if}
|
</form>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="total-countdown"
|
||||||
|
type="button"
|
||||||
|
onclick={toggleTimerDisplay}
|
||||||
|
aria-label={timerDisplay === 'elapsed'
|
||||||
|
? 'Gelaufene Gesamtzeit, zu Restzeit wechseln'
|
||||||
|
: 'Geschätzte Restzeit, zu gelaufener Zeit wechseln'}
|
||||||
|
>
|
||||||
|
<span>{timerDisplay === 'elapsed' ? 'Gelaufen' : 'Restzeit'}</span>
|
||||||
|
<strong>{formatCountdown(timerDisplay === 'elapsed' ? totalElapsedSeconds : remainingSeconds)}</strong>
|
||||||
|
{#if timerDisplay === 'remaining' && data.live.remainingEstimate.unplannedSectionCount}
|
||||||
|
<small title="Verbleibende Abschnitte ohne geplante Abschnittsdauer"
|
||||||
|
>+ {data.live.remainingEstimate.unplannedSectionCount} ungeplant</small
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class:warning={hasActiveSectionTimer &&
|
class:warning={hasActiveSectionTimer &&
|
||||||
sectionRemainingSeconds <= 120 &&
|
sectionRemainingSeconds <= 120 &&
|
||||||
@@ -509,8 +556,96 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<strong>Sendung</strong>
|
<strong>Sendungssteuerung</strong>
|
||||||
{#if needsResume}<form
|
<small>Status: {data.live.show.status}</small>
|
||||||
|
{#if !data.live.startedAtUtc}
|
||||||
|
<form method="POST" action={liveAction('setStatus')} use:enhance={enhanceCommand}>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="status" value={data.live.show.status === 'ready' ? 'draft' : 'ready'} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="text-button" type="submit">
|
||||||
|
{data.live.show.status === 'ready' ? 'Als Entwurf markieren' : 'Als bereit markieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action={liveAction('start')} use:enhance={enhanceCommand}>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="secondary-button" type="submit"><AppIcon name="play" /> Sendung starten</button>
|
||||||
|
</form>
|
||||||
|
{:else if data.live.endedAtUtc}
|
||||||
|
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="secondary-button" type="submit"><AppIcon name="play" /> Lauf fortsetzen</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={liveAction('startNewRun')}
|
||||||
|
use:enhance={enhanceCommand}
|
||||||
|
onsubmit={(event) => {
|
||||||
|
if (!confirm('Neuen Durchlauf starten? Fortschritt und Zeitmessungen werden zurückgesetzt.')) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="text-button" type="submit"><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" action={liveAction(data.live.isPaused ? 'resume' : 'pause')} use:enhance={enhanceCommand}>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="secondary-button" type="submit">
|
||||||
|
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
|
||||||
|
{data.live.isPaused ? 'Fortsetzen' : 'Pausieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}<form
|
||||||
|
method="POST"
|
||||||
|
action={liveAction('resetSectionTimer')}
|
||||||
|
use:enhance={enhanceCommand}
|
||||||
|
onsubmit={(event) => {
|
||||||
|
if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="text-button" type="submit"><AppIcon name="arrow-rotate-left" /> Abschnittszeit zurücksetzen</button>
|
||||||
|
</form>{/if}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={liveAction('resetTotalTimer')}
|
||||||
|
use:enhance={enhanceCommand}
|
||||||
|
onsubmit={(event) => {
|
||||||
|
if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="text-button" type="submit"><AppIcon name="timer" /> Gesamtzeit zurücksetzen</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={liveAction('end')}
|
||||||
|
use:enhance={enhanceCommand}
|
||||||
|
onsubmit={(event) => {
|
||||||
|
if (!confirm('Sendung wirklich beenden? Offene Pieces bleiben offen.')) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="text-button danger-text" type="submit"><AppIcon name="stop" /> Sendung beenden</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if needsResume && !data.live.endedAtUtc}<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('start')}
|
action={liveAction('start')}
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
@@ -560,6 +695,11 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
<div class="live-markdown">{@html viewedUsage.moderationHtml}</div>
|
<div class="live-markdown">{@html viewedUsage.moderationHtml}</div>
|
||||||
</section>{/if}
|
</section>{/if}
|
||||||
|
{#if viewedUsage.interactionHtml}<section class="live-script-section">
|
||||||
|
<p class="eyebrow">Interaktion</p>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
<div class="live-markdown">{@html viewedUsage.interactionHtml}</div>
|
||||||
|
</section>{/if}
|
||||||
{#if viewedUsage.transitionHtml}<section class="live-script-section transition-card">
|
{#if viewedUsage.transitionHtml}<section class="live-script-section transition-card">
|
||||||
<span>Überleitung</span>
|
<span>Überleitung</span>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
@@ -613,7 +753,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
{/if}
|
{/if}
|
||||||
{#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.transitionHtml}
|
{#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.interactionHtml && !viewedUsage.transitionHtml}
|
||||||
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
|
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -673,6 +813,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
bind:this={railCloseButton}
|
bind:this={railCloseButton}
|
||||||
|
class="dialog-close"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Ablauf schließen"
|
aria-label="Ablauf schließen"
|
||||||
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
|
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
|
||||||
@@ -783,7 +924,7 @@
|
|||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button type="submit" aria-label={`${usage.title} präsentieren`}
|
<button type="submit" disabled={Boolean(data.live.endedAtUtc)} aria-label={`${usage.title} präsentieren`}
|
||||||
><AppIcon name="display" /></button
|
><AppIcon name="display" /></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -892,7 +1033,7 @@
|
|||||||
<input type="hidden" name="usageId" value={viewedUsage.id} />
|
<input type="hidden" name="usageId" value={viewedUsage.id} />
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button class="secondary-button" type="submit">
|
<button class="secondary-button" type="submit" disabled={Boolean(data.live.endedAtUtc)}>
|
||||||
{data.live.presentationUsageId === viewedUsage.id
|
{data.live.presentationUsageId === viewedUsage.id
|
||||||
? 'Erneut präsentieren'
|
? 'Erneut präsentieren'
|
||||||
: 'Präsentieren'}
|
: 'Präsentieren'}
|
||||||
@@ -905,7 +1046,7 @@
|
|||||||
<button
|
<button
|
||||||
class="secondary-button"
|
class="secondary-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!viewingLiveCurrent}
|
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
|
||||||
title={!viewingLiveCurrent
|
title={!viewingLiveCurrent
|
||||||
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
|
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
|
||||||
: undefined}>Überspringen</button
|
: undefined}>Überspringen</button
|
||||||
@@ -918,7 +1059,7 @@
|
|||||||
<button
|
<button
|
||||||
class="primary-button"
|
class="primary-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!viewingLiveCurrent}
|
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
|
||||||
title={!viewingLiveCurrent
|
title={!viewingLiveCurrent
|
||||||
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
|
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export const actions = {
|
|||||||
intro: text(data, 'intro'),
|
intro: text(data, 'intro'),
|
||||||
research: text(data, 'research'),
|
research: text(data, 'research'),
|
||||||
moderation: text(data, 'moderation'),
|
moderation: text(data, 'moderation'),
|
||||||
|
interaction: text(data, 'interaction'),
|
||||||
transition: text(data, 'transition'),
|
transition: text(data, 'transition'),
|
||||||
presentation: text(data, 'presentation'),
|
presentation: text(data, 'presentation'),
|
||||||
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
|
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
|
||||||
|
|||||||
Reference in new issue
Block a user