Zum Inhalt
← Galerien & Slider

Video-Vorschau bei Hover / im Viewport

Projektkarten zeigen ein Standbild. Erst unter dem Zeiger oder im Viewport läuft der stumme Clip, das Bild zoomt dabei leicht.

Portfolio, Studio, Kurs-Kacheln: mehrere kurze Clips im Raster, am Desktop per Hover, auf Touch im Viewport, Unterumschalter Hover, Viewport, Scrubbing.

Projektkarten zeigen zuerst ein Standbild. Am Desktop startet beim Hover an derselben Stelle ein stummes Loop-Video von vorn. Das Standbild blendet aus, das Medium zoomt minimal. Beim Verlassen pausiert das Video. Auf Touch-Geräten und in der Viewport-Variante lädt das Video erst, wenn die Karte sichtbar wird, und spielt nur, solange sie sichtbar bleibt. Die Clips sind kurz (3 bis 8 s), stark komprimiert und werden erst bei Bedarf geladen.

Das Raster wird lebendig, ohne dass die ganze Seite flimmert. Bewegung entsteht genau dort, wo der Nutzer hinschaut. Ein paar Sekunden Bewegtbild zeigen Motion-Design, Interaktion und Stimmung eines Projekts schneller als jedes Standbild.

Wann einsetzen

Portfolio-Raster, Projektindizes, Produkt-Features mit kurzen UI-Clips, Kurs-Kacheln mit Trailer, Template-Galerien mit animierter Vorschau.

Wann nicht

Wenn Datensparsamkeit zählt und viele große Videos nötig wären. Wenn der Clip Ton braucht. Wenn mehr als etwa drei Videos gleichzeitig laufen würden. Wenn das Standbild schon alles sagt.

Varianten

  • Hover-to-play: Neustart bei 0, Standbild blendet aus (Cuberto)
  • Viewport: Quelle erst bei Sichtbarkeit setzen, abspielen solange sichtbar, sonst pausieren (Instrument)
  • Scrubbing: Die waagerechte Mausposition steuert currentTime
  • Video im mitlaufenden Vorschaufenster einer Projektliste (siehe listen-hover-bildvorschau)
  • Nur Poster-Bild, Video erst nach Klick
  • Video nur in der aktiven Folie eines Scroll-Snap-Karussells: Nachbarn zeigen das Standbild, stumm, Pause-Knopf, Fremdplayer (YouTube, Vimeo) erst nach dem zweiten Klick

Umsetzung

Markup: <a class=karte href=…><img src=poster.jpg alt=…><video muted loop playsinline preload=none aria-hidden=true data-src=clip.mp4></video></a>. Das Poster ist das erste Bild des Clips, dann springt beim Ausblenden nichts. Hover nur bei matchMedia('(hover: hover) and (pointer: fine)'): bei mouseenter video.currentTime = 0 und video.play().catch(() => {}), bei mouseleave video.pause(). Standbild per opacity ausblenden (400 ms, 200 ms Verzögerung, damit das erste Videobild schon da ist), beide Medien in 1200 ms expo.out auf scale 1.05. Viewport: IntersectionObserver mit threshold 0.25 bis 0.5. Beim ersten Eintritt src aus data-src setzen, dann play(), beim Verlassen pause(). Scrubbing: auf pointermove currentTime setzen, während seeking nur den letzten Wert merken und bei seeked nachziehen; dafür Clips mit kurzen Keyframe-Abständen kodieren, und der Server muss Range-Requests beantworten. Fallstricke: play() liefert ein Promise, Fehler abfangen. Autoplay nur mit muted und playsinline. Clips unter etwa 1 bis 2 MB halten, 720p reicht für Kacheln. Nur wenige Videos gleichzeitig dekodieren.

Robust umsetzen

Telefon. Ohne Hover entweder nur das Standbild zeigen (Cuberto blendet das Video ohne feinen Zeiger aus) oder die sichtbare Karte per Viewport-Autoplay abspielen. Den Datensparmodus beachten (navigator.connection.saveData) und dann nichts automatisch starten.

Ohne Bewegung. Kein Autoplay und kein Zoom. Das Standbild bleibt stehen, abgespielt wird nur auf ausdrücklichen Klick.

Barrierefrei. Rein dekorative Clips bekommen aria-hidden und keinen Ton. Automatisch startende Videos über 5 s brauchen eine Pause-Möglichkeit (WCAG 2.2.2), also in der Viewport-Variante einen Play/Pause-Knopf je Clip wie bei Instrument. Linkziel und Kartentext funktionieren unabhängig vom Video. Bei Tastaturfokus verhält sich die Karte wie beim Hover.

Code

<!DOCTYPE html>
<!--
  Video-Vorschau bei Hover / im Viewport
  Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/video-vorschau-hover-viewport/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Video-Vorschau bei Hover / im Viewport · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  --grund: #0f0f0e; --text: #f1efe9; --leise: rgba(241, 239, 233, .56); --linie: rgba(241, 239, 233, .14);
  --akzent: #e8c27a;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);   /* expo.out: Medien-Zoom 1200 ms (Cuberto, gemessen) */
  --zoom: 1.05;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(20px, 3.4vw, 48px); display: flex; flex-direction: column; justify-content: center; max-width: 1520px; margin: 0 auto; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }

/* Kopf mit Varianten-Umschalter */
.kopf { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 24px; padding-bottom: 14px; border-bottom: 1px solid var(--linie); }
.umschalter { display: flex; gap: 2px; }
.umschalter button { padding: 6px 10px; border-radius: 99px; transition: color .15s linear, background-color .15s linear; }
.umschalter button:hover { color: var(--text); }
.umschalter button[aria-pressed="true"] { color: var(--grund); background: var(--text); }
.umschalter button:focus-visible, .knopf:focus-visible { outline: 1px solid var(--akzent); outline-offset: 2px; }
.touchhinweis, .stillhinweis { display: none; }

.intro { display: grid; grid-template-columns: 1fr minmax(0, 34ch); align-items: end; gap: 12px 40px; margin: clamp(22px, 3.2vw, 40px) 0 clamp(20px, 2.6vw, 32px); }
.intro h1 { font-size: clamp(38px, 5.4vw, 80px); font-weight: 500; letter-spacing: -.045em; line-height: .95; }
.intro h1 em { font-family: 'Instrument Serif', serif; font-style: italic; font-weight: 400; letter-spacing: -.02em; color: var(--akzent); }
.intro p { font-size: 15px; line-height: 1.5; color: var(--leise); }

/* Raster */
.raster { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(10px, 1.3vw, 20px); }
.karte { position: relative; }
.karte > a { display: block; color: inherit; text-decoration: none; outline: none; }
.medium { position: relative; aspect-ratio: 4 / 5; border-radius: 4px; overflow: hidden; background: #1b1b1a; isolation: isolate; }
.medium img, .medium video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* Messwerte Cuberto: Standbild blendet 400 ms (ease) mit 200 ms Verzögerung aus,
   Video blendet 400 ms ein, beide skalieren in 1200 ms expo.out auf 1.05 */
.medium video { opacity: 0; transition: opacity .4s ease, transform 1.2s var(--kurve); }
.medium img { z-index: 1; transition: opacity .4s ease, transform 1.2s var(--kurve); }
.karte.an .medium video { opacity: 1; transform: scale(var(--zoom)); }
.karte.an .medium img { opacity: 0; transform: scale(var(--zoom)); transition-delay: .2s, 0s; }
/* Nachbarn treten zurück, solange ein Clip unter dem Zeiger läuft (Hover, Scrubbing) */
.raster.aktiv .karte:not(.an) .medium { filter: brightness(.5) saturate(.6); }
.raster.aktiv .karte:not(.an) .unter { opacity: .45; }
.medium { transition: filter .4s ease; }
.unter { transition: opacity .4s ease; }
.karte > a:focus-visible .medium { outline: 1px solid var(--akzent); outline-offset: 3px; }

.zeit { position: absolute; z-index: 2; left: 10px; top: 10px; display: flex; align-items: center; gap: 7px; padding: 6px 9px 5px; border-radius: 99px; background: rgba(15, 15, 14, .52); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); color: var(--text); font-size: 10px; letter-spacing: .1em; font-variant-numeric: tabular-nums; }
.zeit i { width: 6px; height: 6px; border-radius: 50%; background: var(--leise); transition: background-color .15s linear; }
.karte.laeuft .zeit i { background: var(--akzent); }
.balken { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: 2px; background: var(--akzent); transform-origin: 0 50%; transform: scaleX(0); opacity: 0; transition: opacity .15s linear; }
.karte.an .balken { opacity: 1; }

.knopf { position: absolute; z-index: 3; right: 10px; top: 10px; width: 32px; height: 32px; border-radius: 50%; display: none; place-items: center; background: rgba(15, 15, 14, .52); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.knopf svg { width: 12px; height: 12px; fill: var(--text); }
.knopf .ii { display: none; }
.karte.laeuft .knopf .ii { display: block; }
.karte.laeuft .knopf .play { display: none; }
body[data-modus="viewport"] .knopf, .mitknopf .knopf { display: grid; }

.unter { display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 4px 12px; padding-top: 12px; }
.unter h2 { font-size: clamp(16px, 1.35vw, 19px); font-weight: 500; letter-spacing: -.02em; }
.unter .art { grid-column: 2 / 4; grid-row: 2; font-size: 14px; color: var(--leise); }

.werte { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: clamp(20px, 2.6vw, 32px); padding-top: 14px; border-top: 1px solid var(--linie); }
.werte b { color: var(--text); font-weight: 500; }

/* Vorführung: gedachter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .6)); }
.vorfuehrung .zeiger { display: block; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; padding: 28px 32px; }
.vorschau .intro p, .vorschau .umschalter { display: none; }
.vorschau .intro { margin: 22px 0 22px; grid-template-columns: 1fr; }
.vorschau .intro h1 { font-size: 56px; }

@media (max-width: 900px) { .raster { grid-template-columns: repeat(2, minmax(0, 1fr)); } .intro { grid-template-columns: 1fr; } }
@media (max-width: 560px) {
  .raster { grid-template-columns: 1fr; gap: 28px; }
  .intro h1 { font-size: 42px; }
  .kopf .lang { display: none; }
}
/* Touch: kein Hover. Der sichtbare Clip spielt (Viewport-Variante), mit Pause-Knopf. */
@media (hover: none), (pointer: coarse) {
  .umschalter { display: none; }
  .touchhinweis { display: block; }
  .knopf { display: grid; width: 40px; height: 40px; }
}
/* Reduzierte Bewegung: kein Autoplay, kein Zoom; Abspielen nur per Knopf, Standbild blendet kurz über */
.reduziert .knopf { display: grid; }
.reduziert .umschalter, .reduziert .touchhinweis { display: none; }
.reduziert .stillhinweis { display: block; }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.reduziert .medium img, .reduziert .medium video { transition: opacity .15s linear; transform: none !important; transition-delay: 0s !important; }

/* =========================================================================
   Varianten (Regel 13): ?v=basis (Raster, Hover/Viewport/Scrubbing) | karussell-folie (Video nur in der aktiven Folie)
   ========================================================================= */
html[data-v="karussell-folie"] [data-welt="basis"], html:not([data-v="karussell-folie"]) [data-welt="karussell-folie"] { display: none !important; }
.vpult { position: fixed; z-index: 12; right: 16px; bottom: 16px; display: flex; gap: 3px; padding: 4px; border-radius: 99px; background: rgba(15, 15, 14, .86); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border: 1px solid rgba(241, 239, 233, .16); }
.vpult button { height: 28px; padding: 0 12px; border-radius: 99px; color: rgba(241, 239, 233, .74); transition: color .15s linear, background-color .15s linear; }
.vpult button:hover { color: #f1efe9; }
.vpult button[aria-pressed="true"] { background: #f1efe9; color: #0f0f0e; }
.vpult button:focus-visible { outline: 1px solid var(--akzent); outline-offset: 2px; }

/* karussell-folie · Rezept handwerk-werkstatt-klartext: Zement, Holz, Salbei; Special Gothic + Atkinson Hyperlegible Next; token-tech-praezise */
html[data-v="karussell-folie"] { --grund: #EFEDEA; background: #EFEDEA; color: #1A1A1A; }
html[data-v="karussell-folie"] body { display: block; max-width: none; padding: 0; }
.kf { --k: cubic-bezier(0.2, 0, 0.38, 0.9); --zement: #D3CDC2; --holz: #C3B4A5; --salbei: #BDC1A9; --text: #1A1A1A; --leise: #5f5e5c; --linie: #CDCBC9;
  --folie: min(60vw, 820px); --rand: clamp(16px, 4vw, 48px);
  font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; color: var(--text); padding: 0 0 clamp(40px, 6vw, 72px); }
.kf-label { font: 600 .8125rem/1.3 'Special Gothic', system-ui, sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; }
.kf-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; padding: 18px var(--rand); border-bottom: 1px solid var(--linie); }
.kf-marke { font: 700 1.375rem/1 'Special Gothic', system-ui, sans-serif; letter-spacing: -.01em; color: inherit; text-decoration: none; }
.kf-marke span { font-weight: 400; color: var(--leise); }
.kf-anruf { display: inline-flex; align-items: center; height: 44px; padding: 0 18px; border-radius: 4px; background: #FB6100; color: #1A1A1A; font-weight: 600; text-decoration: none; }
.kf-kopf nav { display: flex; gap: 22px; }
.kf-kopf nav a { color: inherit; text-decoration: none; }
.kf-oben { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 16px 32px; padding: clamp(28px, 4vw, 52px) var(--rand) clamp(18px, 2.4vw, 28px); }
.kf-oben h2 { font: 700 clamp(1.625rem, 1.2rem + 1.6vw, 2.25rem)/1.05 'Special Gothic', system-ui, sans-serif; letter-spacing: -.01em; max-width: 22ch; }
.kf-oben p { margin-top: 8px; font-size: 1.0625rem; line-height: 1.55; color: var(--leise); max-width: 52ch; }
.kf-steuer { display: flex; align-items: center; gap: 8px; }
.kf-zahl { margin-right: 10px; font-variant-numeric: tabular-nums; }
.kf-zahl b { font-weight: 700; }
.kf-knopf { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--text); border-radius: 4px; color: var(--text); transition: background-color 150ms var(--k), color 150ms var(--k); }
.kf-knopf:hover { background: var(--text); color: #EFEDEA; }
.kf-knopf svg { width: 16px; height: 16px; }
.kf-pause .ab, .kf-pause[aria-pressed="true"] .an { display: none; }
.kf-pause[aria-pressed="true"] .ab { display: block; }
.kf-spur { display: grid; grid-auto-flow: column; grid-auto-columns: var(--folie); gap: clamp(12px, 1.6vw, 24px); overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; scroll-padding-inline: var(--rand); padding: 0 max(var(--rand), calc(100% - var(--folie) - var(--rand))) 6px var(--rand); list-style: none; margin: 0; scrollbar-width: none; outline: none; }
.kf-spur::-webkit-scrollbar { display: none; }
.kf-spur:focus-visible { box-shadow: inset 0 0 0 2px var(--text); }
.kf-folie { scroll-snap-align: start; scroll-snap-stop: always; }
.kf-medium { position: relative; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; background: var(--zement); }
.kf-medium img, .kf-medium video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.kf-medium img { transition: filter 400ms var(--k); }
.kf-medium video { opacity: 0; transition: opacity 400ms var(--k); }
.kf-folie.laeuft video { opacity: 1; }
/* Nachbarn: nur das Bild tritt zurück, der Text bleibt voll lesbar */
.kf-folie:not(.aktiv) .kf-medium img { filter: brightness(.62) saturate(.7); }
.kf-chip { position: absolute; z-index: 2; left: 12px; top: 12px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 4px; background: rgba(26, 26, 26, .78); color: #EFEDEA; font-size: .75rem; font-variant-numeric: tabular-nums; }
.kf-chip i { width: 6px; height: 6px; border-radius: 50%; background: #9a9893; }
.kf-folie.laeuft .kf-chip i { background: #FB6100; }
.kf-folie:not(.aktiv) .kf-chip { visibility: hidden; }
.kf-balken { position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: 3px; background: #FB6100; transform-origin: 0 50%; transform: scaleX(0); }
.kf-folie:not(.aktiv) .kf-balken { visibility: hidden; }
.kf-unter { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 6px 20px; padding-top: 14px; }
.kf-unter h3 { font: 700 1.25rem/1.15 'Special Gothic', system-ui, sans-serif; letter-spacing: -.005em; }
.kf-unter p { grid-column: 1; font-size: 1rem; line-height: 1.5; color: var(--leise); }
.kf-film { grid-column: 2; grid-row: 1 / span 2; display: inline-flex; align-items: center; gap: 10px; height: 44px; padding: 0 16px; border: 1px solid var(--text); border-radius: 4px; font-weight: 600; font-size: .9375rem; white-space: nowrap; transition: background-color 150ms var(--k), color 150ms var(--k); }
.kf-film:hover { background: var(--text); color: #EFEDEA; }
.kf-film small { font-weight: 400; font-variant-numeric: tabular-nums; }
/* Zwei-Klick-Hinweis: der Fremdplayer würde erst nach „Film laden“ eingebunden */
.kf-zweiklick { position: absolute; z-index: 3; inset: 0; display: grid; align-content: center; justify-items: start; gap: 12px; padding: clamp(18px, 3vw, 40px); background: rgba(26, 26, 26, .9); color: #EFEDEA; }
.kf-zweiklick[hidden] { display: none; }
.kf-zweiklick p { max-width: 46ch; font-size: 1rem; line-height: 1.5; }
.kf-zweiklick .reihe { display: flex; flex-wrap: wrap; gap: 8px; }
.kf-zweiklick button { height: 44px; padding: 0 16px; border-radius: 4px; border: 1px solid #EFEDEA; color: #EFEDEA; font-weight: 600; }
.kf-zweiklick button.voll { background: #EFEDEA; color: #1A1A1A; }
.kf :is(button, a):focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
.kf-zweiklick :is(button):focus-visible { outline-color: #EFEDEA; }
.vorschau .kf-kopf nav, .vorschau .kf-oben p, .vorschau .kf-anruf { display: none; }
.vorschau .kf-oben { padding-top: 24px; }
.vorschau .kf { --folie: min(74vw, 860px); }
@media (max-width: 900px) { .kf-kopf nav { display: none; } .kf { --folie: 78vw; } }
@media (max-width: 640px) {
  .kf { --folie: 84vw; }
  .kf-oben { grid-template-columns: minmax(0, 1fr); }
  .kf-medium { aspect-ratio: 4 / 5; }
  .kf-unter { grid-template-columns: minmax(0, 1fr); }
  .kf-film { grid-column: 1; grid-row: auto; justify-self: start; }
  .kf-anruf { height: 40px; padding: 0 14px; }
  .vpult { right: 10px; bottom: 10px; }
}
@media (max-width: 360px) { .kf-zahl { margin-right: auto; } .kf-steuer { width: 100%; } }
@media (forced-colors: active) { button, [role=button] { border: 1px solid ButtonText !important; } }
.reduziert .kf-medium img, .reduziert .kf-medium video { transition: opacity 150ms linear; }
html[data-v="karussell-folie"] .zeiger { display: none !important; }
</style>
</head>
<body data-modus="hover">
<header class="kopf" data-welt="basis">
  <p class="mono">Studio Halbton<span class="lang"> · Bewegtbild für Marken</span> · Köln</p>
  <div class="umschalter" role="group" aria-label="Variante" data-umschalter>
    <button class="mono" data-modus="hover" aria-pressed="true">Hover</button>
    <button class="mono" data-modus="viewport" aria-pressed="false">Viewport</button>
    <button class="mono" data-modus="scrub" aria-pressed="false">Scrubbing</button>
  </div>
  <p class="mono touchhinweis">Touch · spielt im Viewport</p>
  <p class="mono stillhinweis">Reduzierte Bewegung · nur per Knopf</p>
</header>

<section class="intro" data-welt="basis">
  <h1>Marken, die sich <em>bewegen.</em></h1>
  <p data-intro>Vier Kurzfilme aus dem Archiv. Fahren Sie über eine Karte: Der Clip startet an derselben Stelle von vorn und hält an, sobald Sie weiterziehen.</p>
</section>

<ul class="raster" data-raster data-welt="basis">
  <li class="karte">
    <a href="#ocker">
      <div class="medium">
        <img src="bilder/flakon-poster.jpg" alt="Ocker 04, bernsteinfarbener Flakon auf weißem Podest vor Kobaltblau">
        <video data-src="bilder/flakon.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="zeit mono" aria-hidden="true"><i></i><span data-zeit>00:00 / 00:04</span></span>
        <span class="balken" aria-hidden="true"></span>
      </div>
      <div class="unter"><span class="mono">01</span><h2>Ocker 04</h2><span class="mono">2025</span><p class="art">Packshot · Kamera umkreist den Flakon</p></div>
    </a>
    <button class="knopf" type="button" aria-label="Clip abspielen"><svg class="play" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 1.2v9.6L10.6 6Z"/></svg><svg class="ii" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.4 1.2h2.6v9.6H2.4zM7 1.2h2.6v9.6H7z"/></svg></button>
  </li>
  <li class="karte">
    <a href="#gefuege">
      <div class="medium">
        <img src="bilder/magazin-doppelseite-poster.jpg" alt="Gefüge, aufgeschlagenes Magazin mit Keramik-Stillleben und der Überschrift Die Ruhe der Dinge">
        <video data-src="bilder/magazin-doppelseite.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="zeit mono" aria-hidden="true"><i></i><span data-zeit>00:00 / 00:04</span></span>
        <span class="balken" aria-hidden="true"></span>
      </div>
      <div class="unter"><span class="mono">02</span><h2>Gefüge</h2><span class="mono">2024</span><p class="art">Editorial · Kamerafahrt über die Doppelseite</p></div>
    </a>
    <button class="knopf" type="button" aria-label="Clip abspielen"><svg class="play" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 1.2v9.6L10.6 6Z"/></svg><svg class="ii" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.4 1.2h2.6v9.6H2.4zM7 1.2h2.6v9.6H7z"/></svg></button>
  </li>
  <li class="karte">
    <a href="#salz-und-schale">
      <div class="medium">
        <img src="bilder/zitronen-tisch-poster.jpg" alt="Salz &amp; Schale, Zitronen und ein heller Teller auf dunklem Tisch">
        <video data-src="bilder/zitronen-tisch.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="zeit mono" aria-hidden="true"><i></i><span data-zeit>00:00 / 00:04</span></span>
        <span class="balken" aria-hidden="true"></span>
      </div>
      <div class="unter"><span class="mono">03</span><h2>Salz &amp; Schale</h2><span class="mono">2026</span><p class="art">Kochbuch-Trailer · Fahrt über den Tisch</p></div>
    </a>
    <button class="knopf" type="button" aria-label="Clip abspielen"><svg class="play" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 1.2v9.6L10.6 6Z"/></svg><svg class="ii" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.4 1.2h2.6v9.6H2.4zM7 1.2h2.6v9.6H7z"/></svg></button>
  </li>
  <li class="karte">
    <a href="#scheibenwerk">
      <div class="medium">
        <img src="bilder/keramik-stillleben-poster.jpg" alt="Scheibenwerk, Vasen, Becher und Schalen auf Sockeln vor Terrakotta">
        <video data-src="bilder/keramik-stillleben.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="zeit mono" aria-hidden="true"><i></i><span data-zeit>00:00 / 00:04</span></span>
        <span class="balken" aria-hidden="true"></span>
      </div>
      <div class="unter"><span class="mono">04</span><h2>Scheibenwerk</h2><span class="mono">2023</span><p class="art">Keramikwerkstatt · ruhiger Schwenk</p></div>
    </a>
    <button class="knopf" type="button" aria-label="Clip abspielen"><svg class="play" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 1.2v9.6L10.6 6Z"/></svg><svg class="ii" viewBox="0 0 12 12" aria-hidden="true"><path d="M2.4 1.2h2.6v9.6H2.4zM7 1.2h2.6v9.6H7z"/></svg></button>
  </li>
</ul>

<footer class="werte mono" data-werte data-welt="basis"></footer>

<!-- ===== Variante karussell-folie: Scroll-Snap-Karussell, Video nur in der aktiven Folie (Splide Video-Extension als Vorbild) ===== -->
<section class="kf" data-welt="karussell-folie" aria-labelledby="kf-titel">
  <header class="kf-kopf">
    <a class="kf-marke" href="#start">Werkhaus <span>am Ryck</span></a>
    <nav class="kf-label"><a href="#werkstaetten">Werkstätten</a><a href="#kurse">Kurse</a><a href="#laden">Laden</a><a href="#anfahrt">Anfahrt</a></nav>
    <a class="kf-anruf" href="tel:+49383400000">03834 00 00 00</a>
  </header>
  <div class="kf-oben">
    <div>
      <h2 id="kf-titel">Fünf Werkstätten, fünf kurze Filme.</h2>
      <p>Töpferei, Glas, Buchbinderei, Hofküche: alles unter einem Dach in Greifswald.</p>
    </div>
    <div class="kf-steuer">
      <p class="kf-zahl kf-label" aria-hidden="true"><b data-kf-nr>01</b> / 05</p>
      <button type="button" class="kf-knopf kf-pause" data-kf-pause aria-pressed="false" aria-label="Bewegung anhalten"><svg class="an" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 3v10M11 3v10" stroke="currentColor" stroke-width="1.8"/></svg><svg class="ab" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 2.5v11L13 8z" fill="currentColor"/></svg></button>
      <button type="button" class="kf-knopf" data-kf-schritt="-1" aria-label="Vorherige Folie"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
      <button type="button" class="kf-knopf" data-kf-schritt="1" aria-label="Nächste Folie"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
    </div>
  </div>
  <ul class="kf-spur" data-kf-spur role="region" aria-roledescription="Karussell" aria-label="Werkstattfilme, mit Pfeiltasten blättern" tabindex="0">
    <li class="kf-folie aktiv" data-kf-folie role="group" aria-roledescription="Folie" aria-label="1 von 5: Töpferei">
      <div class="kf-medium">
        <img src="bilder/keramik-stillleben-poster.jpg" alt="Becher, Vasen und Schalen aus der Werkstatt, auf Sockeln vor Terrakotta" width="1280" height="720" decoding="async">
        <video data-src="bilder/keramik-stillleben.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="kf-chip" aria-hidden="true"><i></i><span data-kf-zeit>00:00 / 00:05</span> · ohne Ton</span>
        <span class="kf-balken" aria-hidden="true"></span>
        <div class="kf-zweiklick" data-kf-zweiklick hidden>
          <p data-kf-zweiklick-text>Der ganze Film (3:40 Min., Untertitel Deutsch und Englisch) liegt bei Vimeo. Erst wenn Sie „Film laden“ wählen, werden Daten an Vimeo übertragen.</p>
          <div class="reihe"><button type="button" class="voll" data-kf-laden>Film laden</button><button type="button" data-kf-abbrechen>Abbrechen</button></div>
        </div>
      </div>
      <div class="kf-unter">
        <h3>Töpferei</h3>
        <p>Keramik · Drehscheibe, Engobe, Holzbrand</p>
        <button type="button" class="kf-film" data-kf-film aria-expanded="false">Ganzen Film ansehen <small>3:40</small></button>
      </div>
    </li>
    <li class="kf-folie" data-kf-folie role="group" aria-roledescription="Folie" aria-label="2 von 5: Glaswerkstatt">
      <div class="kf-medium">
        <img src="bilder/flakon-poster.jpg" alt="Bernsteinfarbener Glasflakon auf einem Steinsockel vor Kobaltblau" width="1280" height="720" decoding="async">
        <video data-src="bilder/flakon.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="kf-chip" aria-hidden="true"><i></i><span data-kf-zeit>00:00 / 00:05</span> · ohne Ton</span>
        <span class="kf-balken" aria-hidden="true"></span>
        <div class="kf-zweiklick" data-kf-zweiklick hidden>
          <p data-kf-zweiklick-text>Der ganze Film (2:55 Min., Untertitel Deutsch und Englisch) liegt bei Vimeo. Erst wenn Sie „Film laden“ wählen, werden Daten an Vimeo übertragen.</p>
          <div class="reihe"><button type="button" class="voll" data-kf-laden>Film laden</button><button type="button" data-kf-abbrechen>Abbrechen</button></div>
        </div>
      </div>
      <div class="kf-unter">
        <h3>Glaswerkstatt</h3>
        <p>Glas · Bernsteinglas, vor der Lampe geblasen</p>
        <button type="button" class="kf-film" data-kf-film aria-expanded="false">Ganzen Film ansehen <small>2:55</small></button>
      </div>
    </li>
    <li class="kf-folie" data-kf-folie role="group" aria-roledescription="Folie" aria-label="3 von 5: Buchbinderei">
      <div class="kf-medium">
        <img src="bilder/magazin-doppelseite-poster.jpg" alt="Aufgeschlagenes, fadengeheftetes Heft mit Keramik-Stillleben auf der linken Seite" width="1280" height="720" loading="lazy" decoding="async">
        <video data-src="bilder/magazin-doppelseite.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="kf-chip" aria-hidden="true"><i></i><span data-kf-zeit>00:00 / 00:05</span> · ohne Ton</span>
        <span class="kf-balken" aria-hidden="true"></span>
        <div class="kf-zweiklick" data-kf-zweiklick hidden>
          <p data-kf-zweiklick-text>Der ganze Film (4:10 Min., Untertitel Deutsch und Englisch) liegt bei Vimeo. Erst wenn Sie „Film laden“ wählen, werden Daten an Vimeo übertragen.</p>
          <div class="reihe"><button type="button" class="voll" data-kf-laden>Film laden</button><button type="button" data-kf-abbrechen>Abbrechen</button></div>
        </div>
      </div>
      <div class="kf-unter">
        <h3>Buchbinderei</h3>
        <p>Papier · Fadenheftung, Offsetdruck aus dem Haus</p>
        <button type="button" class="kf-film" data-kf-film aria-expanded="false">Ganzen Film ansehen <small>4:10</small></button>
      </div>
    </li>
    <li class="kf-folie" data-kf-folie role="group" aria-roledescription="Folie" aria-label="4 von 5: Hofküche">
      <div class="kf-medium">
        <img src="bilder/zitronen-tisch-poster.jpg" alt="Zitronen und ein heller Teller auf einem dunklen Holztisch" width="1280" height="720" loading="lazy" decoding="async">
        <video data-src="bilder/zitronen-tisch.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="kf-chip" aria-hidden="true"><i></i><span data-kf-zeit>00:00 / 00:05</span> · ohne Ton</span>
        <span class="kf-balken" aria-hidden="true"></span>
        <div class="kf-zweiklick" data-kf-zweiklick hidden>
          <p data-kf-zweiklick-text>Der ganze Film (3:05 Min., Untertitel Deutsch und Englisch) liegt bei Vimeo. Erst wenn Sie „Film laden“ wählen, werden Daten an Vimeo übertragen.</p>
          <div class="reihe"><button type="button" class="voll" data-kf-laden>Film laden</button><button type="button" data-kf-abbrechen>Abbrechen</button></div>
        </div>
      </div>
      <div class="kf-unter">
        <h3>Hofküche</h3>
        <p>Küche · Einkochen und Fermentieren, Kurse für acht Personen</p>
        <button type="button" class="kf-film" data-kf-film aria-expanded="false">Ganzen Film ansehen <small>3:05</small></button>
      </div>
    </li>
    <li class="kf-folie" data-kf-folie role="group" aria-roledescription="Folie" aria-label="5 von 5: Der Ort">
      <div class="kf-medium">
        <img src="bilder/eigener-videoplayer-poster.jpg" alt="Abendlicht über ruhigem Wasser, am Horizont eine flache Küste" width="1280" height="720" loading="lazy" decoding="async">
        <video data-src="bilder/eigener-videoplayer-am-sund.mp4" muted loop playsinline preload="none" aria-hidden="true"></video>
        <span class="kf-chip" aria-hidden="true"><i></i><span data-kf-zeit>00:00 / 00:16</span> · ohne Ton</span>
        <span class="kf-balken" aria-hidden="true"></span>
        <div class="kf-zweiklick" data-kf-zweiklick hidden>
          <p data-kf-zweiklick-text>Der ganze Film (2:20 Min., Untertitel Deutsch und Englisch) liegt bei Vimeo. Erst wenn Sie „Film laden“ wählen, werden Daten an Vimeo übertragen.</p>
          <div class="reihe"><button type="button" class="voll" data-kf-laden>Film laden</button><button type="button" data-kf-abbrechen>Abbrechen</button></div>
        </div>
      </div>
      <div class="kf-unter">
        <h3>Der Ort</h3>
        <p>Haus · Ein alter Speicher am Wasser, fünf Werkstätten unter einem Dach</p>
        <button type="button" class="kf-film" data-kf-film aria-expanded="false">Ganzen Film ansehen <small>2:20</small></button>
      </div>
    </li>
  </ul>
  <p class="unsichtbar" data-kf-live aria-live="polite" aria-atomic="true"></p>
</section>
<div class="vpult" role="group" aria-label="Variante">
  <button type="button" class="mono" data-v-wahl="basis" aria-pressed="true">Raster</button>
  <button type="button" class="mono" data-v-wahl="karussell-folie" aria-pressed="false">Karussell</button>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#f1efe9" stroke="#0f0f0e" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement, body = document.body;
  const reduziert = () => html.classList.contains('reduziert');
  const fein = matchMedia('(hover: hover) and (pointer: fine)');
  const sparen = !!(navigator.connection && navigator.connection.saveData);
  const SCHWELLE = 0.5;   // Anteil sichtbar, ab dem ein Clip im Viewport spielt
  const zeiger = document.querySelector('[data-zeiger]');
  const werte = document.querySelector('[data-werte]');
  const intro = document.querySelector('[data-intro]');
  let modus = 'hover';

  const karten = [...document.querySelectorAll('.karte')].map(el => {
    const video = el.querySelector('video');
    return { el, video, link: el.querySelector('a'), knopf: el.querySelector('.knopf'),
      zeit: el.querySelector('[data-zeit]'), balken: el.querySelector('.balken'),
      dauer: +el.querySelector('[data-zeit]').textContent.slice(-2), sichtbar: false, angehalten: false, ziel: null };
  });

  // Was gerade gilt: ohne feinen Zeiger immer Viewport, bei reduzierter Bewegung nur auf Klick
  const wirksam = () => reduziert() ? 'still' : (!fein.matches ? 'viewport' : modus);

  // Quelle erst setzen, wenn der Clip gebraucht wird (wie Instrument: data-src → src)
  function laden(k, vorab) {
    if (!k.video.getAttribute('src')) { if (vorab) k.video.preload = 'auto'; k.video.src = k.video.dataset.src; }
  }
  function spielen(k, vonVorn) {
    laden(k);
    if (vonVorn) k.video.currentTime = 0;              // Cuberto: Neustart bei 0 je Hover
    const p = k.video.play(); if (p) p.catch(() => {});  // play() liefert ein Promise
  }
  function anhalten(k) { k.video.pause(); }

  for (const k of karten) {
    const v = k.video;
    v.addEventListener('playing', () => { k.el.classList.add('laeuft'); if (wirksam() !== 'hover') k.el.classList.add('an'); ticken(); setzeKnopf(k); });
    v.addEventListener('pause', () => { k.el.classList.remove('laeuft'); setzeKnopf(k); });
    v.addEventListener('loadedmetadata', () => { if (v.duration) k.dauer = v.duration; zeichne(k); });
    v.addEventListener('seeked', () => {
      zeichne(k);
      if (k.ziel != null) { const z = k.ziel; k.ziel = null; if (Math.abs(z - v.currentTime) > 0.02) v.currentTime = z; }
    });
  }

  function fmt(s) { s = Math.max(0, Math.floor(s)); return '00:' + String(s).padStart(2, '0'); }
  function zeichne(k) {
    const t = k.video.currentTime || 0, d = k.dauer || 1;
    k.zeit.textContent = fmt(t) + ' / ' + fmt(d);
    k.balken.style.transform = `scaleX(${Math.min(1, t / d)})`;
  }
  let tickt = false;
  function ticken() {
    if (tickt) return; tickt = true;
    const f = () => {
      let aktiv = false;
      for (const k of karten) { zeichne(k); if (!k.video.paused) aktiv = true; }
      if (aktiv) requestAnimationFrame(f); else tickt = false;
    };
    requestAnimationFrame(f);
  }
  function setzeKnopf(k) { k.knopf.setAttribute('aria-label', k.video.paused ? 'Clip abspielen' : 'Clip anhalten'); }

  // Variante Hover (Cuberto)
  const raster = document.querySelector('[data-raster]');
  function eintreten(k) {
    if (wirksam() === 'hover') { k.el.classList.add('an'); spielen(k, true); }
    else if (wirksam() === 'scrub') { laden(k, true); anhalten(k); k.el.classList.add('an'); }
    else return;
    raster.classList.add('aktiv');
  }
  function verlassen(k) {
    const w = wirksam();
    if (w === 'hover' || w === 'scrub') { anhalten(k); k.el.classList.remove('an'); k.ziel = null; }
    raster.classList.toggle('aktiv', karten.some(x => x !== k && x.el.classList.contains('an')) && (w === 'hover' || w === 'scrub'));
  }
  // Variante Scrubbing: waagerechte Position steuert currentTime
  function scrubben(k, f) {
    if (wirksam() !== 'scrub') return;
    const z = Math.min(0.999, Math.max(0, f)) * (k.dauer || 1);
    if (k.video.readyState < 1 || k.video.seeking) { k.ziel = z; return; }
    k.video.currentTime = z;
  }
  // nach dem ersten Laden der Metadaten ein ausstehendes Ziel anfahren
  karten.forEach(k => k.video.addEventListener('loadedmetadata', () => { if (k.ziel != null) { const z = k.ziel; k.ziel = null; k.video.currentTime = z; } }));

  // Variante Viewport (Instrument): spielen, solange sichtbar; außerhalb pausieren
  function viewportPruefen(k) {
    if (wirksam() !== 'viewport' || sparen) return;
    if (k.sichtbar && !k.angehalten) spielen(k, false); else anhalten(k);
  }
  const io = new IntersectionObserver(eintraege => {
    for (const e of eintraege) {
      const k = karten.find(x => x.el === e.target);
      k.sichtbar = e.isIntersecting && e.intersectionRatio >= SCHWELLE;
      viewportPruefen(k);
    }
  }, { threshold: [0, SCHWELLE, 1] });
  karten.forEach(k => io.observe(k.el));

  // Maus und Tastatur
  const echt = () => { if (html.classList.contains('vorfuehrung')) neustart(); };
  karten.forEach(k => {
    k.link.addEventListener('pointerenter', e => { if (e.pointerType !== 'mouse') return; echt(); eintreten(k); });
    k.link.addEventListener('pointerleave', e => { if (e.pointerType !== 'mouse') return; verlassen(k); });
    k.link.addEventListener('pointermove', e => {
      if (e.pointerType !== 'mouse') return;
      const r = k.link.querySelector('.medium').getBoundingClientRect();
      scrubben(k, (e.clientX - r.left) / r.width);
    });
    k.link.addEventListener('focus', () => { if (k.link.matches(':focus-visible')) eintreten(k); });
    k.link.addEventListener('blur', () => verlassen(k));
    k.link.addEventListener('click', e => e.preventDefault());
    // Play/Pause (WCAG 2.2.2); bei reduzierter Bewegung der einzige Weg zum Abspielen
    k.knopf.addEventListener('click', () => {
      echt();
      if (k.video.paused) { k.angehalten = false; spielen(k, false); }
      else { k.angehalten = true; anhalten(k); }
    });
  });

  // Umschalter und Werte-Anzeige
  const TEXTE = {
    hover: ['Vier Kurzfilme aus dem Archiv. Fahren Sie über eine Karte: Der Clip startet an derselben Stelle von vorn und hält an, sobald Sie weiterziehen.',
      '<span>Zoom <b>1 → 1.05</b> · <b>1200 ms</b> · <b>cubic-bezier(.16, 1, .3, 1)</b></span><span>Standbild aus <b>400 ms ease</b> · Verzögerung <b>200 ms</b></span><span>Video ein <b>400 ms</b></span><span>Neustart bei <b>currentTime 0</b></span>'],
    viewport: ['Jeder Clip lädt erst, wenn seine Karte zur Hälfte im Bild ist, und spielt nur, solange er sichtbar bleibt. Der Knopf hält ihn an.',
      '<span>IntersectionObserver · threshold <b>0.5</b></span><span><b>data-src → src</b> beim ersten Eintritt</span><span>außerhalb <b>pause()</b></span><span>Play/Pause je Clip</span>'],
    scrub: ['Die Maus ist der Schieberegler: Links steht der Anfang des Clips, rechts sein Ende. So lässt sich jede Einstellung genau ansehen.',
      '<span><b>currentTime = x / Breite × Dauer</b></span><span>Clips <b>1280 × 720</b> · <b>25 fps</b>, Ausschnitt 4:5 per <b>object-fit: cover</b></span><span>Zoom <b>1 → 1.05</b> · <b>1200 ms</b> expo.out</span>'],
    still: ['Reduzierte Bewegung: Kein Clip startet von selbst. Der Knopf auf jeder Karte spielt ihn ab.',
      '<span>kein Autoplay</span><span>kein Zoom</span><span>Überblenden <b>150 ms linear</b></span><span>Abspielen per Knopf</span>'],
  };
  function anzeigen() {
    const w = wirksam();
    body.dataset.modus = w;
    intro.textContent = TEXTE[w][0];
    werte.innerHTML = TEXTE[w][1];
    if (sparen && w === 'viewport') karten.forEach(k => k.el.classList.add('mitknopf'));
  }
  function zuruecksetzen() {
    raster.classList.remove('aktiv');
    karten.forEach(k => { anhalten(k); k.el.classList.remove('an', 'laeuft'); k.angehalten = false; k.ziel = null;
      if (k.video.readyState > 0) k.video.currentTime = 0; zeichne(k); });
    anzeigen();
    karten.forEach(viewportPruefen);
  }
  document.querySelectorAll('[data-umschalter] button').forEach(b => b.addEventListener('click', () => {
    modus = b.dataset.modus;
    document.querySelectorAll('[data-umschalter] button').forEach(x => x.setAttribute('aria-pressed', x === b));
    echt(); zuruecksetzen();
  }));
  new MutationObserver(() => { if (wirksam() !== body.dataset.modus) zuruecksetzen(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
  fein.addEventListener('change', zuruecksetzen);

  // Vorführung: ein gedachter Zeiger besucht die Karten nacheinander (Hover, Scrubbing).
  // In der Viewport-Variante spielen die sichtbaren Clips ohnehin; der Zeiger hält Karte 2 kurz an.
  const RUNDE = 3400, WEG = 650, RAUS = 2750;
  let uhr = null, start = 0, versatz = 0, unter = -1, von = null;
  const easeIO = x => x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
  function punkt(i, fx, fy) {
    const r = karten[i].link.querySelector('.medium').getBoundingClientRect();
    return { x: r.left + r.width * fx, y: r.top + r.height * fy };
  }
  function schritt(t) {
    const zeitpunkt = Math.max(0, t - start + versatz);   // rAF-Zeit kann knapp vor performance.now() liegen
    const w = wirksam();
    let x, y;
    if (w === 'still') { uhr = null; return; }
    if (w === 'viewport') {
      // Zeiger fährt zum Knopf von Karte 2, hält den Clip an und spielt ihn wieder ab
      const p = (zeitpunkt % 6000) / 6000, k = karten[1], r = k.knopf.getBoundingClientRect();
      const ruhe = { x: innerWidth * 0.5, y: innerHeight * 0.94 }, ziel = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
      const a = p < .2 ? easeIO(p / .2) : p < .75 ? 1 : 1 - easeIO((p - .75) / .25);
      x = ruhe.x + (ziel.x - ruhe.x) * a; y = ruhe.y + (ziel.y - ruhe.y) * a;
      const soll = p > .25 && p < .6;   // angehalten zwischen 25 % und 60 % der Runde
      if (soll !== k.angehalten) { k.angehalten = soll; viewportPruefen(k); }
    } else {
      const n = karten.length, i = Math.floor(zeitpunkt / RUNDE) % n, q = zeitpunkt % RUNDE;
      const drin = w === 'scrub'
        ? punkt(i, 0.5 - 0.42 * Math.cos(Math.max(0, q - WEG) / (RAUS - WEG) * Math.PI), 0.55)
        : punkt(i, 0.5 + 0.08 * Math.sin(q / 700), 0.52 + 0.05 * Math.cos(q / 900));
      const draussen = punkt(i, 1.06, 1.12);
      if (!von) von = { x: innerWidth * 0.5, y: innerHeight * 0.96 };
      if (q < WEG) { const a = easeIO(q / WEG); x = von.x + (drin.x - von.x) * a; y = von.y + (drin.y - von.y) * a; }
      else if (q < RAUS) { x = drin.x; y = drin.y; }
      else { const a = easeIO((q - RAUS) / (RUNDE - RAUS)); x = drin.x + (draussen.x - drin.x) * a; y = drin.y + (draussen.y - drin.y) * a; if (q > RUNDE - 40) von = draussen; }
      // welche Karte liegt unter dem Zeiger?
      const j = karten.findIndex(k => { const r = k.link.querySelector('.medium').getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; });
      if (j !== unter) { if (unter >= 0) verlassen(karten[unter]); unter = j; if (j >= 0) eintreten(karten[j]); }
      if (j >= 0 && w === 'scrub') { const r = karten[j].link.querySelector('.medium').getBoundingClientRect(); scrubben(karten[j], (x - r.left) / r.width); }
    }
    zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
    uhr = requestAnimationFrame(schritt);
  }
  function vorfuehren() {
    if (welt === 'karussell-folie') return KF.vorfuehren();
    neustart();
    html.classList.add('vorfuehrung');
    start = performance.now(); versatz = 0; von = null; unter = -1;
    uhr = requestAnimationFrame(schritt);
  }
  function pause() {
    if (welt === 'karussell-folie') return KF.pause();
    if (uhr) { cancelAnimationFrame(uhr); uhr = null; versatz += performance.now() - start; }
    karten.forEach(k => { k.warSpielend = !k.video.paused; anhalten(k); });
  }
  function weiter() {
    if (welt === 'karussell-folie') return KF.weiter();
    karten.forEach(k => { if (k.warSpielend) spielen(k, false); k.warSpielend = false; });
    if (!uhr && html.classList.contains('vorfuehrung')) { start = performance.now(); uhr = requestAnimationFrame(schritt); }
  }
  function neustart() {
    if (welt === 'karussell-folie') return KF.neustart();
    if (uhr) cancelAnimationFrame(uhr); uhr = null;
    html.classList.remove('vorfuehrung'); unter = -1;
    zuruecksetzen();
  }
  anzeigen();

  /* ---------- Variante „karussell-folie“: Video spielt nur in der aktiven Folie ----------
     Scroll-Snap (start, mandatory, Nachbar angeschnitten); aktiv ist die Folie, deren linke Kante am Snap-Punkt liegt.
     Erst wenn das Scrollen ruht (scrollend, sonst 150 ms Ruhe), bekommt die aktive Folie ihre Quelle (data-src → src) und spielt stumm ab 0;
     die Nachbarn pausieren und zeigen das Standbild. Fremdplayer (Vimeo) würden erst nach einem zweiten Klick laden. */
  const KF = (() => {
    const sek = document.querySelector('.kf'), spur = sek.querySelector('[data-kf-spur]'), folien = [...sek.querySelectorAll('[data-kf-folie]')];
    const knopf = sek.querySelector('[data-kf-pause]'), nr = sek.querySelector('[data-kf-nr]'), live = sek.querySelector('[data-kf-live]');
    const F = folien.map(el => ({ el, video: el.querySelector('video'), zeit: el.querySelector('[data-kf-zeit]'), balken: el.querySelector('.kf-balken'),
      film: el.querySelector('[data-kf-film]'), zk: el.querySelector('[data-kf-zweiklick]'), zkText: el.querySelector('[data-kf-zweiklick-text]') }));
    const RUHE = 150, TAKT_VORF = 4600, N = F.length;
    let aktiv = 0, ziel = null, angehalten = false, ruheUhr = null, an = false, vorfUhr = null, vorf = false;
    const fmt = s => '00:' + String(Math.max(0, Math.floor(s))).padStart(2, '0');
    function zeichne(f) { const v = f.video, d = v.duration || 5; f.zeit.textContent = `${fmt(v.currentTime || 0)} / ${fmt(d)}`; f.balken.style.transform = `scaleX(${Math.min(1, (v.currentTime || 0) / d)})`; }
    let tickt = false;
    function ticken() { if (tickt) return; tickt = true; const t = () => { const f = F[aktiv]; zeichne(f); if (!f.video.paused && an) requestAnimationFrame(t); else tickt = false; }; requestAnimationFrame(t); }
    F.forEach(f => {
      f.video.addEventListener('playing', () => { f.el.classList.add('laeuft'); ticken(); });
      f.video.addEventListener('pause', () => f.el.classList.remove('laeuft'));
    });
    function welche() {
      const r = spur.getBoundingClientRect(), anfang = r.left + (parseFloat(getComputedStyle(spur).scrollPaddingLeft) || 0);
      let best = 0, d = Infinity;
      F.forEach((f, i) => { const x = Math.abs(f.el.getBoundingClientRect().left - anfang); if (x < d) { d = x; best = i; } });
      return best;
    }
    function markieren(i) {
      aktiv = i;
      F.forEach((f, k) => f.el.classList.toggle('aktiv', k === i));
      nr.textContent = String(i + 1).padStart(2, '0');
    }
    // Videos nur in der aktiven Folie; Quelle erst bei Bedarf
    function videosAbgleichen() {
      F.forEach((f, k) => {
        if (k !== aktiv) { f.video.pause(); f.zk.hidden = true; f.film.setAttribute('aria-expanded', 'false'); return; }
        if (!an || angehalten) { f.video.pause(); return; }
        if (!f.video.getAttribute('src')) f.video.src = f.video.dataset.src;
        f.video.currentTime = 0;
        const p = f.video.play(); if (p) p.catch(() => {});
      });
      zeichne(F[aktiv]);
    }
    function ruhig() {
      clearTimeout(ruheUhr); ruheUhr = null; ziel = null;
      const i = welche();
      if (i !== aktiv) { markieren(i); live.textContent = folien[i].getAttribute('aria-label'); }
      videosAbgleichen();
    }
    spur.addEventListener('scroll', () => {
      const i = welche(); if (i !== aktiv) markieren(i);   // Markierung sofort, Video erst in Ruhe
      clearTimeout(ruheUhr); ruheUhr = setTimeout(ruhig, RUHE);
    }, { passive: true });
    if ('onscrollend' in window) spur.addEventListener('scrollend', ruhig);
    function gehe(i, nutzer = true) {
      i = Math.max(0, Math.min(N - 1, i)); ziel = i;
      const f = folien[i], r = spur.getBoundingClientRect(), b = f.getBoundingClientRect();
      spur.scrollTo({ left: spur.scrollLeft + b.left - (r.left + (parseFloat(getComputedStyle(spur).scrollPaddingLeft) || 0)), behavior: reduziert() ? 'auto' : 'smooth' });   // scrollTo statt scrollBy: scrollBy gilt als Richtungsscroll und hält mit scroll-snap-stop an jeder Folie
      if (nutzer) stoppVorf();
    }
    sek.querySelectorAll('[data-kf-schritt]').forEach(b => b.addEventListener('click', () => gehe(((ziel ?? aktiv) + +b.dataset.kfSchritt + N) % N)));   // an den Enden weiter zum anderen Ende
    spur.addEventListener('keydown', e => {
      const d = { ArrowRight: 1, ArrowLeft: -1, Home: -99, End: 99 }[e.key];
      if (!d || e.target !== spur) return;
      e.preventDefault(); gehe(Math.abs(d) > 1 ? (d > 0 ? N - 1 : 0) : (ziel ?? aktiv) + d);
    });
    // Klick auf eine Nachbarfolie holt sie in die Mitte
    folien.forEach((el, i) => el.addEventListener('click', e => { if (i !== aktiv && !e.target.closest('button')) gehe(i); }));
    // Fokus in einer Nachbarfolie: sie wird aktiv (Tastatur)
    folien.forEach((el, i) => el.addEventListener('focusin', () => { if (i !== aktiv) gehe(i); }));
    function setzeAngehalten(a) { angehalten = a; knopf.setAttribute('aria-pressed', String(a)); videosAbgleichen(); }
    knopf.addEventListener('click', () => { stoppVorf(); setzeAngehalten(!angehalten); });
    // Ganzer Film: zuerst ein Hinweis, der Fremdplayer würde erst nach „Film laden“ eingebunden (keine echte Einbettung in der Demo)
    F.forEach(f => {
      f.film.addEventListener('click', () => {
        stoppVorf();
        const auf = f.zk.hidden; f.zk.hidden = !auf; f.film.setAttribute('aria-expanded', String(auf));
        if (auf) { f.video.pause(); f.zk.querySelector('[data-kf-laden]').focus(); }
        else videosAbgleichen();
      });
      f.zk.querySelector('[data-kf-abbrechen]').addEventListener('click', () => { f.zk.hidden = true; f.film.setAttribute('aria-expanded', 'false'); f.film.focus(); videosAbgleichen(); });
      f.zk.querySelector('[data-kf-laden]').addEventListener('click', () => {
        f.zkText.textContent = 'Demo: Hier würde jetzt der Vimeo-Player mit Untertiteln laden. Auf der echten Seite erst nach diesem Klick, vorher fließen keine Daten.';
      });
    });
    function stoppVorf() { if (!vorf) return; vorf = false; clearTimeout(vorfUhr); html.classList.remove('vorfuehrung'); }
    function takt() { if (!vorf) return; gehe(aktiv >= N - 1 ? 0 : aktiv + 1, false); vorfUhr = setTimeout(takt, TAKT_VORF); }
    return {
      start() {
        an = true; angehalten = reduziert() || sparen;
        knopf.setAttribute('aria-pressed', String(angehalten));
        spur.scrollLeft = 0; markieren(0); videosAbgleichen();
      },
      stopp() { an = false; stoppVorf(); F.forEach(f => { f.video.pause(); f.zk.hidden = true; }); },
      neustart() { stoppVorf(); this.stopp(); this.start(); },
      vorfuehren() { this.stopp(); this.start(); if (!reduziert()) setzeAngehalten(false); vorf = true; html.classList.add('vorfuehrung'); vorfUhr = setTimeout(takt, TAKT_VORF); },
      pause() { clearTimeout(vorfUhr); vorf = false; F.forEach(f => f.video.pause()); },
      weiter() { if (html.classList.contains('vorfuehrung')) { vorf = true; videosAbgleichen(); vorfUhr = setTimeout(takt, TAKT_VORF); } },
    };
  })();

  // Varianten nach Regel 13: ?v=basis | karussell-folie
  const WELTEN = ['basis', 'karussell-folie'];
  let welt = 'basis';
  function setzeWelt(v, url = true) {
    if (welt === 'karussell-folie') KF.stopp(); else { if (uhr) cancelAnimationFrame(uhr); uhr = null; html.classList.remove('vorfuehrung'); karten.forEach(anhalten); }
    welt = v; html.dataset.v = v;
    document.querySelectorAll('[data-v-wahl]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.vWahl === v)));
    if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); scrollTo(0, 0); }
    if (v === 'karussell-folie') KF.start(); else zuruecksetzen();
  }
  document.querySelectorAll('[data-v-wahl]').forEach(b => b.addEventListener('click', () => setzeWelt(b.dataset.vWahl)));
  const ausUrl = new URLSearchParams(location.search).get('v');
  setzeWelt(WELTEN.includes(ausUrl) ? ausUrl : WELTEN[0], false);

})();
</script>
</body>
</html>