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

+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'],