Build compact live control view
This commit is contained in:
1 parent
3389ba17f4
commit
bd9ffe6faa
10 files changed
+1573
-625
No files matched your search
+16
-6
@@ -413,14 +413,24 @@
|
||||
- reale OBS-Browserquelle, echte YouTube-/TikTok-Wiedergabe und Safari auf einem
|
||||
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 |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------- |
|
||||
| Werkzeugdialog ist initial geschlossen | Chromium prüft den Dialog vor jeder Bedienaktion bei 390 und 1280 Pixeln | 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 23.10.2026 |
|
||||
| Bestehender Werkzeugablauf bleibt erhalten | Browser prüft Icon, Format, Deaktivierung und Piece-Zuordnung nach erneutem Laden | 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 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 24.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 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
|
||||
|
||||
|
||||
@@ -283,6 +283,17 @@ Der Live-Modus ist eine eigene, fokussierte Arbeitsansicht. Die
|
||||
Präsentationsausgabe verwendet eine separate, strikt reduzierte Datenansicht
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
noch eine Laufzeitabhängigkeit vom benachbarten Quellverzeichnis; Herkunft und
|
||||
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
|
||||
|
||||
## 0.5.11 - Verwaltungskorrektur
|
||||
## 0.5.11 - Kompakte Live-Regie
|
||||
|
||||
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
|
||||
wieder zuverlässig geschlossen.
|
||||
- Der Dialog erscheint erst, wenn ein neues Werkzeug angelegt oder ein
|
||||
bestehendes Werkzeug bearbeitet wird.
|
||||
zuverlässig geschlossen und erscheint erst nach einer bewussten Aktion.
|
||||
- Die Iconauswahl unter „Inhaltliche Zuordnung“ bleibt auf Desktop und
|
||||
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
|
||||
|
||||
|
||||
+24
-7
@@ -7,14 +7,31 @@ Anmeldung, Rollen, persistente Inhaltsbearbeitung, Sendungsplanung,
|
||||
Live-Steuerung, Präsentationsausgabe und lokaler Betriebsweg sind dokumentiert.
|
||||
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
|
||||
wird erst durch „Werkzeug hinzufügen“ oder die Bearbeitung eines vorhandenen
|
||||
Werkzeugs als modaler Dialog eingeblendet
|
||||
- die Iconauswahl der Themenverwaltung bleibt auch in der rechten Desktop-Spalte
|
||||
vollständig innerhalb des sichtbaren Browserbereichs
|
||||
- ein Browser-Regressionscheck sichert den geschlossenen Ausgangszustand ab
|
||||
- fokussierte private Liveansicht ohne normale App-Seitenleiste oder mobile
|
||||
Bottomnavigation; Planung, Theme, Schriftgröße, Zeitdarstellung und
|
||||
Ausgabekanal bleiben über das kompakte Regiemenü erreichbar
|
||||
- feste Kopfzeile mit aktuellem Abschnitt, persistentem Abschnittscountdown und
|
||||
öffentlichem Ausgabezustand sowie feste Fußleiste mit nächstem Piece,
|
||||
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.
|
||||
|
||||
|
||||
+778
-401
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 }) => ({
|
||||
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}
|
||||
{@render children()}
|
||||
{:else if data.user && data.liveControllerMode}
|
||||
<main id="main-content" class="live-controller-shell">{@render children()}</main>
|
||||
{:else if data.user}
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
|
||||
+472
-191
@@ -1,24 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { formatClock, formatDuration } from '#lib/format.js';
|
||||
import { onMount, tick, untrack } from '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';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let promptSize = $state(1.55);
|
||||
let showRail = $state(true);
|
||||
let mobileRailOpen = $state(false);
|
||||
let showTimes = $state(true);
|
||||
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 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 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(
|
||||
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(
|
||||
(data.live.current?.actualDurationSeconds ?? 0) +
|
||||
(data.live.current ? Math.max(0, Math.floor((now - loadedAt) / 1000)) : 0)
|
||||
@@ -32,14 +64,38 @@
|
||||
let sectionRemainingSeconds = $derived(
|
||||
(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(() => {
|
||||
const nextCurrentId = data.live.current?.id ?? null;
|
||||
if (nextCurrentId !== currentId || data.live.liveRevision !== loadedRevision) {
|
||||
const changedPiece = nextCurrentId !== currentId;
|
||||
currentId = nextCurrentId;
|
||||
loadedRevision = data.live.liveRevision;
|
||||
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(() => {
|
||||
@@ -49,24 +105,43 @@
|
||||
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 (typeof settings.scrollY === 'number')
|
||||
requestAnimationFrame(() => scrollTo(0, settings.scrollY));
|
||||
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)');
|
||||
const handleBreakpointChange = (event: MediaQueryListEvent) => {
|
||||
if (!event.matches) void closeMobileRail(false);
|
||||
};
|
||||
mobileBreakpoint.addEventListener('change', handleBreakpointChange);
|
||||
const interval = setInterval(() => (now = Date.now()), 1000);
|
||||
const persistScroll = () => persistSettings();
|
||||
addEventListener('scroll', persistScroll, { passive: true });
|
||||
return () => {
|
||||
mobileBreakpoint.removeEventListener('change', handleBreakpointChange);
|
||||
clearInterval(interval);
|
||||
removeEventListener('scroll', persistScroll);
|
||||
};
|
||||
});
|
||||
|
||||
function persistSettings() {
|
||||
if (pieceScroller && currentId) scrollPositions[currentId] = pieceScroller.scrollTop;
|
||||
localStorage.setItem(
|
||||
`sendewerk-live-${data.live.show.id}`,
|
||||
JSON.stringify({ promptSize, showRail, showTimes, scrollY })
|
||||
JSON.stringify({
|
||||
promptSize,
|
||||
showRail,
|
||||
showTimes,
|
||||
scrollPositions
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,11 +150,50 @@
|
||||
persistSettings();
|
||||
}
|
||||
|
||||
function toggleRail() {
|
||||
function toggleDesktopRail() {
|
||||
showRail = !showRail;
|
||||
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() {
|
||||
showTimes = !showTimes;
|
||||
persistSettings();
|
||||
@@ -94,24 +208,38 @@
|
||||
: `${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 }) => {
|
||||
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');
|
||||
if (command instanceof HTMLInputElement) {
|
||||
command.value = crypto.randomUUID();
|
||||
formData.set('commandId', command.value);
|
||||
}
|
||||
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
|
||||
return async ({ update }) => {
|
||||
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 }) => {
|
||||
const enhanceOutput: SubmitFunction = ({ formElement, submitter }) => {
|
||||
formElement.closest<HTMLDetailsElement>('.live-menu')?.removeAttribute('open');
|
||||
popupMessage = '';
|
||||
outputWindow = window.open('about:blank', 'heinzlive-output');
|
||||
if (!outputWindow) popupMessage = 'Popup blockiert. Bitte Popups erlauben und erneut öffnen.';
|
||||
@@ -134,151 +262,194 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWindowKeydown} />
|
||||
|
||||
<svelte:head><title>Live | {data.live.show.title} | Sendewerk</title></svelte:head>
|
||||
|
||||
<div class="page-wrap live-page live-controller">
|
||||
<header class="live-header">
|
||||
<div class:without-rail={!showRail} class="live-page live-controller">
|
||||
<header class="live-command-header" inert={mobileRailOpen}>
|
||||
<div class="live-section-context">
|
||||
<span class="live-pulse" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="eyebrow"><span class="live-pulse"></span> Live-Steuerung</p>
|
||||
<h1>{data.live.show.title}</h1>
|
||||
<p class="live-state-line">
|
||||
{data.live.startedAtUtc
|
||||
? 'Live-Stand wird automatisch gespeichert.'
|
||||
: 'Noch nicht gestartet.'}
|
||||
</p>
|
||||
<small>{data.live.show.title}</small>
|
||||
<strong>{currentSection?.name ?? (sessionComplete ? 'Sendungsende' : 'Live-Regie')}</strong>
|
||||
</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">
|
||||
<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
|
||||
<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
|
||||
>
|
||||
<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 type="button" class="text-button" aria-pressed={!showTimes} onclick={toggleTimes}>
|
||||
{showTimes ? 'Zeiten ausblenden' : 'Zeiten einblenden'}
|
||||
</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}>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
|
||||
</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"
|
||||
action={liveAction('present')}
|
||||
action={liveAction('start')}
|
||||
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>
|
||||
<button class="text-button" type="submit">Live-Stand wiederaufnehmen</button>
|
||||
</form>{/if}
|
||||
<details class="live-output-settings">
|
||||
<summary>Ausgabekanal</summary>
|
||||
<div>
|
||||
<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 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>
|
||||
<a href={resolve('/sendungen/[id]', { id: data.live.show.id })}
|
||||
>Liveansicht verlassen · Zur Planung</a
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
<div class="live-session-summary">
|
||||
<strong>{data.live.completedCount} / {data.live.totalCount}</strong>
|
||||
<span>{progress}% bearbeitet</span>
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<div class:without-rail={!showRail} class="live-grid">
|
||||
{#if data.live.current}<main
|
||||
<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>
|
||||
|
||||
<div class="live-workspace">
|
||||
{#if data.live.current}<article
|
||||
class="live-current"
|
||||
style={`--prompt-size: ${promptSize}rem`}
|
||||
aria-labelledby="current-title"
|
||||
inert={mobileRailOpen}
|
||||
>
|
||||
{#if data.live.sectionTimer?.sectionId === data.live.current.sectionId}
|
||||
<section
|
||||
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-current-scroll" bind:this={pieceScroller} onscroll={persistSettings}>
|
||||
<div class="live-piece-heading">
|
||||
<div class="live-kicker">
|
||||
<span>Aktuell · {data.live.current.sectionName}</span>
|
||||
{#if showTimes}<strong
|
||||
>{formatClock(currentActualSeconds)} / {formatClock(
|
||||
<span>Piece {currentPosition} von {data.live.totalCount}</span>
|
||||
{#if showTimes}<strong>
|
||||
{formatClock(currentActualSeconds)} / {formatClock(
|
||||
data.live.current.durationSeconds
|
||||
)}</strong
|
||||
>{/if}
|
||||
)}
|
||||
</strong>{/if}
|
||||
</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. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<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>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
<div class="live-markdown">{@html data.live.current.moderationHtml}</div>
|
||||
</section>{/if}
|
||||
<details class="research-details">
|
||||
<summary>Manuelle Recherche öffnen</summary>
|
||||
{#if data.live.current.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 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 -->
|
||||
<div class="live-markdown">{@html data.live.current.researchHtml}</div>
|
||||
</details>
|
||||
{#if data.live.current.sources.length || data.live.current.tools.length}
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<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>
|
||||
</details>{/if}
|
||||
{#if data.live.current.sources.length}<details class="live-materials">
|
||||
<summary>Quellen · {data.live.current.sources.length}</summary>
|
||||
<ul>
|
||||
{#each data.live.current.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
|
||||
>
|
||||
{#if source.timecodeSeconds !== null}<small
|
||||
>ab {formatClock(source.timecodeSeconds)}</small
|
||||
>{/if}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>{/if}
|
||||
{#if data.live.current.tools.length}<div class="live-material-group">
|
||||
<strong>Werkzeuge</strong>
|
||||
</details>{/if}
|
||||
{#if data.live.current.tools.length}<details class="live-materials">
|
||||
<summary>Werkzeuge · {data.live.current.tools.length}</summary>
|
||||
<ul>
|
||||
{#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. -->
|
||||
@@ -287,125 +458,147 @@
|
||||
href={tool.workUrl || tool.startUrl}
|
||||
target="_blank"
|
||||
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}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>{/if}
|
||||
</section>
|
||||
</details>{/if}
|
||||
{#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 -->
|
||||
{/if}
|
||||
{#if data.live.current.transitionHtml}<section class="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}
|
||||
|
||||
<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>
|
||||
{#if !data.live.current.introHtml && !data.live.current.moderationHtml && !data.live.current.transitionHtml}
|
||||
<p class="live-empty-copy">Für dieses Piece sind keine Moderationstexte hinterlegt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>{:else}<main class="live-current live-empty">
|
||||
<p class="eyebrow">Kein aktives Piece</p>
|
||||
<h2>{data.live.totalCount ? 'Live-Stand bereit' : 'Noch kein Piece eingeplant'}</h2>
|
||||
</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>
|
||||
{data.live.totalCount
|
||||
? 'Sendung starten oder einen abgeschlossenen Schritt zurückgehen.'
|
||||
{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.completedCount}<form
|
||||
{#if !data.live.startedAtUtc || needsResume}<form
|
||||
method="POST"
|
||||
action={liveAction('back')}
|
||||
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">Zum letzten Piece zurück</button>
|
||||
<button class="primary-button" type="submit">
|
||||
{data.live.startedAtUtc ? 'Live-Stand wiederaufnehmen' : 'Sendung starten'}
|
||||
</button>
|
||||
</form>{/if}
|
||||
</main>{/if}
|
||||
</div>
|
||||
</section>{/if}
|
||||
|
||||
{#if showRail}<aside class="live-rail">
|
||||
<section class="next-card">
|
||||
<span>Als Nächstes</span>
|
||||
{#if data.live.next}
|
||||
<h3>{data.live.next.title}</h3>
|
||||
<p>
|
||||
{data.live.next.sectionName}{#if showTimes}
|
||||
· {formatClock(data.live.next.durationSeconds)}{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<h3>Sendungsende</h3>
|
||||
<p>Kein weiteres offenes Piece eingeplant.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="live-progress">
|
||||
<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>
|
||||
<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>
|
||||
<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)}
|
||||
<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>
|
||||
<div>
|
||||
<strong>{section.name}</strong>
|
||||
{#if showTimes}<small>{formatDuration(section.durationSeconds)}</small>{/if}
|
||||
</div>
|
||||
<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="live-run-item">
|
||||
<div>
|
||||
<strong>{usage.title}</strong><small>{usage.status}</small>
|
||||
</div>
|
||||
<div class="live-run-actions">
|
||||
<div
|
||||
class:current={usage.id === data.live.current?.id}
|
||||
class:done={usage.status === 'done'}
|
||||
class:skipped={usage.status === 'skipped'}
|
||||
class="live-run-item"
|
||||
data-current={usage.id === data.live.current?.id}
|
||||
>
|
||||
<form method="POST" action={liveAction('select')} 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"
|
||||
class="live-run-select"
|
||||
type="submit"
|
||||
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>
|
||||
{#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
|
||||
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="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} />
|
||||
@@ -413,16 +606,22 @@
|
||||
type="submit"
|
||||
disabled={usageIndex === 0 ||
|
||||
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
|
||||
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="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} />
|
||||
@@ -434,7 +633,8 @@
|
||||
><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</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} />
|
||||
@@ -447,11 +647,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !section.usages.length}<p>Keine Pieces in diesem Abschnitt.</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="live-reserve">
|
||||
<header><span>Reserve</span><strong>{data.live.reserve.length}</strong></header>
|
||||
<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>
|
||||
@@ -459,8 +662,18 @@
|
||||
>{formatClock(usage.durationSeconds)}</small
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="live-run-actions">
|
||||
<form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}>
|
||||
<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} />
|
||||
@@ -473,7 +686,12 @@
|
||||
><AppIcon name="arrow-up" /></button
|
||||
>
|
||||
</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="showRevision" value={data.live.show.revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
@@ -486,6 +704,8 @@
|
||||
><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{#if data.live.current || data.live.sections[0]}<form
|
||||
method="POST"
|
||||
action={liveAction('moveUsage')}
|
||||
@@ -510,7 +730,68 @@
|
||||
</div>
|
||||
{/each}
|
||||
{#if !data.live.reserve.length}<p>Keine Pieces in Reserve.</p>{/if}
|
||||
</section>
|
||||
</aside>{/if}
|
||||
</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>
|
||||
+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.getByText('Menü', { exact: true }).click();
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
||||
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 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 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.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
|
||||
.locator('.live-current')
|
||||
.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.reload();
|
||||
const restored = await page
|
||||
.locator('.live-current')
|
||||
.evaluate((element) => getComputedStyle(element).getPropertyValue('--prompt-size'));
|
||||
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 page.getByText('Menü', { exact: true }).click();
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup'),
|
||||
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 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 expect(page.locator('.next-card')).toContainText('Pixelart');
|
||||
await page.getByRole('button', { name: 'Erledigen und weiter' }).click();
|
||||
await expect(page.locator('.live-next-summary')).toContainText('Pixelart');
|
||||
await page.getByRole('button', { name: 'Erledigen & weiter' }).click();
|
||||
await expect(page.locator('.live-current')).toContainText('Pixelart');
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück' }).click();
|
||||
await expect(page.locator('.live-current')).toContainText('Pixelart');
|
||||
await expect(page.locator('#current-title')).toHaveText(
|
||||
'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 expect(output.getByText('Verbindung unterbrochen')).toBeVisible({ timeout: 5_000 });
|
||||
await output.unroute('**/state');
|
||||
await expect(output.getByText('Verbunden')).toBeVisible({ timeout: 5_000 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('live-controller-mobile.png'),
|
||||
fullPage: true
|
||||
});
|
||||
await page.getByRole('button', { name: 'Präsentation leeren' }).click();
|
||||
await expect(output.locator('.output-content')).toHaveCount(0);
|
||||
await expect(output.locator('.heinzlive-output')).toHaveClass(/empty/);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.open = () => null;
|
||||
});
|
||||
await page.getByText('Menü', { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Ausgabe verbinden und öffnen' }).click();
|
||||
await expect(
|
||||
page
|
||||
|
||||
Reference in new issue
Block a user