Files
sendewerk/src/routes/live/+page.svelte
T
2026-10-25 23:29:00 +01:00

1256 lines
47 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { resolve } from '$app/paths';
import { onMount, tick, untrack } from 'svelte';
import AppIcon from '#lib/components/AppIcon.svelte';
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import { formatClock, formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let promptSize = $state(1.55);
let showRail = $state(true);
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('');
let now = $state(Date.now());
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>();
let liveRail = $state<HTMLElement>();
let mobileRunButton = $state<HTMLButtonElement>();
let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>();
let completionDialog = $state<HTMLDialogElement>();
let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
let viewedUsage = $derived(
allUsages.find((usage) => usage.id === viewedUsageId) ?? data.live.current
);
let currentPosition = $derived(
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0
);
let viewedPosition = $derived(
viewedUsage ? allUsages.findIndex((usage) => usage.id === viewedUsage?.id) + 1 : 0
);
let previousUsage = $derived(viewedPosition > 1 ? allUsages[viewedPosition - 2] : null);
let viewingLiveCurrent = $derived(viewedUsage?.id === data.live.current?.id);
let runExpanded = $derived(isMobile ? mobileRailOpen : showRail);
let currentSection = $derived(
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
);
let viewedSection = $derived(
data.live.sections.find((section) => section.id === viewedUsage?.sectionId) ?? null
);
let presentedUsage = $derived(
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
);
let progress = $derived(
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
);
let needsResume = $derived(
Boolean(data.live.startedAtUtc && !data.live.current && data.live.next)
);
let hasOtherOpenUsages = $derived(
allUsages.some((usage) => usage.status === 'open' && usage.id !== data.live.current?.id)
);
let sessionComplete = $derived(
Boolean(
data.live.startedAtUtc &&
!data.live.current &&
data.live.totalCount &&
data.live.completedCount === data.live.totalCount
)
);
let currentSectionTitle = $derived(
currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')
);
let nextTitle = $derived(
data.live.next?.title ?? (sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')
);
let currentActualSeconds = $derived(
(data.live.current?.actualDurationSeconds ?? 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)
);
let sectionElapsedSeconds = $derived(
(data.live.sectionTimer?.elapsedSeconds ?? 0) +
(data.live.sectionTimer?.runningSinceUtc
? Math.max(0, Math.floor((now - loadedAt) / 1000))
: 0)
);
let sectionRemainingSeconds = $derived(
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
);
let hasActiveSectionTimer = $derived(
Boolean(
data.live.sectionTimer &&
data.live.sectionTimer.plannedDurationSeconds > 0 &&
data.live.current &&
data.live.sectionTimer.sectionId === data.live.current.sectionId
)
);
async function copyTwitchTags() {
if (!viewedSection?.twitchTags.length) return;
try {
await navigator.clipboard.writeText(viewedSection.twitchTags.join(', '));
popupMessage = 'Twitch-Tags kopiert.';
} catch {
popupMessage = 'Kopieren wurde vom Browser blockiert.';
}
}
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 ||
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;
void tick().then(() => {
if (pieceScroller)
pieceScroller.scrollTop = nextCurrentId ? (scrollPositions[nextCurrentId] ?? 0) : 0;
});
}
}
});
$effect(() => {
const activeId = data.live.current?.id;
if (!activeId) return;
void tick().then(() => {
railScroller
?.querySelector<HTMLElement>('[data-current="true"]')
?.scrollIntoView({ block: 'nearest' });
});
});
$effect(() => {
if (!data.completion && completionDialog?.open) completionDialog.close();
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
const settings = JSON.parse(localStorage.getItem(key) || '{}');
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] =>
Number.isFinite(entry[1])
)
);
} else if (currentId && typeof settings.scrollY === 'number') {
scrollPositions[currentId] = settings.scrollY;
}
requestAnimationFrame(() => {
if (pieceScroller && currentId) pieceScroller.scrollTop = scrollPositions[currentId] ?? 0;
});
} catch {
localStorage.removeItem(key);
}
const mobileBreakpoint = matchMedia('(max-width: 46rem)');
isMobile = mobileBreakpoint.matches;
const handleBreakpointChange = (event: MediaQueryListEvent) => {
isMobile = event.matches;
if (!event.matches) void closeMobileRail(false);
};
const handleDocumentClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (menuOpen && !liveMenuPopover?.contains(target) && !liveMenuTrigger?.contains(target)) {
void closeLiveMenu();
}
if (
expandedContext &&
!(target instanceof Element && target.closest('.live-context-title'))
) {
expandedContext = null;
}
};
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
document.addEventListener('click', handleDocumentClick);
const interval = setInterval(() => (now = Date.now()), 1000);
return () => {
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
document.removeEventListener('click', handleDocumentClick);
clearInterval(interval);
};
});
function persistSettings() {
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
localStorage.setItem(
`sendewerk-live-${data.live.show.id}`,
JSON.stringify({
promptSize,
showRail,
showTimes,
timerDisplay,
scrollPositions
})
);
}
function changePromptSize(delta: number) {
promptSize = Math.min(2.4, Math.max(1.1, promptSize + delta));
persistSettings();
}
function toggleDesktopRail() {
showRail = !showRail;
persistSettings();
}
function toggleRunPanel() {
void closeLiveMenu(false);
if (isMobile) {
if (mobileRailOpen) void closeMobileRail();
else void openMobileRail();
return;
}
toggleDesktopRail();
}
function viewUsage(usageId: string) {
if (pieceScroller && viewedUsageId) scrollPositions[viewedUsageId] = pieceScroller.scrollTop;
viewedUsageId = usageId;
void tick().then(() => {
if (pieceScroller) pieceScroller.scrollTop = scrollPositions[usageId] ?? 0;
});
void closeMobileRail();
}
function viewPreviousUsage() {
if (previousUsage) viewUsage(previousUsage.id);
}
async function openMobileRail() {
await closeLiveMenu(false);
mobileRailOpen = true;
await tick();
railCloseButton?.focus();
}
async function closeMobileRail(restoreFocus = true) {
if (!mobileRailOpen) return;
mobileRailOpen = false;
await tick();
if (restoreFocus) mobileRunButton?.focus();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return;
if (menuOpen) {
event.preventDefault();
void closeLiveMenu();
} else if (expandedContext) {
event.preventDefault();
expandedContext = null;
} else if (mobileRailOpen) {
event.preventDefault();
void closeMobileRail();
}
}
async function toggleLiveMenu() {
if (menuOpen) {
await closeLiveMenu();
return;
}
menuOpen = true;
expandedContext = null;
await tick();
liveMenuPopover
?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
?.focus({ preventScroll: true });
}
async function closeLiveMenu(restoreFocus = true) {
if (!menuOpen) return;
menuOpen = false;
await tick();
if (restoreFocus) liveMenuTrigger?.focus({ preventScroll: true });
}
function toggleContext(name: 'section' | 'next') {
expandedContext = expandedContext === name ? null : name;
}
function trapRailFocus(event: KeyboardEvent) {
if (!mobileRailOpen || event.key !== 'Tab' || !liveRail) return;
const focusable = Array.from(
liveRail.querySelectorAll<HTMLElement>(
'button:not(:disabled), summary, a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])'
)
).filter((element) => element.offsetParent !== null);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function toggleTimes() {
showTimes = !showTimes;
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);
const seconds = Math.floor(totalSeconds % 60);
return hours > 0
? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
function statusLabel(status: string) {
if (status === 'current') return 'Aktuell';
if (status === 'done') return 'Erledigt';
if (status === 'skipped') return 'Übersprungen';
return 'Kommend';
}
function liveAction(name: string) {
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
}
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
void closeLiveMenu();
const submittedFromRail = Boolean(formElement.closest('.live-rail'));
const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
const command = formElement.elements.namedItem('commandId');
if (command instanceof HTMLInputElement) {
command.value = crypto.randomUUID();
formData.set('commandId', command.value);
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
await update({ reset: false, invalidateAll: true });
if (submittedFromRail && (result.type !== 'success' || !keepRailOpen)) {
await closeMobileRail();
}
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
const enhanceOutput: SubmitFunction = ({ submitter }) => {
void closeLiveMenu();
popupMessage = '';
outputWindow = window.open('about:blank', 'heinzlive-output');
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
return async ({ result, update }) => {
if (result.type === 'success') {
const response = result.data as { outputUrl: string };
if (outputWindow && !outputWindow.closed) {
outputWindow.location.href = response.outputUrl;
popupMessage = 'Präsentationsfenster verbunden.';
} else {
popupMessage = `Präsentationsfenster konnte nicht geöffnet werden: ${response.outputUrl}`;
}
} else {
outputWindow?.close();
}
await update({ reset: false, invalidateAll: false });
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
};
};
</script>
<svelte:window onkeydown={handleWindowKeydown} />
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
<div class:without-rail={!showRail} class="live-page live-controller">
<header class="live-command-header" inert={mobileRailOpen}>
<div class="live-context-row">
<div class="live-context-summary">
<span class:paused={data.live.isPaused} class="live-pulse" aria-hidden="true"></span>
<button
type="button"
class:expanded={expandedContext === 'section'}
class="live-context-title section"
aria-label={`Aktueller Abschnitt: ${currentSectionTitle}`}
aria-expanded={expandedContext === 'section'}
onclick={() => toggleContext('section')}
>
<span>{currentSectionTitle}</span>
</button>
<div class="live-next-inline">
<small>Danach:</small>
<button
type="button"
class:expanded={expandedContext === 'next'}
class="live-context-title next"
aria-label={`Danach: ${nextTitle}`}
aria-expanded={expandedContext === 'next'}
onclick={() => toggleContext('next')}
>
<span>{nextTitle}</span>
</button>
</div>
</div>
<button
bind:this={liveMenuTrigger}
class="live-menu-trigger"
type="button"
aria-haspopup="dialog"
aria-controls="live-menu-popover"
aria-expanded={menuOpen}
onclick={toggleLiveMenu}
>
<AppIcon name="bars" />
<span>Menü</span>
</button>
</div>
<div class="live-status-row">
<div class:active={Boolean(presentedUsage)} class="live-output-control">
<div class="live-output-state" aria-live="polite">
<span class="live-output-icon" aria-hidden="true"><AppIcon name="display" /></span>
<div>
<small>Ausgabe</small>
<strong title={presentedUsage?.title ?? undefined}
>{presentedUsage?.title ?? 'Leer'}</strong
>
</div>
</div>
{#if data.live.presentationUsageId}<form
method="POST"
action={liveAction('present')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<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" 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 class="timer-label"
><AppIcon name="timer" /> {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="section-timer-control">
<div
class:warning={hasActiveSectionTimer &&
sectionRemainingSeconds <= 120 &&
sectionRemainingSeconds >= 0}
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
class="section-countdown"
aria-live="off"
>
<span class="timer-label"><AppIcon name="timer" /> Abschnitt</span>
<strong>
{#if hasActiveSectionTimer}
{sectionRemainingSeconds < 0
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
: formatCountdown(sectionRemainingSeconds)}
{:else}Kein Countdown{/if}
</strong>
</div>
{#if data.live.startedAtUtc && !data.live.endedAtUtc}<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:paused={data.live.isPaused}
class="live-timer-toggle"
type="submit"
aria-label={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
title={data.live.isPaused ? 'Sendung fortsetzen' : 'Sendung pausieren'}
>
<AppIcon name={data.live.isPaused ? 'play' : 'pause'} />
</button>
</form>{/if}
</div>
<button
bind:this={mobileRunButton}
class:active={runExpanded}
class="live-run-button"
type="button"
aria-controls="live-run-sheet"
aria-expanded={runExpanded}
onclick={toggleRunPanel}
>
<AppIcon name="list" />
<span>Ablauf · {currentPosition || data.live.completedCount}/{data.live.totalCount}</span>
<AppIcon name="chevron-right" />
</button>
</div>
</header>
{#if menuOpen}<div
bind:this={liveMenuPopover}
class="live-menu-popover"
id="live-menu-popover"
role="dialog"
aria-label="Regiemenü"
inert={mobileRailOpen}
>
<section class="live-menu-show">
<small>Sendung</small>
<strong>{data.live.show.title}</strong>
</section>
<section>
<strong>Ansicht</strong>
<div class="font-control" aria-label="Prompter-Schriftgröße">
<button
type="button"
onclick={() => changePromptSize(-0.1)}
aria-label="Schrift verkleinern">A−</button
>
<button
type="button"
onclick={() => changePromptSize(0.1)}
aria-label="Schrift vergrößern">A+</button
>
</div>
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
</button>
<ThemeToggle />
</section>
<section>
<strong>Ausgabekanal</strong>
{#if data.canManageShares}<ShareManagement
shows={[{ ...data.live.show, startsAtUtc: null }]}
shares={data.shares}
fixedShowId={data.live.show.id}
{form}
/>{/if}
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
</form>
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
<input type="hidden" name="showId" value={data.live.show.id} />
<button class="text-button" type="submit">Schlüssel erneuern</button>
</form>
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
<button class="text-button danger-text" type="submit">Ausgabekanal trennen</button>
</form>
</section>
<section>
<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}
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
{:else}
<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>
<button
class="primary-button"
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
</button>
{/if}
{: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}
>
<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">Live-Stand wiederaufnehmen</button>
</form>{/if}
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Liveansicht verlassen · Zur Planung</a
>
</section>
</div>{/if}
<div class="live-messages">
{#if form?.error}<div class="live-message form-message error" role="alert">
{form.error}
</div>{/if}
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div>
<dialog bind:this={completionDialog} class="completion-dialog">
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
gesendet.
</p>
{#if data.completion}
<div class="completion-counts">
<span><strong>{data.completion.doneCount}</strong> gesendet</span><span
><strong>{data.completion.skippedCount}</strong> übersprungen</span
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
><strong>{candidate.title}</strong>{#if candidate.usageCount > 1}<small
>{candidate.usageCount} gesendete Verwendungen</small
>{/if}</span
></label
>{/each}
{#if !data.completion.candidates.length}<p class="quiet-copy">
Keine archivierbaren Originale vorhanden.
</p>{/if}
</fieldset>
{/if}
<input type="hidden" name="showId" value={data.live.show.id} />
<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} />
<footer>
<button class="text-button" type="button" onclick={() => completionDialog?.close()}
>Abbrechen</button
><button class="primary-button" type="submit">Historischen Stand abschließen</button>
</footer>
</form>
</dialog>
<div class="live-workspace">
{#if viewedUsage}<article
class="live-current"
style={`--prompt-size: ${promptSize}rem`}
aria-labelledby="current-title"
inert={mobileRailOpen}
>
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
<div class="live-piece-heading">
<h1 id="current-title">{viewedUsage.title}</h1>
{#if showTimes}<strong class="live-piece-time">
{formatClock(viewedActualSeconds)} / {formatClock(viewedUsage.durationSeconds)}
</strong>{/if}
</div>
{#if viewedSection?.twitchTags.length}<button
class="text-button live-twitch-tags"
type="button"
onclick={copyTwitchTags}
>
<AppIcon name="copy" />
{viewedSection.twitchTags.join(' · ')}
</button>{/if}
{#if viewedUsage.introHtml}<section class="live-script-section live-intro">
<span>Einleitung</span>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="prompter-copy">{@html viewedUsage.introHtml}</div>
</section>{/if}
{#if viewedUsage.moderationHtml}<section class="live-script-section moderation-card">
<span>Moderationsstichpunkte</span>
<!-- 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 -->
<div class="live-markdown">{@html viewedUsage.transitionHtml}</div>
</section>{/if}
{#if viewedUsage.researchHtml || viewedUsage.sources.length || viewedUsage.tools.length || viewedUsage.ownNotes}
<!-- External source and tool URLs are server-validated private snapshots. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="live-support-actions" aria-label="Private Zusatzinformationen">
{#if viewedUsage.researchHtml}<details class="research-details">
<summary>Recherche</summary>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="live-markdown">{@html viewedUsage.researchHtml}</div>
</details>{/if}
{#if viewedUsage.sources.length}<details class="live-materials">
<summary>Quellen · {viewedUsage.sources.length}</summary>
<ul>
{#each viewedUsage.sources as source (source.id)}<li>
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer"
>{source.label}</a
>
{#if source.timecodeSeconds !== null}<small
>ab {formatClock(source.timecodeSeconds)}</small
>{/if}
</li>{/each}
</ul>
</details>{/if}
{#if viewedUsage.tools.length}<details class="live-materials">
<summary>Werkzeuge · {viewedUsage.tools.length}</summary>
<ul>
{#each viewedUsage.tools as tool (tool.id)}<li>
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href={tool.workUrl || tool.startUrl}
target="_blank"
rel="noopener noreferrer">{tool.name}</a
>
{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
{#if tool.note}<small>{tool.note}</small>{/if}
</li>{/each}
</ul>
</details>{/if}
{#if viewedUsage.ownNotes}<details class="live-materials">
<summary>Eigene Notizen</summary>
<p>{viewedUsage.ownNotes}</p>
</details>{/if}
</div>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
{#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>
</article>{:else}<section class="live-current live-empty" inert={mobileRailOpen}>
<div>
<p class="eyebrow">{sessionComplete ? 'Ablauf abgeschlossen' : 'Live-Stand'}</p>
<h1>
{sessionComplete
? 'Alle Pieces bearbeitet'
: data.live.totalCount
? 'Bereit für die Sendung'
: 'Noch kein Piece eingeplant'}
</h1>
<p>
{sessionComplete
? 'Der Live-Ablauf ist am Ende. Die Sendung wird hier nicht automatisch abgeschlossen.'
: data.live.totalCount
? 'Starte oder setze den gespeicherten Live-Stand fort.'
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
</p>
{#if !data.live.startedAtUtc || needsResume}<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="primary-button" type="submit">
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
</button>
</form>{/if}
</div>
</section>{/if}
<button
class:visible={mobileRailOpen}
class="live-rail-backdrop"
type="button"
aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}
></button>
<aside
bind:this={liveRail}
class:open={mobileRailOpen}
class="live-rail"
id="live-run-sheet"
role={mobileRailOpen ? 'dialog' : undefined}
aria-modal={mobileRailOpen ? 'true' : undefined}
aria-labelledby="live-run-title"
onkeydown={trapRailFocus}
>
<header>
<div>
<strong id="live-run-title">Ablauf</strong>
<span>{data.live.completedCount}/{data.live.totalCount} bearbeitet · {progress}%</span>
</div>
<button
bind:this={railCloseButton}
class="dialog-close"
type="button"
aria-label="Ablauf schließen"
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
>
</header>
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
<div class="live-run-scroll" bind:this={railScroller}>
{#each data.live.sections as section, sectionIndex (section.id)}
<details
class:active={section.id === data.live.current?.sectionId}
class="rail-section"
open={section.id === data.live.current?.sectionId ||
(!data.live.current &&
sectionIndex ===
data.live.sections.findIndex((candidate) => candidate.usages.length))}
>
<summary>
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
<div>
<strong>{section.name}</strong>
<small
>{section.usages.length} Pieces{#if showTimes}
· {formatDuration(section.durationSeconds)}{/if}</small
>
</div>
</summary>
<div class="rail-section-items">
{#each section.usages as usage, usageIndex (usage.id)}
<div
class:current={usage.id === data.live.current?.id}
class:selected={usage.id === viewedUsage?.id}
class:presented={usage.id === data.live.presentationUsageId}
class:done={usage.status === 'done'}
class:skipped={usage.status === 'skipped'}
class="live-run-item"
data-current={usage.id === data.live.current?.id}
>
<button
class="live-run-select"
type="button"
disabled={!data.live.startedAtUtc || usage.id === viewedUsage?.id}
aria-label={`${usage.title} in der Regie auswählen`}
onclick={() => viewUsage(usage.id)}
>
<strong>{usage.title}</strong>
<small
>{statusLabel(usage.status)}{usage.id === data.live.presentationUsageId
? ' · Ausgegeben'
: ''}</small
>
</button>
<div class="live-run-actions">
{#if usage.status === 'done' || usage.status === 'skipped'}<form
method="POST"
action={liveAction('resumeUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<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
class="text-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}
>Wiederaufnehmen</button
>
</form>{/if}
{#if usage.status === 'open'}<details class="live-run-order">
<summary aria-label={`${usage.title} sortieren`}
><AppIcon name="ellipsis" /></summary
>
<div>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input
type="hidden"
name="showRevision"
value={data.live.show.revision}
/>
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex - 1} />
<button
type="submit"
disabled={usageIndex === 0 ||
section.usages[usageIndex - 1]?.status !== 'open'}
aria-label={`${usage.title} nach oben`}
><AppIcon name="arrow-up" /></button
>
</form>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input
type="hidden"
name="showRevision"
value={data.live.show.revision}
/>
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value={section.id} />
<input type="hidden" name="position" value={usageIndex + 1} />
<button
type="submit"
disabled={usageIndex === section.usages.length - 1 ||
section.usages[usageIndex + 1]?.status !== 'open'}
aria-label={`${usage.title} nach unten`}
><AppIcon name="arrow-down" /></button
>
</form>
</div>
</details>{/if}
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<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"
disabled={Boolean(data.live.endedAtUtc)}
aria-label={`${usage.title} präsentieren`}
><AppIcon name="display" /></button
>
</form>
</div>
</div>
{/each}
{#if !section.usages.length}<p>Keine Pieces in diesem Abschnitt.</p>{/if}
</div>
</details>
{/each}
<details class="live-reserve">
<summary><span>Reserve</span><strong>{data.live.reserve.length}</strong></summary>
<div>
{#each data.live.reserve as usage, reserveIndex (usage.id)}
<div class="live-reserve-item">
<div>
<strong>{usage.title}</strong>{#if showTimes}<small
>{formatClock(usage.durationSeconds)}</small
>{/if}
</div>
<div class="live-reserve-actions">
<details class="live-run-order">
<summary aria-label={`${usage.title} in Reserve sortieren`}
><AppIcon name="ellipsis" /></summary
>
<div>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex - 1} />
<button
type="submit"
disabled={reserveIndex === 0}
aria-label={`${usage.title} in Reserve nach oben`}
><AppIcon name="arrow-up" /></button
>
</form>
<form
data-keep-rail-open="true"
method="POST"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input type="hidden" name="sectionId" value="" />
<input type="hidden" name="position" value={reserveIndex + 1} />
<button
type="submit"
disabled={reserveIndex === data.live.reserve.length - 1}
aria-label={`${usage.title} in Reserve nach unten`}
><AppIcon name="arrow-down" /></button
>
</form>
</div>
</details>
{#if data.live.current || data.live.sections[0]}<form
method="POST"
action={liveAction('moveUsage')}
use:enhance={enhanceCommand}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="usageId" value={usage.id} />
<input
type="hidden"
name="sectionId"
value={data.live.current?.sectionId ?? data.live.sections[0]?.id}
/>
<input
type="hidden"
name="position"
value={(data.live.current?.position ?? -1) + 1}
/>
<button class="text-button" type="submit">Als Nächstes</button>
</form>{/if}
</div>
</div>
{/each}
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
</div>
</details>
</div>
</aside>
</div>
<footer class="live-action-bar" inert={mobileRailOpen}>
<div class="live-footer-actions">
{#if data.live.current && viewedUsage}<button
class="secondary-button"
type="button"
disabled={!previousUsage}
onclick={viewPreviousUsage}>Zurück</button
>
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<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" disabled={Boolean(data.live.endedAtUtc)}>
{data.live.presentationUsageId === viewedUsage.id
? 'Erneut präsentieren'
: 'Präsentieren'}
</button>
</form>
{#if (viewedUsage.status === 'done' || viewedUsage.status === 'skipped') && !viewingLiveCurrent}
<form method="POST" action={liveAction('resumeUsage')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<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="primary-button"
type="submit"
disabled={data.live.isPaused || Boolean(data.live.endedAtUtc)}>Wiederaufnehmen</button
>
</form>
{:else}
<form method="POST" action={liveAction('skip')} 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"
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}>Überspringen</button
>
</form>
<form method="POST" action={liveAction('complete')} 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="primary-button"
type="submit"
disabled={!viewingLiveCurrent || data.live.isPaused || Boolean(data.live.endedAtUtc)}
title={!viewingLiveCurrent
? 'Zuerst im Ablauf zum aktuellen Live-Piece zurückkehren'
: undefined}
>
{data.live.next
? 'Erledigen & weiter'
: hasOtherOpenUsages
? 'Erledigen'
: 'Erledigen & Sendungsende'}
</button>
</form>
{/if}
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
>{/if}
</div>
</footer>
</div>