This commit is contained in:
team3 committed 2026-09-16 17:00:35 +02:00
1 parent bfaf0313c1
commit cb78225e86
29 files changed
+1992 -1227

No files matched your search

+83 -585
View File
@@ -1,9 +1,12 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
const props = defineProps({
defineProps({
host: Boolean,
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] },
@@ -16,118 +19,10 @@ const items = ref([])
const laden = ref(true)
const fehler = ref('')
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
// Postern rast unter der Marke durch und bremst auf dem Gewinner aus.
const KACHEL = 220
const LUECKE = 10
const SCHRITT = KACHEL + LUECKE
// Die Ziehung läuft in Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn
// rast los und kriecht am Ende). Jedes zweite Mal bricht danach die
// Halterung: kurze Ruhe, dann rollt die Bahn ein paar Poster zurück. Der
// Server nennt die Gesamtdauer und die Halte, die Phasenlängen stehen
// hier — Ruhe plus Rückfahrt müssen zu BRUCH_DAUER im Backend passen.
const ANLAUF = 1.0
const BRUCH_RUHE = 1.2
const BRUCH_FAHRT = 2.5
// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
const ENTHUELLUNG = 0.6
const ergebnis = ref(null)
const dreht = ref(false)
const gewinner = ref(null)
const dauer = ref(0)
const x = ref(0)
const anlauf = ref(false)
const bruch = ref(false)
// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
const KURVEN = {
fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
}
const kurve = ref(KURVEN.fahrt)
const bahn = ref(null)
let timer = null
let enthuellung = null
// ── Ton ─────────────────────────────────────────────────────────────
// Ein Tick je Poster, das unter der Marke durchläuft, und ein Klang zur
// Enthüllung. Alles synthetisch, keine Dateien. Der Browser gibt Ton erst
// nach einer Eingabe frei — wer nur zuschaut und nie geklickt hat, hört
// nichts, sieht aber alles.
let audio = null
function klang() {
if (!audio) audio = new AudioContext()
if (audio.state === 'suspended') audio.resume()
return audio
}
function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
const a = klang()
const t = a.currentTime + start
const osc = a.createOscillator()
const gain = a.createGain()
osc.type = art
osc.frequency.value = frequenz
if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
gain.gain.setValueAtTime(lautstaerke, t)
gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
osc.connect(gain).connect(a.destination)
osc.start(t)
osc.stop(t + dauer)
}
// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
// man hört das Bremsen.
function tick(tempo) {
const a = Math.min(1, tempo / 3000)
ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
}
function anlaufTon(sekunden) {
ton(150, sekunden, 0.08, 'sine', 0, 520)
}
function knacken() {
ton(120, 0.12, 0.25, 'square')
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
}
function fanfare() {
ton(660, 0.5, 0.12, 'sine')
ton(990, 0.7, 0.1, 'sine', 0.12)
}
// Liest die laufende Position aus dem Übergang und tickt bei jedem
// Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
let zuletzt = -1
let letzteX = 0
let letzteZeit = 0
function ticken(zeit) {
if (!dreht.value || !bahn.value) return
const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
const idx = Math.floor((-m.m41 + LUECKE / 2) / SCHRITT)
const dt = (zeit - letzteZeit) / 1000
const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
letzteX = m.m41
letzteZeit = zeit
if (zuletzt === -1) {
// Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
zuletzt = idx
} else if (idx !== zuletzt) {
zuletzt = idx
tick(tempo)
}
requestAnimationFrame(ticken)
}
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
// verschwimmt die Marke mit dem Rand
const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
]
const {
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
} = useZiehung({ items })
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let abbestellen = null
@@ -136,138 +31,12 @@ function verbinden() {
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data)
else if (nachricht.type === 'reset') zurueckgesetzt()
})
}
// Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
// Zielkachel unter der Marke, egal wie breit das Fenster ist.
function stoppX(stopp) {
return -(stopp.position * SCHRITT + KACHEL / 2 + stopp.versatz * KACHEL)
}
// Vor dem ersten Dreh stehen die Vorschläge einfach in einer Reihe
function ruheX() {
return -(items.value.length * SCHRITT - LUECKE) / 2
}
function fahren(sekunden, k, ziel) {
kurve.value = k
dauer.value = sekunden
x.value = ziel
}
function ticksStarten() {
zuletzt = -1
letzteZeit = 0
requestAnimationFrame(ticken)
}
// Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
// hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
function phasen(erg) {
const erst = erg.stopps[0]
const ende = erg.stopps.at(-1)
const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
const fahrt = erg.dauer - ANLAUF - extra
const liste = [
{
d: ANLAUF,
start(rest) {
anlauf.value = true
if (rest > 0) anlaufTon(rest)
},
ende() {
anlauf.value = false
},
},
{
d: fahrt,
start(rest) {
fahren(rest, KURVEN.fahrt, stoppX(erst))
ticksStarten()
},
},
]
if (erg.stopps.length > 1) {
liste.push(
{ d: BRUCH_RUHE },
{
d: BRUCH_FAHRT,
start(rest) {
bruch.value = true
if (rest > 0) knacken()
fahren(rest, KURVEN.bruch, stoppX(ende))
},
ende() {
bruch.value = false
},
}
)
}
return liste
}
function ablauf(erg, liste, i, uebersprungen) {
if (i >= liste.length) {
dreht.value = false
// Erst Stille, dann der Glow
enthuellung = window.setTimeout(() => {
gewinner.value = erg.gewinner
fanfare()
}, ENTHUELLUNG * 1000)
return
}
const ph = liste[i]
if (uebersprungen >= ph.d) {
// Schon vorbei: nur den Endzustand setzen
ph.start?.(0)
ph.ende?.()
ablauf(erg, liste, i + 1, uebersprungen - ph.d)
return
}
const rest = ph.d - uebersprungen
ph.start?.(rest)
timer = window.setTimeout(() => {
ph.ende?.()
ablauf(erg, liste, i + 1, 0)
}, rest * 1000)
}
async function abspielen(erg, uebersprungen = 0) {
clearTimeout(timer)
clearTimeout(enthuellung)
gewinner.value = null
ergebnis.value = erg
dreht.value = true
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
// Ergebnis aus los
fahren(0, KURVEN.fahrt, -3 * SCHRITT)
await nextTick()
void bahn.value?.offsetWidth
ablauf(erg, phasen(erg), 0, uebersprungen)
}
async function laufendenDrehHolen() {
try {
const resp = await fetch('/api/spin')
if (!resp.ok) return
const daten = await resp.json()
if (!daten.result) return
if (daten.running) {
abspielen(daten.result, daten.elapsed)
} else {
// Dreh ist durch: Bahn ohne Animation auf die Endstellung setzen,
// sonst steht die Marke woanders als die Gewinnerkarte
ergebnis.value = daten.result
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
gewinner.value = daten.result.gewinner
else if (nachricht.type === 'reset') {
zurueckgesetzt()
emit('reset')
laden_()
}
} catch {
/* dann eben ohne */
}
})
}
async function laden_() {
@@ -278,10 +47,7 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
if (!ergebnis.value) {
dauer.value = 0
x.value = ruheX()
}
ruhen()
} catch (err) {
fehler.value = err.message
} finally {
@@ -289,44 +55,11 @@ async function laden_() {
}
}
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => {
clearTimeout(timer)
clearTimeout(enthuellung)
abbestellen?.()
})
defineExpose({ neu: laden_ })
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
const kacheln = computed(() => {
const erg = ergebnis.value
if (erg) {
const ziel = erg.stopps.at(-1).position
return erg.streifen.map((i, k) => ({
key: k,
film: erg.karten[i],
farbe: FARBEN[i % FARBEN.length],
treffer: k === ziel,
}))
}
return items.value.map((item, i) => ({
key: i,
film: item,
farbe: FARBEN[i % FARBEN.length],
treffer: false,
}))
})
async function drehen() {
if (dreht.value || !items.value.length) return
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
// aus und schickt das Ergebnis an alle, auch an uns.
klang()
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
try {
const resp = await fetch('/api/spin', { method: 'POST' })
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
@@ -335,18 +68,6 @@ async function drehen() {
}
}
function zurueckgesetzt() {
clearTimeout(timer)
clearTimeout(enthuellung)
anlauf.value = false
bruch.value = false
ergebnis.value = null
gewinner.value = null
dreht.value = false
emit('reset')
laden_()
}
async function zuruecksetzen() {
// Der Server meldet den Reset an alle zurück, auch an uns
try {
@@ -356,45 +77,20 @@ async function zuruecksetzen() {
}
}
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
// anderen — für Abende, an denen es keiner gruselig mag.
const nurHorror = ref(true)
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
const wuerfelt = ref(false)
function nachGenre(horror) {
return sammlung.value.filter(
(e) => (e.genres || []).includes('Horror') === horror
)
}
const auswahl = computed(() => nachGenre(nurHorror.value))
const anzahlHorror = computed(() => nachGenre(true).length)
const anzahlOhne = computed(() => nachGenre(false).length)
async function wuerfeln() {
const treffer = auswahl.value
if (!treffer.length || wuerfelt.value) return
wuerfelt.value = true
const film = treffer[Math.floor(Math.random() * treffer.length)]
try {
const resp = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: film.movie_id }),
})
if (!resp.ok) throw new Error()
} catch {
fehler.value = 'Eintragen hat nicht geklappt.'
} finally {
wuerfelt.value = false
}
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
</script>
<template>
@@ -402,7 +98,10 @@ async function oeffnen(item) {
<div class="buehne">
<p v-if="fehler" class="note error">{{ fehler }}</p>
<div class="fenster">
<div
class="fenster"
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
>
<div class="marke" :class="{ anlauf, bruch }"></div>
<div
ref="bahn"
@@ -468,61 +167,16 @@ async function oeffnen(item) {
</div>
</div>
<aside class="liste">
<div v-if="!host" class="sammlung-wuerfeln">
<div class="schalter">
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
Horror ({{ anzahlHorror }})
</button>
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
Kein Horror ({{ anzahlOhne }})
</button>
</div>
<button
type="button"
class="wuerfeln"
:disabled="!auswahl.length || wuerfelt"
@click="wuerfeln"
>
Zufällig aus Sammlung
</button>
</div>
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
type="button"
class="eintrag"
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
@click="oeffnen(item)"
>
<span
class="cover"
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
>
<img
v-if="posterUrl(item.poster_path, 'w154')"
:src="posterUrl(item.poster_path, 'w154')"
:alt="item.title"
loading="lazy"
/>
</span>
<span class="daten">
<span class="titel">{{ item.title }}</span>
<span class="meta">
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
</span>
<span v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</span>
<span class="von">{{ item.name || 'Anonym' }}</span>
</span>
</button>
</li>
</ul>
</aside>
<VorschlagsListe
:items="items"
:laden="laden"
:host="host"
:merkliste="merkliste"
:mine="mine"
:gesehen-ids="gesehenIds"
@open="oeffnen"
@fehler="fehler = $event"
/>
</div>
</template>
@@ -546,7 +200,8 @@ async function oeffnen(item) {
.fenster {
position: relative;
align-self: stretch;
height: 370px;
/* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */
height: calc(var(--kachel) * 1.5 + 40px);
overflow: hidden;
/* Links und rechts läuft der Streifen ins Dunkel aus */
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
@@ -557,7 +212,7 @@ async function oeffnen(item) {
top: 20px;
left: 50%;
display: flex;
gap: 10px;
gap: var(--luecke);
will-change: transform;
/* Die Kurve setzt das Skript je Phase */
transition-property: transform;
@@ -565,8 +220,8 @@ async function oeffnen(item) {
.kachel {
position: relative;
flex: 0 0 220px;
height: 330px;
flex: 0 0 var(--kachel);
height: calc(var(--kachel) * 1.5);
overflow: hidden;
background: var(--bg-soft);
border: 2px solid;
@@ -634,6 +289,28 @@ async function oeffnen(item) {
pointer-events: none;
}
.marke::before,
.marke::after {
position: absolute;
left: 50%;
width: 0;
height: 0;
margin-left: -9px;
content: '';
border-right: 9px solid transparent;
border-left: 9px solid transparent;
}
.marke::before {
top: 0;
border-top: 14px solid var(--accent);
}
.marke::after {
bottom: 0;
border-bottom: 14px solid var(--accent);
}
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
.marke.anlauf {
animation: puls 0.33s ease-in-out 3;
@@ -667,28 +344,6 @@ async function oeffnen(item) {
}
}
.marke::before,
.marke::after {
position: absolute;
left: 50%;
width: 0;
height: 0;
margin-left: -9px;
content: '';
border-right: 9px solid transparent;
border-left: 9px solid transparent;
}
.marke::before {
top: 0;
border-top: 14px solid var(--accent);
}
.marke::after {
bottom: 0;
border-bottom: 14px solid var(--accent);
}
.leer-bahn {
position: absolute;
inset: 0;
@@ -702,6 +357,21 @@ async function oeffnen(item) {
gap: 0.7rem;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.dreh:disabled {
background: var(--line);
color: var(--muted);
cursor: default;
}
.reset {
padding: 0.7rem 1.4rem;
color: var(--muted);
@@ -721,73 +391,6 @@ async function oeffnen(item) {
cursor: default;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.dreh:disabled {
background: var(--line);
color: var(--muted);
cursor: default;
}
.sammlung-wuerfeln {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.6rem;
}
.schalter {
display: flex;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.schalter button {
flex: 1;
padding: 0.45rem 0.9rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
}
.schalter button.an {
color: var(--text);
background: var(--bg-soft);
}
.wuerfeln {
padding: 0.5rem 1.1rem;
font-size: 0.88rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
}
.wuerfeln:hover:not(:disabled) {
border-color: var(--accent);
}
.wuerfeln:disabled {
color: var(--muted);
cursor: not-allowed;
opacity: 0.5;
}
.gewinner {
display: flex;
gap: 1rem;
@@ -837,111 +440,6 @@ async function oeffnen(item) {
font-size: 0.85rem;
}
.liste h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.liste ul {
display: flex;
flex-direction: column;
gap: 0.2rem;
list-style: none;
}
.eintrag {
display: flex;
gap: 0.7rem;
align-items: flex-start;
width: 100%;
padding: 0.55rem;
color: var(--text);
font-size: 0.86rem;
text-align: left;
border-radius: 8px;
}
/* Schon gelaufen — die Ziehung überspringt diesen Film */
.eintrag.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.eintrag.gesehen:hover {
opacity: 1;
filter: none;
}
.eintrag:hover {
background: var(--bg-soft);
}
/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
.cover {
display: block;
width: 46px;
flex-shrink: 0;
overflow: hidden;
background: var(--bg-soft);
border-left: 3px solid;
border-radius: 3px;
}
.cover img {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
}
.daten {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
padding-top: 0.1rem;
}
.titel {
overflow: hidden;
font-weight: 600;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.eintrag.eigen .titel {
color: var(--accent);
}
.meta,
.genres,
.von {
overflow: hidden;
color: var(--muted);
font-size: 0.74rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.von {
margin-top: 0.15rem;
font-size: 0.72rem;
opacity: 0.75;
}
.note {
padding: 3rem 0;
color: var(--muted);