diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 2027760..8653952 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 193980e..f9a63bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c33fa9f..66b4223 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md index 3129656..bd18f18 100644 --- a/docs/RELEASE_NOTES.md +++ b/docs/RELEASE_NOTES.md @@ -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 diff --git a/docs/STATUS.md b/docs/STATUS.md index b15b66b..8c4e88d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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. diff --git a/migrations/0013_live_controls_and_interaction.sql b/migrations/0013_live_controls_and_interaction.sql new file mode 100644 index 0000000..ee54e66 --- /dev/null +++ b/migrations/0013_live_controls_and_interaction.sql @@ -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; diff --git a/src/app.css b/src/app.css index 0c79e58..f4d4bb5 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } diff --git a/src/lib/components/UsageEditorDialog.svelte b/src/lib/components/UsageEditorDialog.svelte index 2e91ac8..1ecdbfb 100644 --- a/src/lib/components/UsageEditorDialog.svelte +++ b/src/lib/components/UsageEditorDialog.svelte @@ -108,6 +108,13 @@ rows={10} bind:value={draft.moderation} /> + & { +export type PieceEditor = Omit< + PieceInput, + 'status' | 'tools' | 'presentationMediaId' | 'interactionText' +> & { id: string; status: PieceStatus; revision: number; @@ -92,9 +96,12 @@ export type PieceEditor = Omit { 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 { source.intro, source.researchText, source.moderationNotes, + source.interactionText, source.transitionText, source.presentationText, source.presentationMode, diff --git a/src/lib/server/live.ts b/src/lib/server/live.ts index deced70..9ce7d41 100644 --- a/src/lib/server/live.ts +++ b/src/lib/server/live.ts @@ -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, + 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) { diff --git a/src/lib/server/shows.ts b/src/lib/server/shows.ts index 207c4e8..223ba66 100644 --- a/src/lib/server/shows.ts +++ b/src/lib/server/shows.ts @@ -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, diff --git a/src/routes/fundus/[id]/+page.server.ts b/src/routes/fundus/[id]/+page.server.ts index 56975c8..f02eca2 100644 --- a/src/routes/fundus/[id]/+page.server.ts +++ b/src/routes/fundus/[id]/+page.server.ts @@ -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'], diff --git a/src/routes/fundus/[id]/+page.svelte b/src/routes/fundus/[id]/+page.svelte index 345b944..85495b1 100644 --- a/src/routes/fundus/[id]/+page.svelte +++ b/src/routes/fundus/[id]/+page.svelte @@ -308,6 +308,13 @@ rows={10} bind:value={draft.moderationNotes} /> + { + 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(); diff --git a/src/routes/live/+page.svelte b/src/routes/live/+page.svelte index fdb58a2..a43da41 100644 --- a/src/routes/live/+page.svelte +++ b/src/routes/live/+page.svelte @@ -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(); let railScroller = $state(); @@ -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 @@
- + + {/if}
+ +
- Sendung - {#if needsResume}
Sendungssteuerung + Status: {data.live.show.status} + {#if !data.live.startedAtUtc} + + + + + + + +
+
+ + + + +
+ {:else if data.live.endedAtUtc} +
+ + + + +
+
{ + if (!confirm('Neuen Durchlauf starten? Fortschritt und Zeitmessungen werden zurückgesetzt.')) event.preventDefault(); + }} + > + + + + +
+ {:else} +
+ + + + +
+ {#if data.live.sectionTimer && data.live.sectionTimer.plannedDurationSeconds > 0}
{ + if (!confirm('Abschnittstimer wirklich zurücksetzen?')) event.preventDefault(); + }} + > + + + + +
{/if} +
{ + if (!confirm('Gesamtzeit wirklich zurücksetzen?')) event.preventDefault(); + }} + > + + + + +
+
{ + if (!confirm('Sendung wirklich beenden? Offene Pieces bleiben offen.')) event.preventDefault(); + }} + > + + + + +
+ {/if} + {#if needsResume && !data.live.endedAtUtc}
{@html viewedUsage.moderationHtml}
{/if} + {#if viewedUsage.interactionHtml}
+

Interaktion

+ +
{@html viewedUsage.interactionHtml}
+
{/if} {#if viewedUsage.transitionHtml}
Überleitung @@ -613,7 +753,7 @@
{/if} - {#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.transitionHtml} + {#if !viewedUsage.introHtml && !viewedUsage.moderationHtml && !viewedUsage.interactionHtml && !viewedUsage.transitionHtml}

Für dieses Piece sind keine Moderationstexte hinterlegt.

{/if}
@@ -673,6 +813,7 @@ - @@ -892,7 +1033,7 @@ -