Add videos: library, block and section background

Videos join the media library as their own table (schema v28), with a
video block and a looping background on sections. video.js starts loops
only while visible, offers a pause button and leaves the still image for
reduced motion and data saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWYm8kjAWmZaB2QB2xuBhU
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-11-05 05:23:17 +00:00
1 parent 592e3d5342
commit 76613b9f8e
25 files changed
+674 -20

No files matched your search

+8 -5
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', video: 'Video', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', video: '▶', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
/**
@@ -9,7 +9,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
*/
export const CATEGORIES = [
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'video', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
@@ -132,6 +132,8 @@ export function createBlock(type) {
if (type === 'language') block.props = { display: 'code' };
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
if (type === 'html') block.props = { html: '' };
// Leer eingefuegt wie „Eigenes HTML“: auf der Website steht nichts, bis ein Video gewaehlt ist.
if (type === 'video') block.props = { src: '', mode: 'player', title: '' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -197,6 +199,7 @@ export function upgradeForType(document, type) {
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (type === 'html') upgradeDocument(document, 24);
if (type === 'video') upgradeDocument(document, 28);
if (type === 'language') upgradeDocument(document, 27);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
@@ -348,7 +351,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 27;
export const SCHEMA_VERSION = 28;
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
@@ -692,7 +695,7 @@ export function relevantProperty(block, key, property) {
if (property.group === 'menu') return block.type === 'menu';
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'video', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (key === 'columnWidths') return block.type === 'row';
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
return true;
+55 -1
View File
@@ -1,5 +1,5 @@
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
import { openMedia, openDocuments, createMediaPreview } from './media.js';
import { openMedia, openDocuments, openVideos, createMediaPreview } from './media.js';
import { openPageDialog } from './page-picker.js';
import { createDesignPanel } from './design-panel.js';
import { renderButtonControls, setButtonDesign } from './button-design.js';
@@ -549,6 +549,13 @@ function renderContent(block, panel) {
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
}
if (block.type === 'video') {
videoChoice(panel, block, 'src', 'Video', 'MP4 mit H.264 spielt jeder Browser ab.');
videoChoice(panel, block, 'srcWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
posterChoice(panel, block);
field('Verhalten', 'mode', block.props.mode || 'player', { choices: { player: 'Spieler mit Steuerung', loop: 'Schleife ohne Ton' }, help: 'Die Schleife startet von selbst, sobald das Video zu sehen ist, und läuft nur stumm. Wer „Bewegung reduzieren“ eingestellt hat, sieht das Standbild.' });
field('Titel', 'title', block.props.title || '', { maxLength: 160, help: 'Worum es im Video geht. Screenreader lesen das vor. Bei einer rein dekorativen Schleife leer lassen.' });
}
if (block.type === 'html') {
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
@@ -671,6 +678,8 @@ function renderContent(block, panel) {
panel.append(images);
}
if (block.type === 'section') {
videoChoice(panel, block, 'backgroundVideo', 'Hintergrundvideo', 'Läuft stumm in der Schleife hinter dem Inhalt. Das Hintergrundbild unter Design bleibt das Standbild, bis das Video läuft, und für alle, die keine Bewegung möchten. Eine Überlagerung liegt nicht über dem Video.');
videoChoice(panel, block, 'backgroundVideoWebm', 'Zweitformat WebM (optional)', 'Meist kleiner als das MP4. Browser, die es können, nehmen es zuerst.', 'video/webm');
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
}
@@ -866,6 +875,51 @@ function renderPluginFields(block, panel, plugin, field) {
}
}
/** Ein Videofeld im Eigenschaftenfeld. Im Dokument steht die Adresse der Datei. */
function videoChoice(panel, block, key, labelText, helpText, only = '') {
const value = block.props?.[key] || '';
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = labelText; wrap.append(label);
const set = src => {
history.change(doc => {
upgradeDocument(doc, 28);
const current = locate(doc.blocks, block.id)?.block;
if (!current) return;
current.props ??= {};
if (src) current.props[key] = src; else delete current.props[key];
});
renderProperties(); changed();
};
if (value) { const name = document.createElement('code'); name.textContent = value; wrap.append(name); }
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Video wählen' : 'Video wählen oder hochladen';
pick.addEventListener('click', () => openVideos({ only, onChoose: item => set(item.src) }));
wrap.append(pick);
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Video entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = helpText; wrap.append(help);
panel.append(wrap);
}
/** Das Standbild eines Videos: ein Bild der Mediathek, in einer Breite, die für ein Video reicht. */
function posterChoice(panel, block) {
const value = block.props?.poster || '';
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = 'Standbild (optional)'; wrap.append(label);
const preview = createMediaPreview({ src: value }); wrap.append(preview.element);
const set = src => {
history.change(doc => {
const current = locate(doc.blocks, block.id)?.block;
if (!current) return;
if (src) current.props.poster = src; else delete current.props.poster;
});
renderProperties(); changed();
};
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild wählen';
pick.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) set((item.variants.find(variant => variant.width >= 1600) ?? item.variants.at(-1)).src); } }));
wrap.append(pick);
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
panel.append(wrap);
}
function chooseMedia(multiple) {
const target = selected;
openMedia({ multiple, onChoose: items => {
+1
View File
@@ -58,6 +58,7 @@ export const PREVIEWS = {
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
video: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10 5.4l5 2.6-5 2.6z"/>',
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
};
+1 -1
View File
@@ -9,7 +9,7 @@ const zone = document.querySelector('[data-dropzone]');
const input = zone?.querySelector('input[type="file"]');
const chosen = document.querySelector('[data-chosen]');
if (zone && input && chosen) {
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'video/mp4', 'video/webm'];
const describe = (files, ignored = 0) => {
if (!files.length) {
+65
View File
@@ -206,4 +206,69 @@ export function openDocuments({ onChoose } = {}) {
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
}
/**
* Ein Video aus der Mediathek wählen - oder eines hochladen und gleich wählen.
*
* Wie bei den PDFs ein eigener, schmaler Dialog. Mit only: 'video/webm' bietet er nur dieses Format
* an, für das Zweitformat neben dem MP4.
*/
export function openVideos({ onChoose, only = '' } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog document-dialog');
dialog.setAttribute('aria-labelledby', 'video-dialog-title');
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">VIDEOS</p><h2 id="video-dialog-title">Video wählen</h2></div><button class="secondary" id="video-close">Schließen</button></div>
<p class="panel-hint">MP4 (H.264) oder WebM bis 64 MB. Das Video wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar. Kleine Dateien laden schneller – ein Hintergrund sollte nur wenige MB haben.</p>
<div class="media-tools"><div><label for="video-search">Videos suchen</label><input id="video-search" type="search" placeholder="Titel"></div><div><label for="video-upload">Video hochladen</label><input id="video-upload" type="file" accept="video/mp4,video/webm,.mp4,.webm" disabled></div></div>
<p id="video-status" role="status" aria-live="polite">Wird geladen …</p><p id="video-error" class="media-error" role="alert" hidden></p>
<div id="video-list" class="document-list" role="group" aria-label="Video auswählen"></div>
<div class="media-footer"><span></span><button id="video-insert" class="primary" disabled>Video übernehmen</button></div>`;
document.body.append(dialog);
const $ = selector => dialog.querySelector(selector);
let items = [], chosen = null, token;
const error = message => { $('#video-error').textContent = message; $('#video-error').hidden = !message; };
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
const base = document.querySelector('meta[name="admin-base"]').content + '/api/videos';
const fits = item => !only || item.mime === only;
function render() {
const query = $('#video-search').value.toLocaleLowerCase('de');
const visible = items.filter(item => fits(item) && item.title.toLocaleLowerCase('de').includes(query));
$('#video-list').replaceChildren();
for (const item of visible) {
const card = element('button', '', 'document-card'); card.type = 'button';
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
card.append(element('span', item.mime === 'video/webm' ? 'WEBM' : 'MP4', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
card.addEventListener('dblclick', () => { chosen = item; $('#video-insert').click(); });
$('#video-list').append(card);
}
if (!visible.length) $('#video-list').append(element('p', items.some(fits) ? 'Kein Video mit diesem Titel.' : 'Noch keine passenden Videos. Lade oben eines hoch.', 'muted'));
$('#video-status').textContent = `${items.filter(fits).length} Videos${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
$('#video-insert').disabled = !chosen;
}
$('#video-search').addEventListener('input', render);
$('#video-close').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
$('#video-upload').addEventListener('change', async () => {
const file = $('#video-upload').files[0]; if (!file) return;
error('');
if (file.size > 64 * 1024 * 1024) { error(`${file.name}: größer als 64 MB`); $('#video-upload').value = ''; return; }
$('#video-status').textContent = `Wird hochgeladen: ${file.name}`;
try {
const form = new FormData(); form.append('file', file);
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
items.unshift(data.item); chosen = fits(data.item) ? data.item : null;
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
$('#video-upload').value = ''; render();
});
$('#video-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
dialog.showModal();
fetch(base, { credentials: 'same-origin' }).then(async response => {
if (!response.ok || response.redirected) throw new Error('Videos konnten nicht geladen werden. Bitte neu anmelden.');
const data = await response.json(); items = data.items; token = data.csrfToken; $('#video-upload').disabled = false; render();
}).catch(err => { $('#video-status').textContent = ''; error(err.message); });
}
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
+2
View File
@@ -23,3 +23,5 @@
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
/* Die Vorschau läuft ohne Skript; dann blendet Chrome bei jedem Video die Steuerung ein. */
.section-video::-webkit-media-controls{display:none!important}
+10
View File
@@ -241,6 +241,16 @@ body.consent-open{padding-bottom:320px}
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
.block-video{position:relative;margin:0;max-width:100%}
.video-media{display:block;width:100%;height:auto;border-radius:10px}
.has-video{position:relative;overflow:hidden}
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
.has-video>.section-inner{position:relative;z-index:1}
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
.video-toggle::before{content:"❚❚"}
.video-toggle.is-paused::before{content:"▶"}
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
.video-toggle[hidden]{display:none}
.block-html{max-width:100%}
.block-html img,.block-html video,.block-html table{max-width:100%}
.block-html table{border-collapse:collapse}
+1
View File
@@ -26,6 +26,7 @@ export const FIELDS = {
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
video: [{ prop: 'title', label: 'Titel', max: 160 }],
};
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
+34
View File
@@ -0,0 +1,34 @@
/*
* Schleifenvideos: im Hintergrund einer Sektion oder im Video-Baustein.
*
* Abgespielt wird erst von hier aus, nicht mit autoplay im HTML. So lädt die Seite zuerst ihren
* Inhalt, das Video folgt danach (preload="none"), und es läuft nur, solange es zu sehen ist.
* Wer „Bewegung reduzieren“ eingestellt hat oder Daten sparen will, bekommt das Standbild und kann
* das Video selbst starten. Der Knopf gehört dazu: Bewegung, die länger als fünf Sekunden läuft,
* muss sich anhalten lassen (WCAG 2.2.2). Ohne Skript bleibt das Standbild.
*/
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const saving = navigator.connection?.saveData === true;
for (const video of document.querySelectorAll('[data-video-loop]')) {
const toggle = video.parentElement.querySelector(':scope > .video-toggle');
let wanted = !reduced.matches && !saving;
let visible = false;
const paint = () => {
toggle.classList.toggle('is-paused', !wanted);
toggle.setAttribute('aria-label', wanted ? toggle.dataset.pause : toggle.dataset.play);
};
const sync = () => {
if (!wanted || !visible) { video.pause(); return; }
video.play().catch(error => {
// Ein Anhalten mitten im Starten ist kein Fehler. Verweigert der Browser das Abspielen,
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit.
if (error.name === 'NotAllowedError') { wanted = false; paint(); }
});
};
toggle.hidden = false;
toggle.addEventListener('click', () => { wanted = !wanted; paint(); sync(); });
reduced.addEventListener('change', () => { if (reduced.matches) { wanted = false; paint(); sync(); } });
new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(video);
paint();
}