Add persistent live controls and interaction notes

This commit is contained in:
hnzio committed 2026-10-24 14:31:33 +02:00
1 parent b1474bc33d
commit fa17187af4
16 files changed
+882 -62

No files matched your search

+17
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+8 -1
View File
@@ -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'],
+7
View File
@@ -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"
+81
View File
@@ -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
View File
@@ -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'],