Build compact live control view
This commit is contained in:
1 parent
3389ba17f4
commit
bd9ffe6faa
10 files changed
+1571
-623
No files matched your search
+16
-6
@@ -413,14 +413,24 @@
|
|||||||
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
||||||
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
iOS-Gerät nicht ausgeführt; Anbieterrequests wurden lokal abgefangen
|
||||||
|
|
||||||
## Verwaltungskorrektur 0.5.11
|
## Kompakte Live-Regie und Verwaltungskorrektur 0.5.11
|
||||||
|
|
||||||
| Kriterium | Nachweis | Stand |
|
| Kriterium | Nachweis | Stand |
|
||||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------- |
|
||||||
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 23.10.2026 |
|
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | erfüllt am 24.10.2026 |
|
||||||
| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 23.10.2026 |
|
| Dialog öffnet nur nach bewusster Aktion | Browser legt ein Werkzeug an und öffnet ein bestehendes Werkzeug anschließend zum Ändern | erfüllt am 24.10.2026 |
|
||||||
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 23.10.2026 |
|
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | erfüllt am 24.10.2026 |
|
||||||
| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 23.10.2026 |
|
| Themen-Iconpicker bleibt im Viewport | Chromium prüft Öffnen und Auswahl bei 390, 960 und 1280 Pixeln | erfüllt am 24.10.2026 |
|
||||||
|
| Globale Navigation bleibt aus der Regie | Chromium findet weder Seitenleiste noch mobilen App-Header oder Bottomnavigation | erfüllt am 24.10.2026 |
|
||||||
|
| Kleines Desktopfenster bleibt zweispaltig | Chromium prüft Moderation, Ablauf und feste Aktionen bei 900 × 450 Pixeln | erfüllt am 24.10.2026 |
|
||||||
|
| Lange Inhalte scrollen innerhalb der Fläche | Synthetischer Langtext und zusätzliche Ablaufabschnitte erzeugen zwei unabhängige Scroller | erfüllt am 24.10.2026 |
|
||||||
|
| Mobile Regie verwendet ein Ablauf-Sheet | 390 × 700 prüft Endlage, Fokus, Escape, Aktionsschließen und Wechsel auf 900 Pixel | erfüllt am 24.10.2026 |
|
||||||
|
| Leseposition bleibt am Piece | Nach tiefem Scrollen beginnt ein direkt gewähltes anderes Piece am oberen Rand | erfüllt am 24.10.2026 |
|
||||||
|
| Zustände sind ausgeschrieben | Ablauf prüft Kommend, Aktuell, Erledigt und Übersprungen | erfüllt am 24.10.2026 |
|
||||||
|
| Erstes und letztes Piece direkt wählbar | Browser wechselt per stabiler Usage-ID auf Desktop und aus dem mobilen Sheet in beide Richtungen | erfüllt am 24.10.2026 |
|
||||||
|
| Navigation veröffentlicht nicht automatisch | Öffentliche Ausgabe bleibt bei direkter Auswahl unverändert | erfüllt am 24.10.2026 |
|
||||||
|
| Timer und Livezustand überleben Reload | Aktuelles Piece und laufender Abschnittscountdown bleiben nach Neuladen erhalten | erfüllt am 24.10.2026 |
|
||||||
|
| Sendungsende ist eindeutig | Letztes Piece zeigt Endaktion ohne horizontalen Überlauf; danach fehlen Weiter-Aktionen | erfüllt am 24.10.2026 |
|
||||||
|
|
||||||
## Ausgeführte Prüfungen 0.5.11
|
## Ausgeführte Prüfungen 0.5.11
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,17 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
|||||||
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
||||||
ohne interne Anwendungsnavigation.
|
ohne interne Anwendungsnavigation.
|
||||||
|
|
||||||
|
Die private Route `/live` verwendet ebenfalls einen eigenen authentifizierten
|
||||||
|
Layoutmodus ohne allgemeine Seitenleiste, mobilen App-Header oder
|
||||||
|
Bottomnavigation. Ihre Regiefläche belegt die verfügbare Viewporthöhe als Raster
|
||||||
|
aus fester Statuszeile, unabhängig scrollender Piece- und Ablaufspalte sowie
|
||||||
|
fester Aktionszeile. Unterhalb des schmalen Breakpoints wird ausschließlich die
|
||||||
|
Ablaufspalte als modales Sheet mit Fokusbegrenzung eingeblendet; beim Wechsel
|
||||||
|
auf einen breiteren Viewport wird dieser Modalzustand aufgehoben. Dieser
|
||||||
|
Layoutmodus ändert weder die Live-Actions noch die getrennte öffentliche
|
||||||
|
`/heinzlive`-Projektion. Lokale Lesepositionen sind an die jeweilige Verwendung
|
||||||
|
gebunden und werden nicht zwischen Pieces übertragen.
|
||||||
|
|
||||||
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
|
Icons werden aus dem lokalen Font-Awesome-Classic-Regular-Sprite unter
|
||||||
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
|
`static/icons` gerendert. Die Verwaltung lädt zusätzlich die durchsuchbaren
|
||||||
Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
|
Metadaten aller 3.671 Icons, zeigt je Suchergebnis aber höchstens 120 Einträge
|
||||||
|
|||||||
@@ -360,3 +360,17 @@ Validierung, während ein statischer SVG-Sprite über den gemeinsamen
|
|||||||
Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf
|
Pfaddaten in ihren JavaScript-Chunk. Es gibt weder einen externen Laufzeitabruf
|
||||||
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
|
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
|
||||||
Lizenz stehen in `THIRD_PARTY_NOTICES.md`.
|
Lizenz stehen in `THIRD_PARTY_NOTICES.md`.
|
||||||
|
|
||||||
|
## D-024: Private Live-Regie als fokussierter Viewport
|
||||||
|
|
||||||
|
Die authentifizierte Live-Steuerung nutzt einen eigenen Root-Layoutmodus und
|
||||||
|
rendert die allgemeine App-Navigation nicht. Kopf- und Aktionszeile bleiben fest;
|
||||||
|
Moderation und Ablauf sind die einzigen unabhängig scrollenden Arbeitsflächen.
|
||||||
|
Die Umschaltung zum mobilen Sheet hängt ausschließlich von der Fensterbreite ab,
|
||||||
|
damit ein breites, niedriges Desktopfenster weiterhin zwei Spalten verwendet.
|
||||||
|
|
||||||
|
Diese Darstellung ist keine neue Live-Zustandsgrenze. Start, direkte Auswahl,
|
||||||
|
Erledigen, Überspringen, Reserveeinfügung und Präsentation bleiben unveränderte
|
||||||
|
serverseitige Actions mit ihren bisherigen Revisions- und Idempotenzregeln. Ein
|
||||||
|
Piece-Wechsel veröffentlicht daher weiterhin nichts; nur die ausdrückliche
|
||||||
|
Präsentationsaction verändert die getrennte öffentliche Ausgabe.
|
||||||
+61
-6
@@ -1,17 +1,72 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
## 0.5.11 - Verwaltungskorrektur
|
## 0.5.11 - Kompakte Live-Regie
|
||||||
|
|
||||||
Veröffentlicht am 23. Oktober 2026
|
Veröffentlicht am 23. Oktober 2026
|
||||||
|
|
||||||
|
Sendewerk 0.5.11 macht aus der privaten Live-Steuerung eine kompakte Regieansicht
|
||||||
|
für Vollbild, Smartphone und kleine Desktopfenster neben OBS oder Firebot. Das
|
||||||
|
aktuelle Piece, die Position im Ablauf, der Abschnittscountdown und die
|
||||||
|
wichtigsten Aktionen bleiben gleichzeitig erreichbar. Die öffentliche
|
||||||
|
heinzlive-Ausgabe und die gespeicherten Livezustände bleiben technisch getrennt.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- Die Live-Regie verwendet die gesamte Fensterfläche ohne normale
|
||||||
|
App-Seitenleiste, mobilen App-Header oder Bottomnavigation.
|
||||||
|
- Eine kompakte Kopfzeile zeigt aktuellen Abschnitt, Abschnittscountdown und den
|
||||||
|
tatsächlich präsentierten Ausgabezustand.
|
||||||
|
- Moderation und Ablauf bleiben auch bei einem nur 900 × 450 Pixel großen
|
||||||
|
Desktopfenster nebeneinander. Lange Inhalte scrollen innerhalb ihrer
|
||||||
|
Arbeitsfläche; Kopf- und Aktionszeile bleiben sichtbar.
|
||||||
|
- Die feste Fußleiste bündelt nächstes Piece, „Präsentieren“, „Überspringen“ und
|
||||||
|
„Erledigen & weiter“. Beim letzten offenen Piece wird das Sendungsende
|
||||||
|
eindeutig benannt.
|
||||||
|
- Auf dem Smartphone öffnet „Ablauf · Piece X/Y“ den Ablauf als separates Sheet,
|
||||||
|
während das aktuelle Piece die Hauptfläche bleibt. Fokusführung, Escape und
|
||||||
|
der Wechsel zurück auf eine breite Ansicht schließen den Modalzustand sauber.
|
||||||
|
|
||||||
|
### Moderation und Materialien
|
||||||
|
|
||||||
|
- Interner Titel, Einleitung, Moderationsstichpunkte und Überleitung erscheinen
|
||||||
|
in klarer Lesereihenfolge. Leere Bereiche werden weggelassen.
|
||||||
|
- Recherche, Quellen, Werkzeuge und eigene Notizen sind kompakt beschriftet und
|
||||||
|
werden nur bei Bedarf geöffnet.
|
||||||
|
- Die Prompter-Schriftgröße bleibt einstellbar und wird weiterhin lokal
|
||||||
|
gespeichert. Texte werden nicht automatisch verkleinert.
|
||||||
|
|
||||||
|
### Ablauf und Liveverhalten
|
||||||
|
|
||||||
|
- Abschnitte sind kompakt aufklappbar; der aktuelle Abschnitt ist geöffnet und
|
||||||
|
das aktive Piece bleibt in der Ablaufspalte sichtbar.
|
||||||
|
- Pieces kennzeichnen „Aktuell“, „Erledigt“, „Übersprungen“ und „Kommend“ auch
|
||||||
|
textlich und lassen sich direkt auswählen, einschließlich des ersten Eintrags.
|
||||||
|
- Die Reserve bleibt zusammengeklappt erreichbar. Dauerhafte Sortierpfeile und
|
||||||
|
die separate große „Als Nächstes“-Karte entfallen; Umsortieren bleibt über
|
||||||
|
eine kompakte Aktion am jeweiligen Eintrag erreichbar.
|
||||||
|
- Die lokal gespeicherte Leseposition gehört zum jeweiligen Piece. Eine direkte
|
||||||
|
Auswahl beginnt daher oben, ohne die Position des vorherigen Textes zu erben.
|
||||||
|
- Direkte Auswahl verändert keinen Erledigungsstatus und veröffentlicht nichts.
|
||||||
|
Nur „Präsentieren“ oder „Präsentation leeren“ verändert die öffentliche
|
||||||
|
Ausgabe.
|
||||||
|
- Abschnittstimer, tatsächliche Laufzeiten, Livefortschritt und Wiederaufnahme
|
||||||
|
verwenden unverändert die vorhandenen persistenten Zustände.
|
||||||
|
|
||||||
|
### Einstellungen und Verwaltung
|
||||||
|
|
||||||
|
- Ausgabekanal verbinden, Schlüssel erneuern, Kanal trennen, Theme wechseln,
|
||||||
|
Zeiten ein-/ausblenden und „Zur Planung“ bleiben im Regiemenü erreichbar.
|
||||||
- Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite
|
- Der Dialog „Werkzeug hinzufügen“ bleibt beim Aufruf der Verwaltungsseite
|
||||||
wieder zuverlässig geschlossen.
|
zuverlässig geschlossen und erscheint erst nach einer bewussten Aktion.
|
||||||
- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein
|
|
||||||
bestehendes Werkzeug bearbeitet wird.
|
|
||||||
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
|
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
|
||||||
Smartphone vollständig im sichtbaren Browserbereich.
|
Smartphone vollständig im sichtbaren Browserbereich.
|
||||||
- Die Korrektur gilt gleichermaßen für Desktop- und Smartphone-Ansichten und
|
|
||||||
verändert keine vorhandenen Werkzeuge oder Zuordnungen.
|
### Update und Kompatibilität
|
||||||
|
|
||||||
|
- Dieses Release benötigt keine neue Datenbankmigration und keine Änderung der
|
||||||
|
Konfiguration.
|
||||||
|
- Bestehende Sendungen, Livezustände, Timer, Ausgabekanäle und
|
||||||
|
Präsentationssnapshots bleiben erhalten.
|
||||||
|
|
||||||
## 0.5.10 - Editor-Erlebnis
|
## 0.5.10 - Editor-Erlebnis
|
||||||
|
|
||||||
|
|||||||
+24
-7
@@ -7,14 +7,31 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
|
|||||||
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
||||||
Etappe 6 wurde nicht begonnen.
|
Etappe 6 wurde nicht begonnen.
|
||||||
|
|
||||||
## Verwaltungskorrektur 0.5.11
|
## Kompakte Live-Regie 0.5.11
|
||||||
|
|
||||||
- der Werkzeugdialog bleibt nach dem Laden der Verwaltungsseite geschlossen und
|
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
|
||||||
wird erst durch „Werkzeug hinzufügen“ oder die Bearbeitung eines vorhandenen
|
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
|
||||||
Werkzeugs als modaler Dialog eingeblendet
|
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
|
||||||
- die Iconauswahl der Themenverwaltung bleibt auch in der rechten Desktop-Spalte
|
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und
|
||||||
vollständig innerhalb des sichtbaren Browserbereichs
|
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece,
|
||||||
- ein Browser-Regressionscheck sichert den geschlossenen Ausgangszustand ab
|
Präsentation, Überspringen und kontextgerechtem Erledigen
|
||||||
|
- aktuelle Moderation und schmale Ablaufspalte bleiben auch bei 900 × 450 Pixeln
|
||||||
|
nebeneinander; beide Arbeitsflächen scrollen unabhängig, ohne die ganze Seite
|
||||||
|
oder einzelne Textblöcke zu verschachtelten Scrollcontainern zu machen
|
||||||
|
- auf schmalen Ansichten bleibt das Piece die Hauptfläche und der Ablauf öffnet
|
||||||
|
als separates, safe-area-fähiges Sheet mit Fokusführung und Escape-Schließen
|
||||||
|
- kompakter aufklappbarer Ablauf mit ausgeschriebenen Zuständen, direkter
|
||||||
|
Auswahl jedes Pieces, automatisch sichtbarem aktuellen Eintrag und
|
||||||
|
zusammengeklappter Reserve; Sortierung bleibt in einer kompakten Aktion
|
||||||
|
erreichbar, dauerhafte Sortierpfeile entfallen
|
||||||
|
- die Leseposition wird je Piece lokal erhalten; ein erstmals gewähltes Piece
|
||||||
|
beginnt oben statt an der Position des vorherigen Textes
|
||||||
|
- Recherche, Quellen, Werkzeuge und eigene Notizen erscheinen nur bei Bedarf;
|
||||||
|
leere Moderationsbereiche werden nicht dargestellt
|
||||||
|
- Livefortschritt, direkte Auswahl, Abschnittstimer und öffentliche Präsentation
|
||||||
|
verwenden unverändert ihre getrennten persistenten Zustände
|
||||||
|
- der Werkzeugdialog bleibt nach dem Laden der Verwaltungsseite geschlossen;
|
||||||
|
die Themen-Iconauswahl bleibt vollständig innerhalb des Viewports
|
||||||
|
|
||||||
Etappe 6 bleibt unverändert unbegonnen.
|
Etappe 6 bleibt unverändert unbegonnen.
|
||||||
|
|
||||||
|
|||||||
+776
-399
File diff suppressed because it is too large.
Load diff
@@ -2,5 +2,6 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
|
|
||||||
export const load: LayoutServerLoad = ({ locals, url }) => ({
|
export const load: LayoutServerLoad = ({ locals, url }) => ({
|
||||||
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
|
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
|
||||||
presentationMode: url.pathname.startsWith('/heinzlive/')
|
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
||||||
|
liveControllerMode: url.pathname === '/live'
|
||||||
});
|
});
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
|
|
||||||
{#if data.presentationMode}
|
{#if data.presentationMode}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{:else if data.user && data.liveControllerMode}
|
||||||
|
<main id="main-content" class="live-controller-shell">{@render children()}</main>
|
||||||
{:else if data.user}
|
{:else if data.user}
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
|
|||||||
+472
-191
@@ -1,24 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance, type SubmitFunction } from '$app/forms';
|
import { enhance, type SubmitFunction } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, tick, untrack } from 'svelte';
|
||||||
import { formatClock, formatDuration } from '#lib/format.js';
|
|
||||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||||
|
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||||
|
import { formatClock, formatDuration } from '#lib/format.js';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
let promptSize = $state(1.55);
|
let promptSize = $state(1.55);
|
||||||
let showRail = $state(true);
|
let showRail = $state(true);
|
||||||
|
let mobileRailOpen = $state(false);
|
||||||
let showTimes = $state(true);
|
let showTimes = $state(true);
|
||||||
let popupMessage = $state('');
|
let popupMessage = $state('');
|
||||||
let now = $state(Date.now());
|
let now = $state(Date.now());
|
||||||
let loadedAt = $state(Date.now());
|
let loadedAt = $state(Date.now());
|
||||||
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
let currentId = $state(untrack(() => data.live.current?.id ?? null));
|
||||||
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
let loadedRevision = $state(untrack(() => data.live.liveRevision));
|
||||||
|
let pieceScroller = $state<HTMLElement>();
|
||||||
|
let railScroller = $state<HTMLElement>();
|
||||||
|
let liveRail = $state<HTMLElement>();
|
||||||
|
let mobileRunButton = $state<HTMLButtonElement>();
|
||||||
|
let railCloseButton = $state<HTMLButtonElement>();
|
||||||
|
let scrollPositions: Record<string, number> = {};
|
||||||
let outputWindow: Window | null = null;
|
let outputWindow: Window | null = null;
|
||||||
|
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
|
||||||
|
let currentPosition = $derived(
|
||||||
|
data.live.current ? allUsages.findIndex((usage) => usage.id === data.live.current?.id) + 1 : 0
|
||||||
|
);
|
||||||
|
let currentSection = $derived(
|
||||||
|
data.live.sections.find((section) => section.id === data.live.current?.sectionId) ?? null
|
||||||
|
);
|
||||||
|
let presentedUsage = $derived(
|
||||||
|
allUsages.find((usage) => usage.id === data.live.presentationUsageId) ?? null
|
||||||
|
);
|
||||||
let progress = $derived(
|
let progress = $derived(
|
||||||
data.live.totalCount ? Math.round((data.live.completedCount / data.live.totalCount) * 100) : 0
|
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 currentActualSeconds = $derived(
|
let currentActualSeconds = $derived(
|
||||||
(data.live.current?.actualDurationSeconds ?? 0) +
|
(data.live.current?.actualDurationSeconds ?? 0) +
|
||||||
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
||||||
@@ -32,14 +64,38 @@
|
|||||||
let sectionRemainingSeconds = $derived(
|
let sectionRemainingSeconds = $derived(
|
||||||
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
|
(data.live.sectionTimer?.plannedDurationSeconds ?? 0) - sectionElapsedSeconds
|
||||||
);
|
);
|
||||||
|
let hasActiveSectionTimer = $derived(
|
||||||
|
Boolean(
|
||||||
|
data.live.sectionTimer &&
|
||||||
|
data.live.current &&
|
||||||
|
data.live.sectionTimer.sectionId === data.live.current.sectionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const nextCurrentId = data.live.current?.id ?? null;
|
const nextCurrentId = data.live.current?.id ?? null;
|
||||||
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
||||||
|
const changedPiece = nextCurrentId !== currentId;
|
||||||
currentId = nextCurrentId;
|
currentId = nextCurrentId;
|
||||||
loadedRevision = data.live.liveRevision;
|
loadedRevision = data.live.liveRevision;
|
||||||
loadedAt = Date.now();
|
loadedAt = Date.now();
|
||||||
|
if (changedPiece) {
|
||||||
|
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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -49,24 +105,43 @@
|
|||||||
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
|
if (typeof settings.promptSize === 'number') promptSize = settings.promptSize;
|
||||||
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
|
if (typeof settings.showRail === 'boolean') showRail = settings.showRail;
|
||||||
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
|
if (typeof settings.showTimes === 'boolean') showTimes = settings.showTimes;
|
||||||
if (typeof settings.scrollY === 'number')
|
if (settings.scrollPositions && typeof settings.scrollPositions === 'object') {
|
||||||
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
|
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 {
|
} catch {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
|
const mobileBreakpoint = matchMedia('(max-width: 46rem)');
|
||||||
|
const handleBreakpointChange = (event: MediaQueryListEvent) => {
|
||||||
|
if (!event.matches) void closeMobileRail(false);
|
||||||
|
};
|
||||||
|
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
|
||||||
const interval = setInterval(() => (now = Date.now()), 1000);
|
const interval = setInterval(() => (now = Date.now()), 1000);
|
||||||
const persistScroll = () => persistSettings();
|
|
||||||
addEventListener('scroll', persistScroll, { passive: true });
|
|
||||||
return () => {
|
return () => {
|
||||||
|
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
removeEventListener('scroll', persistScroll);
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function persistSettings() {
|
function persistSettings() {
|
||||||
|
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop;
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`sendewerk-live-${data.live.show.id}`,
|
`sendewerk-live-${data.live.show.id}`,
|
||||||
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
|
JSON.stringify({
|
||||||
|
promptSize,
|
||||||
|
showRail,
|
||||||
|
showTimes,
|
||||||
|
scrollPositions
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +150,50 @@
|
|||||||
persistSettings();
|
persistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRail() {
|
function toggleDesktopRail() {
|
||||||
showRail = !showRail;
|
showRail = !showRail;
|
||||||
persistSettings();
|
persistSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openMobileRail() {
|
||||||
|
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 (mobileRailOpen && event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
void closeMobileRail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function toggleTimes() {
|
||||||
showTimes = !showTimes;
|
showTimes = !showTimes;
|
||||||
persistSettings();
|
persistSettings();
|
||||||
@@ -94,24 +208,38 @@
|
|||||||
: `${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) {
|
function liveAction(name: string) {
|
||||||
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
|
return `?show=${encodeURIComponent(data.live.show.id)}&/${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
|
const enhanceCommand: SubmitFunction = ({ formElement, formData, submitter }) => {
|
||||||
|
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
|
||||||
|
const submittedFromRail = Boolean(formElement.closest('.live-rail'));
|
||||||
|
const keepRailOpen = formElement.dataset.keepRailOpen === 'true';
|
||||||
const command = formElement.elements.namedItem('commandId');
|
const command = formElement.elements.namedItem('commandId');
|
||||||
if (command instanceof HTMLInputElement) {
|
if (command instanceof HTMLInputElement) {
|
||||||
command.value = crypto.randomUUID();
|
command.value = crypto.randomUUID();
|
||||||
formData.set('commandId', command.value);
|
formData.set('commandId', command.value);
|
||||||
}
|
}
|
||||||
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
|
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
|
||||||
return async ({ update }) => {
|
return async ({ result, update }) => {
|
||||||
await update({ reset: false, invalidateAll: true });
|
await update({ reset: false, invalidateAll: true });
|
||||||
|
if (submittedFromRail && (result.type !== 'success' || !keepRailOpen)) {
|
||||||
|
await closeMobileRail();
|
||||||
|
}
|
||||||
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
|
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const enhanceOutput: SubmitFunction = ({ submitter }) => {
|
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => {
|
||||||
|
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
|
||||||
popupMessage = '';
|
popupMessage = '';
|
||||||
outputWindow = window.open('about:blank', 'heinzlive-output');
|
outputWindow = window.open('about:blank', 'heinzlive-output');
|
||||||
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
|
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
|
||||||
@@ -134,151 +262,194 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
|
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
|
||||||
|
|
||||||
<div class="page-wrap live-page live-controller">
|
<div class:without-rail={!showRail} class="live-page live-controller">
|
||||||
<header class="live-header">
|
<header class="live-command-header" inert={mobileRailOpen}>
|
||||||
|
<div class="live-section-context">
|
||||||
|
<span class="live-pulse" aria-hidden="true"></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
|
<small>{data.live.show.title}</small>
|
||||||
<h1>{data.live.show.title}</h1>
|
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong>
|
||||||
<p class="live-state-line">
|
|
||||||
{data.live.startedAtUtc
|
|
||||||
? 'Live-Stand wird automatisch gespeichert.'
|
|
||||||
: 'Noch nicht gestartet.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="live-header-actions">
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class:warning={hasActiveSectionTimer &&
|
||||||
|
sectionRemainingSeconds <= 120 &&
|
||||||
|
sectionRemainingSeconds >= 0}
|
||||||
|
class:overrun={hasActiveSectionTimer && sectionRemainingSeconds < 0}
|
||||||
|
class="section-countdown"
|
||||||
|
aria-live="off"
|
||||||
|
>
|
||||||
|
<span>Abschnitt</span>
|
||||||
|
<strong>
|
||||||
|
{#if hasActiveSectionTimer}
|
||||||
|
{sectionRemainingSeconds < 0
|
||||||
|
? `+${formatCountdown(Math.abs(sectionRemainingSeconds))}`
|
||||||
|
: formatCountdown(sectionRemainingSeconds)}
|
||||||
|
{:else}Kein Countdown{/if}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class:active={Boolean(presentedUsage)} class="live-output-state" aria-live="polite">
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<small>Ausgabe</small>
|
||||||
|
<strong>{presentedUsage?.title ?? 'Leer'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
bind:this={mobileRunButton}
|
||||||
|
class="live-mobile-run-button"
|
||||||
|
type="button"
|
||||||
|
aria-controls="live-run-sheet"
|
||||||
|
aria-expanded={mobileRailOpen}
|
||||||
|
onclick={openMobileRail}
|
||||||
|
>
|
||||||
|
Ablauf · Piece {currentPosition || data.live.completedCount}/{data.live.totalCount}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<details class="live-menu">
|
||||||
|
<summary>Menü</summary>
|
||||||
|
<div class="live-menu-popover">
|
||||||
|
<section>
|
||||||
|
<strong>Ansicht</strong>
|
||||||
<div class="font-control" aria-label="Prompter-Schriftgröße">
|
<div class="font-control" aria-label="Prompter-Schriftgröße">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => changePromptSize(-0.1)}
|
onclick={() => changePromptSize(-0.1)}
|
||||||
aria-label="Schrift verkleinern">A−</button
|
aria-label="Schrift verkleinern">A−</button
|
||||||
>
|
>
|
||||||
<button type="button" onclick={() => changePromptSize(0.1)} aria-label="Schrift vergrößern"
|
<button
|
||||||
>A+</button
|
type="button"
|
||||||
|
onclick={() => changePromptSize(0.1)}
|
||||||
|
aria-label="Schrift vergrößern">A+</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}
|
<button type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
|
||||||
>{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}</button
|
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
|
||||||
>
|
|
||||||
<button type="button" class="text-button" aria-pressed={!showRail} onclick={toggleRail}
|
|
||||||
>{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}</button
|
|
||||||
>
|
|
||||||
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}>Zur Planung</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
|
||||||
{#if popupMessage}<div class="form-message" role="status">{popupMessage}</div>{/if}
|
|
||||||
|
|
||||||
<section class="live-toolbar" aria-label="Live-Sitzung">
|
|
||||||
<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>
|
</button>
|
||||||
</form>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-button live-desktop-rail-toggle"
|
||||||
|
onclick={toggleDesktopRail}
|
||||||
|
>
|
||||||
|
{showRail ? 'Ablauf ausblenden' : 'Ablauf einblenden'}
|
||||||
|
</button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<strong>Ausgabekanal</strong>
|
||||||
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
|
<form method="POST" action={liveAction('createOutput')} use:enhance={enhanceOutput}>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
|
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
|
||||||
</form>
|
</form>
|
||||||
{#if data.live.presentationUsageId}<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>Sendung</strong>
|
||||||
|
{#if data.live.startedAtUtc}<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('present')}
|
action={liveAction('start')}
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<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="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button class="text-button" type="submit">Präsentation leeren</button>
|
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
|
||||||
</form>{/if}
|
</form>{/if}
|
||||||
<details class="live-output-settings">
|
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
|
||||||
<summary>Ausgabekanal</summary>
|
>Liveansicht verlassen · Zur Planung</a
|
||||||
<div>
|
>
|
||||||
<form method="POST" action={liveAction('rotateOutput')} use:enhance={enhanceOutput}>
|
</section>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
|
||||||
<button class="text-button" type="submit">Schlüssel widerrufen und erneuern</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action={liveAction('clearOutput')} use:enhance={enhanceCommand}>
|
|
||||||
<button class="text-button danger-text" type="submit">Ausgabekanal leeren</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="live-session-summary">
|
</header>
|
||||||
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
|
|
||||||
<span>{progress}% bearbeitet</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class:without-rail={!showRail} class="live-grid">
|
<div class="live-messages">
|
||||||
{#if data.live.current}<main
|
{#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>
|
||||||
|
|
||||||
|
<div class="live-workspace">
|
||||||
|
{#if data.live.current}<article
|
||||||
class="live-current"
|
class="live-current"
|
||||||
style={`--prompt-size: ${promptSize}rem`}
|
style={`--prompt-size: ${promptSize}rem`}
|
||||||
aria-labelledby="current-title"
|
aria-labelledby="current-title"
|
||||||
|
inert={mobileRailOpen}
|
||||||
>
|
>
|
||||||
{#if data.live.sectionTimer?.sectionId === data.live.current.sectionId}
|
<div class="live-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
||||||
<section
|
<div class="live-piece-heading">
|
||||||
class:warning={sectionRemainingSeconds <= 120 && sectionRemainingSeconds >= 0}
|
|
||||||
class:overrun={sectionRemainingSeconds < 0}
|
|
||||||
class="section-countdown"
|
|
||||||
aria-live="off"
|
|
||||||
>
|
|
||||||
<span>{data.live.sectionTimer.sectionName} · Abschnitt</span>
|
|
||||||
<strong
|
|
||||||
>{sectionRemainingSeconds < 0
|
|
||||||
? `Überzogen +${formatCountdown(Math.abs(sectionRemainingSeconds))}`
|
|
||||||
: `Noch ${formatCountdown(sectionRemainingSeconds)}`}</strong
|
|
||||||
>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
<div class="live-kicker">
|
<div class="live-kicker">
|
||||||
<span>Aktuell · {data.live.current.sectionName}</span>
|
<span>Piece {currentPosition} von {data.live.totalCount}</span>
|
||||||
{#if showTimes}<strong
|
{#if showTimes}<strong>
|
||||||
>{formatClock(currentActualSeconds)} / {formatClock(
|
{formatClock(currentActualSeconds)} / {formatClock(
|
||||||
data.live.current.durationSeconds
|
data.live.current.durationSeconds
|
||||||
)}</strong
|
)}
|
||||||
>{/if}
|
</strong>{/if}
|
||||||
</div>
|
</div>
|
||||||
<h2 id="current-title">{data.live.current.title}</h2>
|
<h1 id="current-title">{data.live.current.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.live.current.introHtml}<section class="live-script-section live-intro">
|
||||||
|
<span>Einleitung</span>
|
||||||
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
|
<div class="prompter-copy">{@html data.live.current.introHtml}</div>
|
||||||
{#if data.live.current.moderationHtml}<section class="moderation-card">
|
</section>{/if}
|
||||||
|
{#if data.live.current.moderationHtml}<section
|
||||||
|
class="live-script-section moderation-card"
|
||||||
|
>
|
||||||
<span>Moderationsstichpunkte</span>
|
<span>Moderationsstichpunkte</span>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
|
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
|
||||||
</section>{/if}
|
</section>{/if}
|
||||||
<details class="research-details">
|
{#if data.live.current.transitionHtml}<section
|
||||||
<summary>Manuelle Recherche öffnen</summary>
|
class="live-script-section transition-card"
|
||||||
|
>
|
||||||
|
<span>Überleitung</span>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
|
||||||
|
</section>{/if}
|
||||||
|
|
||||||
|
{#if data.live.current.researchHtml || data.live.current.sources.length || data.live.current.tools.length || data.live.current.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 data.live.current.researchHtml}<details class="research-details">
|
||||||
|
<summary>Recherche</summary>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
||||||
</details>
|
</details>{/if}
|
||||||
{#if data.live.current.sources.length || data.live.current.tools.length}
|
{#if data.live.current.sources.length}<details class="live-materials">
|
||||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
<summary>Quellen · {data.live.current.sources.length}</summary>
|
||||||
<section class="live-materials" aria-labelledby="live-materials-title">
|
|
||||||
<div>
|
|
||||||
<span>Nur Redaktion</span>
|
|
||||||
<h3 id="live-materials-title">Materialien</h3>
|
|
||||||
</div>
|
|
||||||
{#if data.live.current.sources.length}<div class="live-material-group">
|
|
||||||
<strong>Quellen</strong>
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each data.live.current.sources as source (source.id)}<li>
|
{#each data.live.current.sources as source (source.id)}<li>
|
||||||
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={source.url} target="_blank" rel="noopener noreferrer"
|
<a href={source.url} target="_blank" rel="noopener noreferrer"
|
||||||
>{source.label}</a
|
>{source.label}</a
|
||||||
>{#if source.timecodeSeconds !== null}<small
|
>
|
||||||
|
{#if source.timecodeSeconds !== null}<small
|
||||||
>ab {formatClock(source.timecodeSeconds)}</small
|
>ab {formatClock(source.timecodeSeconds)}</small
|
||||||
>{/if}
|
>{/if}
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>{/if}
|
</details>{/if}
|
||||||
{#if data.live.current.tools.length}<div class="live-material-group">
|
{#if data.live.current.tools.length}<details class="live-materials">
|
||||||
<strong>Werkzeuge</strong>
|
<summary>Werkzeuge · {data.live.current.tools.length}</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{#each data.live.current.tools as tool (tool.id)}<li>
|
{#each data.live.current.tools as tool (tool.id)}<li>
|
||||||
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
{#if tool.workUrl || tool.startUrl}<!-- The URL is a server-validated private HTTP(S) snapshot. -->
|
||||||
@@ -287,125 +458,147 @@
|
|||||||
href={tool.workUrl || tool.startUrl}
|
href={tool.workUrl || tool.startUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">{tool.name}</a
|
rel="noopener noreferrer">{tool.name}</a
|
||||||
>{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
|
>
|
||||||
|
{:else}<span>{tool.name}</span><small>Kein Link hinterlegt</small>{/if}
|
||||||
{#if tool.note}<small>{tool.note}</small>{/if}
|
{#if tool.note}<small>{tool.note}</small>{/if}
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>{/if}
|
</details>{/if}
|
||||||
</section>
|
{#if data.live.current.ownNotes}<details class="live-materials">
|
||||||
|
<summary>Eigene Notizen</summary>
|
||||||
|
<p>{data.live.current.ownNotes}</p>
|
||||||
|
</details>{/if}
|
||||||
|
</div>
|
||||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.live.current.transitionHtml}<section class="transition-card">
|
{#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml}
|
||||||
<span>Überleitung</span>
|
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
{/if}
|
||||||
<div class="live-markdown">{@html data.live.current.transitionHtml}</div>
|
|
||||||
</section>{/if}
|
|
||||||
|
|
||||||
<div class="live-current-actions">
|
|
||||||
<form method="POST" action={liveAction('back')} 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">Zurück</button>
|
|
||||||
</form>
|
|
||||||
<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={data.live.current.id} />
|
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
|
||||||
<button class="secondary-button" type="submit">
|
|
||||||
{data.live.presentationUsageId === data.live.current.id
|
|
||||||
? 'Erneut präsentieren'
|
|
||||||
: 'Präsentieren'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<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="text-button" type="submit">Ü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">Erledigen und weiter</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</main>{:else}<main class="live-current live-empty">
|
</article>{:else}<section class="live-current live-empty" inert={mobileRailOpen}>
|
||||||
<p class="eyebrow">Kein aktives Piece</p>
|
<div>
|
||||||
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
|
<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>
|
<p>
|
||||||
{data.live.totalCount
|
{sessionComplete
|
||||||
? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
|
? '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.'}
|
: 'In der Planung kann zuerst ein sendefertiges Piece hinzugefügt werden.'}
|
||||||
</p>
|
</p>
|
||||||
{#if data.live.completedCount}<form
|
{#if !data.live.startedAtUtc || needsResume}<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('back')}
|
action={liveAction('start')}
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button class="secondary-button" type="submit">Zum letzten Piece zurück</button>
|
<button class="primary-button" type="submit">
|
||||||
|
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
|
||||||
|
</button>
|
||||||
</form>{/if}
|
</form>{/if}
|
||||||
</main>{/if}
|
</div>
|
||||||
|
</section>{/if}
|
||||||
|
|
||||||
{#if showRail}<aside class="live-rail">
|
<button
|
||||||
<section class="next-card">
|
class:visible={mobileRailOpen}
|
||||||
<span>Als Nächstes</span>
|
class="live-rail-backdrop"
|
||||||
{#if data.live.next}
|
type="button"
|
||||||
<h3>{data.live.next.title}</h3>
|
aria-label="Ablauf schließen"
|
||||||
<p>
|
onclick={() => closeMobileRail()}
|
||||||
{data.live.next.sectionName}{#if showTimes}
|
></button>
|
||||||
· {formatClock(data.live.next.durationSeconds)}{/if}
|
<aside
|
||||||
</p>
|
bind:this={liveRail}
|
||||||
{:else}
|
class:open={mobileRailOpen}
|
||||||
<h3>Sendungsende</h3>
|
class="live-rail"
|
||||||
<p>Kein weiteres offenes Piece eingeplant.</p>
|
id="live-run-sheet"
|
||||||
{/if}
|
role={mobileRailOpen ? 'dialog' : undefined}
|
||||||
</section>
|
aria-modal={mobileRailOpen ? 'true' : undefined}
|
||||||
|
aria-labelledby="live-run-title"
|
||||||
<section class="live-progress">
|
onkeydown={trapRailFocus}
|
||||||
|
>
|
||||||
<header>
|
<header>
|
||||||
<span>Ablauf</span><strong>{data.live.completedCount} / {data.live.totalCount}</strong>
|
<div>
|
||||||
|
<strong id="live-run-title">Ablauf</strong>
|
||||||
|
<span>{data.live.completedCount}/{data.live.totalCount} bearbeitet · {progress}%</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
bind:this={railCloseButton}
|
||||||
|
type="button"
|
||||||
|
aria-label="Ablauf schließen"
|
||||||
|
onclick={() => closeMobileRail()}><AppIcon name="xmark" /></button
|
||||||
|
>
|
||||||
</header>
|
</header>
|
||||||
<div class="progress-track"><span style={`width:${progress}%`}></span></div>
|
<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)}
|
{#each data.live.sections as section, sectionIndex (section.id)}
|
||||||
<div class:active={section.id === data.live.current?.sectionId} class="rail-section">
|
<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>
|
<span>{String(sectionIndex + 1).padStart(2, '0')}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{section.name}</strong>
|
<strong>{section.name}</strong>
|
||||||
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
|
<small
|
||||||
</div>
|
>{section.usages.length} Pieces{#if showTimes}
|
||||||
|
· {formatDuration(section.durationSeconds)}{/if}</small
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div class="rail-section-items">
|
||||||
{#each section.usages as usage, usageIndex (usage.id)}
|
{#each section.usages as usage, usageIndex (usage.id)}
|
||||||
<div class:current={usage.id === data.live.current?.id} class="live-run-item">
|
<div
|
||||||
<div>
|
class:current={usage.id === data.live.current?.id}
|
||||||
<strong>{usage.title}</strong><small>{usage.status}</small>
|
class:done={usage.status === 'done'}
|
||||||
</div>
|
class:skipped={usage.status === 'skipped'}
|
||||||
<div class="live-run-actions">
|
class="live-run-item"
|
||||||
|
data-current={usage.id === data.live.current?.id}
|
||||||
|
>
|
||||||
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
|
<form method="POST" action={liveAction('select')} use:enhance={enhanceCommand}>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
<input type="hidden" name="commandId" value={data.commandId} />
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
<button
|
<button
|
||||||
class="text-button"
|
class="live-run-select"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
|
disabled={!data.live.startedAtUtc || usage.id === data.live.current?.id}
|
||||||
aria-label={`${usage.title} als aktuelles Piece auswählen`}>Aktuell</button
|
aria-label={`${usage.title} als aktuelles Piece auswählen`}
|
||||||
>
|
>
|
||||||
|
<strong>{usage.title}</strong>
|
||||||
|
<small>{statusLabel(usage.status)}</small>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{#if usage.status === 'open'}
|
<div class="live-run-actions">
|
||||||
|
{#if usage.status === 'open'}<details class="live-run-order">
|
||||||
|
<summary aria-label={`${usage.title} sortieren`}
|
||||||
|
><AppIcon name="ellipsis" /></summary
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<form
|
<form
|
||||||
|
data-keep-rail-open="true"
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('moveUsage')}
|
action={liveAction('moveUsage')}
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="showRevision"
|
||||||
|
value={data.live.show.revision}
|
||||||
|
/>
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
<input type="hidden" name="sectionId" value={section.id} />
|
<input type="hidden" name="sectionId" value={section.id} />
|
||||||
<input type="hidden" name="position" value={usageIndex - 1} />
|
<input type="hidden" name="position" value={usageIndex - 1} />
|
||||||
@@ -413,16 +606,22 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={usageIndex === 0 ||
|
disabled={usageIndex === 0 ||
|
||||||
section.usages[usageIndex - 1]?.status !== 'open'}
|
section.usages[usageIndex - 1]?.status !== 'open'}
|
||||||
aria-label={`${usage.title} nach oben`}><AppIcon name="arrow-up" /></button
|
aria-label={`${usage.title} nach oben`}
|
||||||
|
><AppIcon name="arrow-up" /></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
<form
|
<form
|
||||||
|
data-keep-rail-open="true"
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('moveUsage')}
|
action={liveAction('moveUsage')}
|
||||||
use:enhance={enhanceCommand}
|
use:enhance={enhanceCommand}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="showRevision"
|
||||||
|
value={data.live.show.revision}
|
||||||
|
/>
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
<input type="hidden" name="sectionId" value={section.id} />
|
<input type="hidden" name="sectionId" value={section.id} />
|
||||||
<input type="hidden" name="position" value={usageIndex + 1} />
|
<input type="hidden" name="position" value={usageIndex + 1} />
|
||||||
@@ -434,7 +633,8 @@
|
|||||||
><AppIcon name="arrow-down" /></button
|
><AppIcon name="arrow-down" /></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
</div>
|
||||||
|
</details>{/if}
|
||||||
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
|
<form method="POST" action={liveAction('present')} use:enhance={enhanceCommand}>
|
||||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
@@ -447,11 +647,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if !section.usages.length}<p>Keine Pieces in diesem Abschnitt.</p>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="live-reserve">
|
<details class="live-reserve">
|
||||||
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
|
<summary><span>Reserve</span><strong>{data.live.reserve.length}</strong></summary>
|
||||||
|
<div>
|
||||||
{#each data.live.reserve as usage, reserveIndex (usage.id)}
|
{#each data.live.reserve as usage, reserveIndex (usage.id)}
|
||||||
<div class="live-reserve-item">
|
<div class="live-reserve-item">
|
||||||
<div>
|
<div>
|
||||||
@@ -459,8 +662,18 @@
|
|||||||
>{formatClock(usage.durationSeconds)}</small
|
>{formatClock(usage.durationSeconds)}</small
|
||||||
>{/if}
|
>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="live-run-actions">
|
<div class="live-reserve-actions">
|
||||||
<form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}>
|
<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="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
@@ -473,7 +686,12 @@
|
|||||||
><AppIcon name="arrow-up" /></button
|
><AppIcon name="arrow-up" /></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}>
|
<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="showId" value={data.live.show.id} />
|
||||||
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
<input type="hidden" name="showRevision" value={data.live.show.revision} />
|
||||||
<input type="hidden" name="usageId" value={usage.id} />
|
<input type="hidden" name="usageId" value={usage.id} />
|
||||||
@@ -486,6 +704,8 @@
|
|||||||
><AppIcon name="arrow-down" /></button
|
><AppIcon name="arrow-down" /></button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
{#if data.live.current || data.live.sections[0]}<form
|
{#if data.live.current || data.live.sections[0]}<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={liveAction('moveUsage')}
|
action={liveAction('moveUsage')}
|
||||||
@@ -510,7 +730,68 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
|
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
|
||||||
</section>
|
|
||||||
</aside>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="live-action-bar" inert={mobileRailOpen}>
|
||||||
|
<div class="live-next-summary">
|
||||||
|
<span>{data.live.current ? 'Nächstes Piece' : 'Live-Stand'}</span>
|
||||||
|
<strong>
|
||||||
|
{data.live.next?.title ??
|
||||||
|
(sessionComplete ? 'Sendungsende erreicht' : 'Kein weiteres Piece')}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="live-footer-actions">
|
||||||
|
{#if data.live.presentationUsageId}<form
|
||||||
|
class="live-clear-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">Präsentation leeren</button>
|
||||||
|
</form>{/if}
|
||||||
|
{#if data.live.current}<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={data.live.current.id} />
|
||||||
|
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||||
|
<input type="hidden" name="commandId" value={data.commandId} />
|
||||||
|
<button class="secondary-button" type="submit">
|
||||||
|
{data.live.presentationUsageId === data.live.current.id
|
||||||
|
? 'Erneut präsentieren'
|
||||||
|
: 'Präsentieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<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">Ü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">
|
||||||
|
{data.live.next
|
||||||
|
? 'Erledigen & weiter'
|
||||||
|
: hasOtherOpenUsages
|
||||||
|
? 'Erledigen'
|
||||||
|
: 'Erledigen & Sendungsende'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else if sessionComplete}<strong class="live-finished-label">Ablauf abgeschlossen</strong
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
+193
-13
@@ -1003,6 +1003,7 @@ test('validated YouTube and TikTok players switch without exposing private field
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
await page.goto('/live?show=e2e-presentation');
|
await page.goto('/live?show=e2e-presentation');
|
||||||
|
await page.getByText('Menü', { exact: true }).click();
|
||||||
const popupPromise = page.waitForEvent('popup');
|
const popupPromise = page.waitForEvent('popup');
|
||||||
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
||||||
const output = await popupPromise;
|
const output = await popupPromise;
|
||||||
@@ -1076,10 +1077,14 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click();
|
await plannedConsole.getByRole('button', { name: 'Aus Fundus aktualisieren' }).click();
|
||||||
|
|
||||||
await page.goto('/live?show=show-october');
|
await page.goto('/live?show=show-october');
|
||||||
await expect(page.getByText('Noch nicht gestartet.')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Bereit für die Sendung' })).toBeVisible();
|
||||||
|
await expect(page.locator('.sidebar')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.mobile-header')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.mobile-navigation')).toHaveCount(0);
|
||||||
await page.getByRole('button', { name: 'Sendung starten' }).click();
|
await page.getByRole('button', { name: 'Sendung starten' }).click();
|
||||||
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||||
await expect(page.locator('.section-countdown')).toContainText(/Noch 0[45]:\d{2}/);
|
await expect(page.locator('.section-countdown')).toContainText(/0[45]:\d{2}/);
|
||||||
|
await page.getByText('Quellen · 1', { exact: true }).click();
|
||||||
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
|
await expect(page.locator('.live-materials')).toContainText('Herstellerseite');
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
|
page.getByText('Herstellerangaben gegen unabhängige Tests prüfen')
|
||||||
@@ -1088,15 +1093,17 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
const before = await page
|
const before = await page
|
||||||
.locator('.live-current')
|
.locator('.live-current')
|
||||||
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
||||||
|
await page.getByText('Menü', { exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
|
await page.getByRole('button', { name: 'Schrift vergrößern' }).click();
|
||||||
await page.reload();
|
await page.reload();
|
||||||
const restored = await page
|
const restored = await page
|
||||||
.locator('.live-current')
|
.locator('.live-current')
|
||||||
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
||||||
expect(restored).not.toBe(before);
|
expect(restored).not.toBe(before);
|
||||||
await page.getByText('Manuelle Recherche öffnen').click();
|
await page.locator('.research-details > summary').click();
|
||||||
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
|
await expect(page.locator('.research-details')).toHaveAttribute('open', '');
|
||||||
|
|
||||||
|
await page.getByText('Menü', { exact: true }).click();
|
||||||
const [popup] = await Promise.all([
|
const [popup] = await Promise.all([
|
||||||
page.waitForEvent('popup'),
|
page.waitForEvent('popup'),
|
||||||
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
|
page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click()
|
||||||
@@ -1140,28 +1147,201 @@ test('live control persists state and exposes only explicit presentation data',
|
|||||||
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
|
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
|
||||||
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
|
await page.getByRole('button', { name: 'Präsentieren', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByText('Reserve', { exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Als Nächstes' }).click();
|
await page.getByRole('button', { name: 'Als Nächstes' }).click();
|
||||||
await expect(page.locator('.next-card')).toContainText('Pixelart');
|
await expect(page.locator('.live-next-summary')).toContainText('Pixelart');
|
||||||
await page.getByRole('button', { name: 'Erledigen und weiter' }).click();
|
await page.getByRole('button', { name: 'Erledigen & weiter' }).click();
|
||||||
await expect(page.locator('.live-current')).toContainText('Pixelart');
|
await expect(page.locator('.live-current')).toContainText('Pixelart');
|
||||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||||
await page.getByRole('button', { name: 'Zurück' }).click();
|
await expect(page.locator('#current-title')).toHaveText(
|
||||||
await expect(page.locator('.live-current')).toContainText('Pixelart');
|
'Warum praktische Effekte wieder auffallen'
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.locator('.live-run-item').filter({ hasText: 'Die kleine Konsole mit dem großen Dreh' })
|
||||||
|
).toContainText('Erledigt');
|
||||||
|
await expect(
|
||||||
|
page.locator('.live-run-item').filter({ hasText: 'Pixelart, die nicht nostalgisch sein will' })
|
||||||
|
).toContainText('Übersprungen');
|
||||||
|
await expect(
|
||||||
|
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
|
||||||
|
).toContainText('Aktuell');
|
||||||
|
await page.locator('.live-current-scroll').evaluate((element) => {
|
||||||
|
for (let index = 0; index < 12; index += 1) {
|
||||||
|
const paragraph = document.createElement('p');
|
||||||
|
paragraph.textContent = 'Temporärer Langtext für die Lesepositionsprüfung.';
|
||||||
|
element.append(paragraph);
|
||||||
|
}
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
element.dispatchEvent(new Event('scroll'));
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await page.locator('.live-current-scroll').evaluate((element) => element.scrollTop)
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen'
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||||
|
await expect
|
||||||
|
.poll(() => page.locator('.live-current-scroll').evaluate((element) => element.scrollTop))
|
||||||
|
.toBe(0);
|
||||||
|
await expect(
|
||||||
|
page.locator('.live-run-item').filter({ hasText: 'Warum praktische Effekte wieder auffallen' })
|
||||||
|
).toContainText('Kommend');
|
||||||
|
await expect(
|
||||||
|
output.getByRole('heading', { name: 'Die kleine Konsole mit dem großen Dreh' })
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen'
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText(
|
||||||
|
'Warum praktische Effekte wieder auffallen'
|
||||||
|
);
|
||||||
|
await expect(output.getByText('Wenn das Licht wirklich auf ein Modell fällt.')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await setTheme(page, 'dark');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('live-controller-desktop-dark.png') });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 900, height: 450 });
|
||||||
|
await setTheme(page, 'light');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const title = document.querySelector('#current-title');
|
||||||
|
if (title)
|
||||||
|
title.textContent =
|
||||||
|
'Warum praktische Effekte in einer außergewöhnlich langen Live-Moderation wieder auffallen';
|
||||||
|
const copy = document.querySelector('.prompter-copy');
|
||||||
|
if (copy) {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
const paragraph = document.createElement('p');
|
||||||
|
paragraph.textContent =
|
||||||
|
'Zusätzlicher langer Moderationstext prüft die unabhängige Scrollfläche ohne die feste Bedienleiste zu verdrängen.';
|
||||||
|
copy.append(paragraph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rail = document.querySelector('.live-run-scroll');
|
||||||
|
const section = rail?.querySelector('.rail-section');
|
||||||
|
if (rail && section) {
|
||||||
|
for (let index = 0; index < 8; index += 1) rail.append(section.cloneNode(true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const compactLayout = await page.evaluate(() => {
|
||||||
|
const current = document.querySelector('.live-current')?.getBoundingClientRect();
|
||||||
|
const rail = document.querySelector('.live-rail')?.getBoundingClientRect();
|
||||||
|
const footer = document.querySelector('.live-action-bar')?.getBoundingClientRect();
|
||||||
|
const pieceScroll = document.querySelector('.live-current-scroll');
|
||||||
|
const railScroll = document.querySelector('.live-run-scroll');
|
||||||
|
return {
|
||||||
|
pageFits: document.documentElement.scrollHeight <= innerHeight,
|
||||||
|
twoColumns: Boolean(current && rail && rail.x > current.x + current.width - 1),
|
||||||
|
footerVisible: Boolean(footer && footer.bottom <= innerHeight && footer.top >= 0),
|
||||||
|
pieceScrolls: Boolean(pieceScroll && pieceScroll.scrollHeight > pieceScroll.clientHeight),
|
||||||
|
railScrolls: Boolean(railScroll && railScroll.scrollHeight > railScroll.clientHeight)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(compactLayout).toEqual({
|
||||||
|
pageFits: true,
|
||||||
|
twoColumns: true,
|
||||||
|
footerVisible: true,
|
||||||
|
pieceScrolls: true,
|
||||||
|
railScrolls: true
|
||||||
|
});
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('live-controller-compact-desktop.png') });
|
||||||
|
|
||||||
|
const countdownBeforeReload = await page.locator('.section-countdown strong').textContent();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText(
|
||||||
|
'Warum praktische Effekte wieder auffallen'
|
||||||
|
);
|
||||||
|
const countdownAfterReload = await page.locator('.section-countdown strong').textContent();
|
||||||
|
expect(countdownAfterReload).not.toBe('05:00');
|
||||||
|
expect(countdownAfterReload).toMatch(/0[34]:\d{2}/);
|
||||||
|
expect(countdownBeforeReload).toMatch(/0[34]:\d{2}/);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 700 });
|
||||||
|
const liveRail = page.locator('.live-rail');
|
||||||
|
const railClose = liveRail.getByRole('button', { name: 'Ablauf schließen' });
|
||||||
|
await expect(liveRail).toBeHidden();
|
||||||
|
await expect(page.locator('.live-action-bar')).toBeVisible();
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight)
|
||||||
|
).toBe(true);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile.png') });
|
||||||
|
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
|
||||||
|
await expect(liveRail).toHaveClass(/open/);
|
||||||
|
await expect(railClose).toBeFocused();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const box = await liveRail.boundingBox();
|
||||||
|
return box ? Math.ceil(box.x + box.width) : Number.POSITIVE_INFINITY;
|
||||||
|
})
|
||||||
|
.toBeLessThanOrEqual(390);
|
||||||
|
const mobileRailBox = await liveRail.boundingBox();
|
||||||
|
expect(mobileRailBox?.width ?? 0).toBeGreaterThan(340);
|
||||||
|
await expect(page.locator('.live-run-item.current')).toBeInViewport();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('live-controller-mobile-run.png') });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(liveRail).toBeHidden();
|
||||||
|
await expect(page.getByRole('button', { name: /Ablauf · Piece/ })).toBeFocused();
|
||||||
|
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
|
||||||
|
await page.setViewportSize({ width: 900, height: 450 });
|
||||||
|
await expect(liveRail).not.toHaveAttribute('role', 'dialog');
|
||||||
|
await expect(page.locator('.live-command-header')).not.toHaveAttribute('inert', '');
|
||||||
|
await page.setViewportSize({ width: 390, height: 700 });
|
||||||
|
await expect(liveRail).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
|
||||||
|
await liveRail
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Die kleine Konsole mit dem großen Dreh als aktuelles Piece auswählen'
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(liveRail).toBeHidden();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||||
|
await page.getByRole('button', { name: /Ablauf · Piece/ }).click();
|
||||||
|
await liveRail
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Warum praktische Effekte wieder auffallen als aktuelles Piece auswählen'
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await expect(liveRail).toBeHidden();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText(
|
||||||
|
'Warum praktische Effekte wieder auffallen'
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Erledigen' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Live-Stand wiederaufnehmen' }).click();
|
||||||
|
await expect(page.locator('#current-title')).toHaveText('Die kleine Konsole mit dem großen Dreh');
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
await page.getByRole('button', { name: 'Erledigen & Sendungsende' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Alle Pieces bearbeitet' })).toBeVisible();
|
||||||
|
await expect(page.locator('.live-finished-label')).toHaveText('Ablauf abgeschlossen');
|
||||||
|
await expect(page.getByRole('button', { name: 'Erledigen & weiter' })).toHaveCount(0);
|
||||||
|
|
||||||
await output.route('**/state', (route) => route.abort());
|
await output.route('**/state', (route) => route.abort());
|
||||||
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
|
await expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
|
||||||
await output.unroute('**/state');
|
await output.unroute('**/state');
|
||||||
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
|
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
|
||||||
await expectNoHorizontalOverflow(page);
|
await expect(output.locator('.output-content')).toHaveCount(0);
|
||||||
await page.screenshot({
|
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
|
||||||
path: testInfo.outputPath('live-controller-mobile.png'),
|
|
||||||
fullPage: true
|
|
||||||
});
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.open = () => null;
|
window.open = () => null;
|
||||||
});
|
});
|
||||||
|
await page.getByText('Menü', { exact: true }).click();
|
||||||
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
|
|||||||
Reference in new issue
Block a user