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
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
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 |
| 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 |
| 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 |
| 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 |
@@ -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
unveränderlichen Wert und summiert eigene Laufintervalle; Piece-Wechsel im
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
@@ -156,7 +160,7 @@ erhöht sie atomar. Abgeschlossene Sendungen sind bereits an dieser Grenze gegen
weitere Bearbeitung geschützt.
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,
Metadaten und validierte Anbieter-ID. Zusätzlich speichern eigene geordnete
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
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
Recherche, Moderation und eigene Arbeitsdaten lesen. Die separate Ausgabe nutzt
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
Geheimnisgrenze. Eine Rotation widerruft den bisherigen Schlüssel, normales
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
Uploads werden nicht veröffentlicht; nur Themennamen sowie die ausdrücklich
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
bewusst kein Drag-and-drop, damit eine Terminänderung ausschließlich über die
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
- 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
das aktive Piece bleibt in der Ablaufspalte sichtbar.
- 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
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
- „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
Listen-/Kalenderpräferenz mit dem sicheren Standardwert `list`.
- 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.
- Paket, Lockdatei und Cloudron-Manifest tragen gemeinsam Version `0.5.12`.
### 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.
- 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
+18 -1
View File
@@ -9,6 +9,20 @@ Etappe 6 wurde nicht begonnen.
## 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
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
@@ -53,7 +67,10 @@ Etappe 6 wurde nicht begonnen.
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
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 {
grid-template-columns: minmax(12rem, 1fr) auto auto;
grid-template-columns: minmax(8rem, 1fr) auto auto auto;
gap: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
@@ -1047,7 +1047,9 @@ textarea:focus {
.live-output-state small,
.live-next-inline small,
.section-countdown span {
.section-countdown span,
.total-countdown span,
.total-countdown small {
color: var(--text-faint);
font-size: 0.59rem;
font-weight: 720;
@@ -1132,7 +1134,13 @@ textarea:focus {
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;
min-width: 6.8rem;
gap: 0.08rem;
@@ -1143,11 +1151,30 @@ textarea:focus {
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-variant-numeric: tabular-nums;
}
.total-countdown small {
color: var(--gold-strong);
font-size: 0.5rem;
letter-spacing: 0;
text-transform: none;
}
.section-countdown.warning {
border-color: #f3ba63;
color: #b87600;
@@ -1893,7 +1920,8 @@ textarea:focus {
padding: 0.4rem 0.55rem;
}
.section-countdown {
.section-countdown,
.total-countdown {
padding: 0.25rem 0.45rem;
}
@@ -1946,7 +1974,7 @@ textarea:focus {
}
.live-status-row {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 0.3rem;
padding-top: 0.2rem;
}
@@ -1963,8 +1991,9 @@ textarea:focus {
padding-inline: 0.35rem;
}
.section-countdown {
min-width: 5.5rem;
.section-countdown,
.total-countdown {
min-width: 4.8rem;
padding: 0.28rem 0.4rem;
}
@@ -108,6 +108,13 @@
rows={10}
bind:value={draft.moderation}
/>
<MarkdownEditorField
name="interaction"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interaction}
/>
<MarkdownEditorField
name="transition"
label="Überleitung"
+30 -10
View File
@@ -46,6 +46,7 @@ export type PieceInput = {
intro: string;
researchText: string;
moderationNotes: string;
interactionText?: string;
transitionText: string;
presentationText: string;
presentationMode: PresentationMode;
@@ -83,7 +84,10 @@ export type PieceListItem = {
isFavorite: boolean;
};
export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMediaId'> & {
export type PieceEditor = Omit<
PieceInput,
'status' | 'tools' | 'presentationMediaId' | 'interactionText'
> & {
id: string;
status: PieceStatus;
revision: number;
@@ -92,9 +96,12 @@ export type PieceEditor = Omit<PieceInput, 'status' | 'tools' | 'presentationMed
media: PieceMedia[];
presentationMediaId: string | null;
isFavorite: boolean;
interactionText: string;
tools: PieceTool[];
};
type ValidatedPieceInput = PieceInput;
export type PieceMedia = {
id: string;
originalName: string;
@@ -187,7 +194,7 @@ function requireTaxonomyIds(table: 'formats' | 'topics' | 'section_categories',
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');
if (!title) throw new Error('INVALID_TITLE');
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'),
researchText: cleanText(input.researchText, 50_000, 'research'),
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'),
presentationText: cleanText(input.presentationText, 50_000, 'presentation'),
presentationMode: input.presentationMode,
@@ -402,6 +413,7 @@ type PieceRow = {
intro: string;
research_text: string;
moderation_notes: string;
interaction_text: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
@@ -428,7 +440,7 @@ export function getPiece(id: string): PieceEditor | null {
const row = database
.prepare(
`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_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual, presentation_description_manual,
@@ -452,6 +464,7 @@ export function getPiece(id: string): PieceEditor | null {
intro: row.intro,
researchText: row.research_text,
moderationNotes: row.moderation_notes,
interactionText: row.interaction_text,
transitionText: row.transition_text,
presentationText: row.presentation_text,
presentationMode: row.presentation_mode,
@@ -600,7 +613,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
const result = database
.prepare(
`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_description = ?, presentation_image_url = ?, presentation_site_name = ?,
presentation_provider = ?, presentation_provider_id = ?, presentation_title_manual = ?,
@@ -619,6 +632,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
input.intro,
input.researchText,
input.moderationNotes,
input.interactionText ?? null,
input.transitionText,
input.presentationText,
input.presentationMode,
@@ -650,7 +664,7 @@ export function savePiece(id: string, expectedRevision: number, rawInput: PieceI
return { revision: expectedRevision + 1, updatedAtUtc };
}
function blankPieceInput(): PieceInput {
function blankPieceInput(): ValidatedPieceInput {
return {
title: 'Neues Piece',
outputTitle: '',
@@ -661,6 +675,7 @@ function blankPieceInput(): PieceInput {
intro: '',
researchText: '',
moderationNotes: '',
interactionText: '',
transitionText: '',
presentationText: '',
presentationMode: 'text',
@@ -682,7 +697,7 @@ function blankPieceInput(): PieceInput {
};
}
function templateInput(templateId: string): PieceInput {
function templateInput(templateId: string): ValidatedPieceInput {
if (templateId === 'blank') return blankPieceInput();
const row = getDatabase()
.prepare('SELECT snapshot_json FROM piece_templates WHERE id = ?')
@@ -739,8 +754,8 @@ export function createPiece(template = 'blank'): string {
database
.prepare(
`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,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
research_text, moderation_notes, interaction_text, transition_text, presentation_text, created_at_utc,
updated_at_utc, revision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
id,
@@ -753,6 +768,7 @@ export function createPiece(template = 'blank'): string {
input.intro,
input.researchText,
input.moderationNotes,
input.interactionText ?? '',
input.transitionText,
input.presentationText,
now,
@@ -902,13 +918,16 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
database
.prepare(
`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_image_url, presentation_site_name, presentation_provider,
presentation_provider_id, presentation_title_manual,
presentation_description_manual, presentation_image_manual,
presentation_metadata_fetched_at_utc, created_at_utc, updated_at_utc, revision)
VALUES (?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
VALUES (
?, ?, ?, ?, ?, 'preparing', ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1
)`
)
.run(
id,
@@ -920,6 +939,7 @@ export async function duplicatePiece(sourceId: string): Promise<string> {
source.intro,
source.researchText,
source.moderationNotes,
source.interactionText,
source.transitionText,
source.presentationText,
source.presentationMode,
+411 -17
View File
@@ -11,6 +11,7 @@ import {
movePieceUsage,
type MovePieceUsageInput,
type PieceUsage,
type ShowStatus,
type ShowSection
} from './shows.js';
@@ -41,6 +42,7 @@ export type LiveUsage = PieceUsage & {
introHtml: string;
researchHtml: string;
moderationHtml: string;
interactionHtml: string;
transitionHtml: string;
};
@@ -55,7 +57,10 @@ export type LiveView = {
totalDurationSeconds: number;
};
liveRevision: number;
calculatedAtUtc: string;
startedAtUtc: string | null;
endedAtUtc: string | null;
isPaused: boolean;
updatedAtUtc: string;
presentationUsageId: string | null;
presentationRevision: number;
@@ -72,6 +77,15 @@ export type LiveView = {
elapsedSeconds: number;
runningSinceUtc: string | null;
};
totalTimer: {
elapsedSeconds: number;
runningSinceUtc: string | null;
};
remainingEstimate: {
seconds: number;
activeCountdownSeconds: number;
unplannedSectionCount: number;
};
};
export type PresentationView = {
@@ -152,6 +166,55 @@ function closeActiveRun(database: Database.Database, showId: string, now: string
.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) {
const timer = database
.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);
}
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(
database: Database.Database,
showId: string,
@@ -237,7 +320,8 @@ function executeCommand(
expectedRevision: number,
action: string,
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
nowValue?: Date
nowValue?: Date,
allowCompleted = false
) {
requireCommandId(commandId);
const database = getDatabase();
@@ -251,7 +335,7 @@ function executeCommand(
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
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');
operation(database, state, now);
const updated = database
@@ -307,6 +391,7 @@ function liveUsage(
introHtml: renderMarkdown(usage.intro),
researchHtml: renderMarkdown(usage.research),
moderationHtml: renderMarkdown(usage.moderation),
interactionHtml: renderMarkdown(usage.interaction),
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 }[];
const running = runs.find((run) => run.ended_at_utc === null) ?? null;
const elapsedSeconds = 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
);
const elapsedSeconds = elapsedRuns(runs, now);
sectionTimer = {
sectionId: timer.section_id,
sectionName: timer.section_name,
@@ -371,6 +445,50 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
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 {
show: {
id: show.id,
@@ -380,7 +498,10 @@ export function getLiveView(showId: string, nowValue?: Date): LiveView {
totalDurationSeconds: show.totalDurationSeconds
},
liveRevision: state.revision,
calculatedAtUtc: now,
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,
presentationUsageId: state.presentation_usage_id,
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')
.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
.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'`
)
.run(now, showId);
@@ -419,10 +549,14 @@ export function startOrResumeLive(
WHERE show_id = ?`
)
.run(now, showId);
openShowTimerRun(database, showId, now);
const activeRun = database
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
.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
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
: 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(
showId: string,
commandId: string,
@@ -447,6 +838,7 @@ function advanceCurrent(
expectedRevision,
status,
(database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
if (!state.current_usage_id) throw new Error('NO_CURRENT');
const ordered = orderedUsages(database, showId);
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
@@ -498,6 +890,7 @@ export function goBack(
expectedRevision,
'back',
(database, state, now) => {
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
const ordered = orderedUsages(database, showId);
const currentIndex = state.current_usage_id
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
@@ -533,6 +926,7 @@ export function selectUsage(
'select',
(database, state, now) => {
if (!state.started_at_utc) throw new Error('NOT_STARTED');
if (!showTimerRunning(database, showId)) throw new Error('TIMER_PAUSED');
usageBelongsToShow(database, showId, usageId);
if (state.current_usage_id === usageId) return;
if (state.current_usage_id) {
+22 -10
View File
@@ -47,6 +47,7 @@ export type PieceUsage = {
intro: string;
research: string;
moderation: string;
interaction: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
@@ -157,6 +158,7 @@ export type PieceUsageUpdate = {
intro: string;
research: string;
moderation: string;
interaction?: string;
transition: string;
presentation: string;
presentationMode: PresentationMode;
@@ -216,6 +218,7 @@ type UsageRow = {
intro_snapshot: string;
research_snapshot: string;
moderation_snapshot: string;
interaction_snapshot: string;
transition_snapshot: string;
presentation_snapshot: string;
presentation_mode_snapshot: PresentationMode;
@@ -445,6 +448,7 @@ function usageFromRow(database: Database.Database, row: UsageRow): PieceUsage {
intro: row.intro_snapshot,
research: row.research_snapshot,
moderation: row.moderation_snapshot,
interaction: row.interaction_snapshot,
transition: row.transition_snapshot,
presentation: row.presentation_snapshot,
presentationMode: row.presentation_mode_snapshot,
@@ -793,7 +797,7 @@ export function getShow(id: string): Show | null {
.prepare(
`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.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_source_url_snapshot, u.presentation_source_timecode_snapshot,
u.presentation_title_meta_snapshot, u.presentation_description_snapshot,
@@ -1126,7 +1130,7 @@ export function addPieceUsage(
const piece = database
.prepare(
`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.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
@@ -1143,6 +1147,7 @@ export function addPieceUsage(
intro: string;
research_text: string;
moderation_notes: string;
interaction_text: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
@@ -1178,13 +1183,13 @@ export function addPieceUsage(
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)`
)
.run(
id,
@@ -1197,6 +1202,7 @@ export function addPieceUsage(
piece.intro,
piece.research_text,
piece.moderation_notes,
piece.interaction_text,
piece.transition_text,
piece.presentation_text,
piece.presentation_mode,
@@ -1284,7 +1290,7 @@ export function duplicatePieceUsage(
const source = database
.prepare(
`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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
@@ -1307,13 +1313,13 @@ export function duplicatePieceUsage(
.prepare(
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
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_source_url_snapshot, presentation_source_timecode_snapshot,
presentation_title_meta_snapshot, presentation_description_snapshot,
presentation_image_url_snapshot, presentation_site_name_snapshot,
presentation_provider_snapshot, presentation_provider_id_snapshot, own_notes, is_reserve)
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
id,
@@ -1326,6 +1332,7 @@ export function duplicatePieceUsage(
source.intro_snapshot,
source.research_snapshot,
source.moderation_snapshot,
source.interaction_snapshot,
source.transition_snapshot,
source.presentation_snapshot,
source.presentation_mode_snapshot,
@@ -1360,7 +1367,7 @@ export function refreshPieceUsageFromOriginal(
const source = database
.prepare(
`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.url, '') AS presentation_source_url,
ps.timecode_seconds AS presentation_source_timecode,
@@ -1379,6 +1386,7 @@ export function refreshPieceUsageFromOriginal(
intro: string;
research_text: string;
moderation_notes: string;
interaction_text: string;
transition_text: string;
presentation_text: string;
presentation_mode: PresentationMode;
@@ -1397,7 +1405,7 @@ export function refreshPieceUsageFromOriginal(
database
.prepare(
`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_source_label_snapshot = ?, presentation_source_url_snapshot = ?,
presentation_source_timecode_snapshot = ?, presentation_title_meta_snapshot = ?,
@@ -1412,6 +1420,7 @@ export function refreshPieceUsageFromOriginal(
source.intro,
source.research_text,
source.moderation_notes,
source.interaction_text,
source.transition_text,
source.presentation_text,
source.presentation_mode,
@@ -1452,6 +1461,8 @@ export function updatePieceUsage(
intro: cleanText(input.intro, 50_000, 'intro'),
research: cleanText(input.research, 50_000, 'research'),
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'),
presentation: cleanText(input.presentation, 50_000, 'presentation'),
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
@@ -1473,7 +1484,7 @@ export function updatePieceUsage(
database
.prepare(
`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_source_url_snapshot = ?, presentation_source_timecode_snapshot = ?,
presentation_title_meta_snapshot = ?, presentation_description_snapshot = ?,
@@ -1487,6 +1498,7 @@ export function updatePieceUsage(
values.intro,
values.research,
values.moderation,
values.interaction,
values.transition,
values.presentation,
presentation.mode,
+8 -1
View File
@@ -24,13 +24,19 @@ import { listTools } from '#lib/server/tools.js';
function renderedFields(
piece: Pick<
PieceInput,
'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText'
| 'intro'
| 'researchText'
| 'moderationNotes'
| 'interactionText'
| 'transitionText'
| 'presentationText'
>
) {
return {
intro: renderMarkdown(piece.intro),
research: renderMarkdown(piece.researchText),
moderation: renderMarkdown(piece.moderationNotes),
interaction: renderMarkdown(piece.interactionText ?? ''),
transition: renderMarkdown(piece.transitionText),
presentation: renderMarkdown(piece.presentationText)
};
@@ -73,6 +79,7 @@ function pieceInput(data: FormData): PieceInput {
intro: text(data, 'intro'),
researchText: text(data, 'researchText'),
moderationNotes: text(data, 'moderationNotes'),
interactionText: text(data, 'interactionText'),
transitionText: text(data, 'transitionText'),
presentationText: text(data, 'presentationText'),
presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'],
+7
View File
@@ -308,6 +308,13 @@
rows={10}
bind:value={draft.moderationNotes}
/>
<MarkdownEditorField
name="interactionText"
label="Interaktion"
placeholder="Welche Frage möchtest du dem Chat stellen?"
privateField
bind:value={draft.interactionText}
/>
<MarkdownEditorField
name="transitionText"
label="Überleitung"
+81
View File
@@ -5,14 +5,22 @@ import { requireUser } from '#lib/server/auth.js';
import {
completeCurrent,
clearPresentationChannel,
endLive,
getLiveView,
goBack,
issuePresentationAccess,
moveOpenUsage,
pauseLive,
presentUsage,
rotatePresentationChannelKey,
resetSectionTimer,
resetTotalTimer,
resumeEndedLive,
resumeLive,
selectUsage,
setShowStatus,
skipCurrent,
startNewLiveRun,
startOrResumeLive
} from '#lib/server/live.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.' };
if (code === 'NOT_FOUND')
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.' };
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')))
);
},
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 }) => {
requireUser(locals.user);
const data = await request.formData();
+152 -11
View File
@@ -13,6 +13,7 @@
let mobileRailOpen = $state(false);
let isMobile = $state(false);
let showTimes = $state(true);
let timerDisplay = $state<'elapsed' | 'remaining'>('elapsed');
let menuOpen = $state(false);
let expandedContext = $state<'section' | 'next' | null>(null);
let popupMessage = $state('');
@@ -20,6 +21,7 @@
let loadedAt = $state(Date.now());
let currentId = $state(untrack(() => data.live.current?.id ?? null));
let loadedRevision = $state(untrack(() => data.live.liveRevision));
let calculatedAtUtc = $state(untrack(() => data.live.calculatedAtUtc));
let viewedUsageId = $state(untrack(() => data.live.current?.id ?? null));
let pieceScroller = $state<HTMLElement>();
let railScroller = $state<HTMLElement>();
@@ -74,7 +76,9 @@
);
let currentActualSeconds = $derived(
(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(
viewingLiveCurrent ? currentActualSeconds : (viewedUsage?.actualDurationSeconds ?? 0)
@@ -91,17 +95,34 @@
let hasActiveSectionTimer = $derived(
Boolean(
data.live.sectionTimer &&
data.live.sectionTimer.plannedDurationSeconds > 0 &&
data.live.current &&
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(() => {
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;
currentId = nextCurrentId;
loadedRevision = data.live.liveRevision;
calculatedAtUtc = data.live.calculatedAtUtc;
loadedAt = Date.now();
if (changedPiece) {
viewedUsageId = nextCurrentId;
@@ -130,6 +151,9 @@
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
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') {
scrollPositions = Object.fromEntries(
Object.entries(settings.scrollPositions).filter((entry): entry is [string, number] =>
@@ -182,6 +206,7 @@
promptSize,
showRail,
showTimes,
timerDisplay,
scrollPositions
})
);
@@ -296,6 +321,11 @@
persistSettings();
}
function toggleTimerDisplay() {
timerDisplay = timerDisplay === 'elapsed' ? 'remaining' : 'elapsed';
persistSettings();
}
function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -367,7 +397,7 @@
<header class="live-command-header" inert={mobileRailOpen}>
<div class="live-context-row">
<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
type="button"
class:expanded={expandedContext === 'section'}
@@ -425,10 +455,27 @@
<input type="hidden" name="usageId" value="" />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<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}
</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
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
@@ -509,8 +556,96 @@
</form>
</section>
<section>
<strong>Sendung</strong>
{#if needsResume}<form
<strong>Sendungssteuerung</strong>
<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"
action={liveAction('start')}
use:enhance={enhanceCommand}
@@ -560,6 +695,11 @@
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html viewedUsage.moderationHtml}</div>
</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">
<span>Überleitung</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
@@ -613,7 +753,7 @@
</div>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/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>
{/if}
</div>
@@ -673,6 +813,7 @@
</div>
<button
bind:this={railCloseButton}
class="dialog-close"
type="button"
aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
@@ -783,7 +924,7 @@
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<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
>
</form>
@@ -892,7 +1033,7 @@
<input type="hidden" name="usageId" value={viewedUsage.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<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
? 'Erneut präsentieren'
: 'Präsentieren'}
@@ -905,7 +1046,7 @@
<button
class="secondary-button"
type="submit"
disabled={!viewingLiveCurrent}
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}>Überspringen</button
@@ -918,7 +1059,7 @@
<button
class="primary-button"
type="submit"
disabled={!viewingLiveCurrent}
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}
@@ -237,6 +237,7 @@ export const actions = {
intro: text(data, 'intro'),
research: text(data, 'research'),
moderation: text(data, 'moderation'),
interaction: text(data, 'interaction'),
transition: text(data, 'transition'),
presentation: text(data, 'presentation'),
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],