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

View File

@@ -1,6 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { abonnieren } from './live'
import { computed, onMounted, ref } from 'vue'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
@@ -14,507 +13,31 @@ import StreamTab from './components/StreamTab.vue'
import WatchedTab from './components/WatchedTab.vue'
import WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.vue'
import { useEinstellungen } from './composables/useEinstellungen'
import { useMeins } from './composables/useMeins'
import { matches, useKatalog } from './composables/useKatalog'
import { REGLER, useFilter } from './composables/useFilter'
import { useLangeListe } from './composables/useLangeListe'
const movies = ref([])
const status = ref(null)
const loading = ref(true)
const error = ref('')
const search = ref('')
const sort = ref('random')
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
// "More like this" zeigt. Im Katalog gibt es die nicht.
const suchSort = ref('relevanz')
const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen()
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
const {
runde, ich, michSetzen,
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
meinVorschlag, aufDemRad, ladeVorschlag,
merkliste, ladeMerkliste,
wuensche, ladeWuensche,
} = useMeins({ host })
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
const {
movies, loading, error, load,
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
} = useKatalog()
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene'
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus')
watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus'))
const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
// Bilder kommen gleich danach.
//
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
async function filmOeffnen(movie) {
selected.value = movie
if (!movie?.id) return
try {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
} catch {
/* dann eben mit den Listendaten */
}
}
// Nach dem Neuladen soll derselbe Reiter offen sein
const TAB_SCHLUESSEL = 'filmabend_tab'
const TAB_IDS = [
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
'wuensche', 'verwaltung', 'stream',
]
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
const include = ref([])
const exclude = ref([])
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
const canonMovies = computed(() =>
movies.value.filter((m) => m.canon_group !== null)
)
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 50, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
}
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
'Western',
].map((genre) => ({ genre, count: null }))
const grenzen = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const out = {}
for (const regler of REGLER) {
const werte = pool.map((m) => m[regler.feld]).filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
}
}
return out
})
const ranges = ref({})
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
watch(
grenzen,
(neu) => {
ranges.value = Object.fromEntries(
Object.entries(neu).map(([k, v]) => [k, [v.min, v.max]])
)
},
{ immediate: true }
)
function resetRanges() {
ranges.value = Object.fromEntries(
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
)
}
function imBereich(movie) {
for (const regler of REGLER) {
const spanne = ranges.value[regler.key]
const grenze = grenzen.value[regler.key]
if (!spanne) continue
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
const wert = movie[regler.feld]
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
}
return true
}
function toggleGenre(kind, genre) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const at = liste.value.indexOf(genre)
if (at === -1) {
liste.value = [...liste.value, genre]
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
andere.value = andere.value.filter((g) => g !== genre)
} else {
liste.value = liste.value.filter((g) => g !== genre)
}
}
function toggleAll(kind) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const alle = genreOptions.value.map((o) => o.genre)
// Sind schon alle drin, ist der zweite Klick ein Leeren
if (liste.value.length === alle.length) {
liste.value = []
} else {
liste.value = alle
andere.value = []
}
}
function resetGenres() {
include.value = []
exclude.value = []
resetRanges()
}
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
const genreOptions = computed(() => {
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
const counts = new Map()
for (const movie of pool) {
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
}
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
return [...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
.map(([genre, count]) => ({ genre, count }))
})
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
function groupBy(movies, schluessel) {
const byKey = new Map()
const einzeln = []
for (const movie of movies) {
const key = schluessel(movie)
if (key === null || key === undefined) {
einzeln.push(movie)
continue
}
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key).push(movie)
}
const gruppen = []
for (const [key, parts] of byKey) {
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
if (parts.length === 1) einzeln.push(parts[0])
else gruppen.push({ group: key, parts })
}
return { gruppen, einzeln }
}
function sortiereReihe(parts) {
const sorted = [...parts].sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
)
// Die Karte startet beim stärksten Teil, danach sortiert auch die
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
let best = 0
sorted.forEach((part, i) => {
if (score(part) > score(sorted[best])) best = i
})
return { parts: sorted, best }
}
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
// Jahr — hier zählt das Erscheinungsjahr.
const canonGroups = computed(() => {
const byGroup = new Map()
for (const movie of canonMovies.value) {
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
byGroup.get(movie.canon_group).push(movie)
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
})
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
const netflixEntries = computed(() => {
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
return [
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
]
})
const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{
id: 'canon',
label: 'Horror-Kanon',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
},
{ id: 'search', label: 'Suche', count: null },
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
...(host.value
? []
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
])
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
const suchFilter = computed(() => {
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
dauer: spanne('runtime'),
jahr: spanne('year'),
note: spanne('vote_average'),
grenzen: grenzen.value,
}
})
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
const aehnlichStart = ref(null)
function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie
selected.value = null
activeTab.value = 'aehnlich'
}
const rad = ref(null)
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
const obenKnopf = ref(false)
function beimScrollen() {
obenKnopf.value = window.scrollY > 800
}
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
const OBEN_DAUER = 450
function nachOben() {
const start = window.scrollY
const beginn = performance.now()
function schritt(jetzt) {
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
// Sanft an- und auslaufend
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
window.scrollTo(0, start * (1 - p))
if (t < 1) requestAnimationFrame(schritt)
}
requestAnimationFrame(schritt)
}
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
// ob der Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
try {
const resp = await fetch('/api/watched')
if (resp.ok) gesehen.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
// Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
const gesehenIds = computed(
() => new Set(gesehen.value.map((e) => e.movie_id))
)
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(
() => gesehen.value.filter((e) => host.value || !e.hidden).length
)
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus.
// Den Namen setzt danach der Server an Vorschläge und Wertungen.
const runde = ref([])
const ich = ref(null)
async function michSetzen(id) {
await fetch('/api/users/waehlen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
})
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
await ladeRunde()
ladeVorschlag()
ladeMerkliste()
ladeGesehen()
ladeWuensche()
}
async function ladeRunde() {
try {
const resp = await fetch('/api/users')
if (!resp.ok) return
const daten = await resp.json()
runde.value = daten.items
ich.value = daten.mine
} catch {
/* dann eben ohne */
}
}
const HOST_SCHLUESSEL = 'filmabend_host'
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
watch(host, (an) => {
// Verwaltung sieht nur der Host, Vorschläge nur die Gäste
const weg = an ? 'merkliste' : 'verwaltung'
if (activeTab.value === weg) activeTab.value = 'netflix'
})
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
}
}
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null)
// Alle Filme auf dem Rad, damit die Karten es zeigen können
const aufDemRad = ref([])
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
const merkliste = ref([])
// Was der Seite noch fehlt
const wuensche = ref([])
async function ladeWuensche() {
try {
const resp = await fetch('/api/features')
if (resp.ok) wuensche.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
async function ladeMerkliste() {
try {
const resp = await fetch('/api/wishlist')
if (resp.ok) merkliste.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
async function ladeVorschlag() {
try {
const resp = await fetch('/api/suggestions')
if (!resp.ok) return
const daten = await resp.json()
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
aufDemRad.value = daten.items.map((i) => i.movie_id)
} catch {
meinVorschlag.value = null
aufDemRad.value = []
}
}
function nachVorschlag() {
ladeVorschlag()
rad.value?.neu()
}
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
const MIN_VOTES = 150
const average = computed(() => {
const rated = movies.value.filter((m) => m.vote_count)
if (!rated.length) return 0
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
})
function score(movie) {
const votes = movie.vote_count || 0
return (
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
)
}
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
// Karten bei jedem Filtern neu durcheinander.
const wuerfel = new Map()
function zufall(movie) {
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
return wuerfel.get(movie.id)
}
const SORTS = {
random: (a, b) => zufall(a) - zufall(b),
rating: (a, b) => score(b) - score(a),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function matches(movie, term) {
return (
movie.title.toLowerCase().includes(term) ||
(movie.original_title || '').toLowerCase().includes(term)
)
}
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
function nichtGesehen(movie) {
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
}
function passt(movie) {
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
}
function hasGenre(movie) {
const genres = movie.genres || []
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
return false
}
return !include.value.length || genres.some((g) => include.value.includes(g))
}
const {
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil
@@ -538,55 +61,58 @@ const visibleCanon = computed(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
)
// Gefiltert wird über den ganzen Katalog, gerendert nur der Anfang davon.
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
// Bilder — der Browser meldet dann "Seite reagiert nicht".
const STAPEL = 100
const gezeigt = ref(STAPEL)
const aktuelleListe = computed(() =>
activeTab.value === 'netflix' ? visible.value : visibleCanon.value
)
const sichtbar = computed(() => aktuelleListe.value.slice(0, gezeigt.value))
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
watch([aktuelleListe, ansicht], () => {
gezeigt.value = STAPEL
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
liste: aktuelleListe,
ansicht,
})
async function mehrLaden() {
if (gezeigt.value >= aktuelleListe.value.length) return
gezeigt.value += STAPEL
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
// käme sonst nie ein zweiter Stapel.
await nextTick()
const el = nachschub.value
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
}
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
// Gleich hier angelegt, nicht in onMounted: die Vorlage setzt die Referenz
// schon beim ersten Zeichnen, da muss der Beobachter bereits stehen.
const nachschub = ref(null)
const beobachter = new IntersectionObserver(
(eintraege) => {
if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{
id: 'canon',
label: 'Horror-Kanon',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
},
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
{ rootMargin: '600px' }
{ id: 'search', label: 'Suche', count: null },
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
])
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
// klappt sie über einen Knopf auf
const filterOffen = ref(false)
const mitFilter = computed(() =>
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
)
onUnmounted(() => beobachter.disconnect())
const selected = ref(null)
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
// Wechsel neu anmelden
watch(nachschub, (el, vorher) => {
if (vorher) beobachter.unobserve(vorher)
if (el) beobachter.observe(el)
})
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
// Bilder kommen gleich danach.
//
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
async function filmOeffnen(movie) {
selected.value = movie
if (!movie?.id) return
try {
const resp = await fetch(`/api/search/${movie.id}`)
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
} catch {
/* dann eben mit den Listendaten */
}
}
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
@@ -607,60 +133,27 @@ const series = computed(() => {
)
})
const currentCount = computed(() =>
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
)
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
const aehnlichStart = ref(null)
async function load() {
loading.value = true
error.value = ''
try {
const [moviesRes, statusRes] = await Promise.all([
fetch('/api/movies'),
fetch('/api/status'),
])
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
movies.value = await moviesRes.json()
status.value = await statusRes.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie
selected.value = null
activeTab.value = 'aehnlich'
}
let abbestellen = null
const rad = ref(null)
onMounted(() => {
load()
function nachVorschlag() {
ladeVorschlag()
ladeGesehen()
ladeRunde()
ladeMerkliste()
ladeWuensche()
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
else if (nachricht.type === 'users') ladeRunde()
else if (nachricht.type === 'features') ladeWuensche()
})
window.addEventListener('keydown', tastatur)
window.addEventListener('scroll', beimScrollen, { passive: true })
})
rad.value?.neu()
}
onUnmounted(() => {
window.removeEventListener('keydown', tastatur)
window.removeEventListener('scroll', beimScrollen)
abbestellen?.()
})
onMounted(load)
</script>
<template>
<div class="page" :class="{
breit: [
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
].includes(activeTab),
}">
<div class="page" :class="{ breit: !mitFilter }">
<nav class="tabs">
<button
v-for="tab in tabs"
@@ -672,6 +165,15 @@ onUnmounted(() => {
{{ tab.label }}
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
<button
v-if="mitFilter"
type="button"
class="filter-knopf"
:class="{ an: filterOffen }"
@click="filterOffen = !filterOffen"
>
Filter
</button>
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
<span v-else class="host">Host</span>
</nav>
@@ -687,8 +189,8 @@ onUnmounted(() => {
/>
<AehnlichTab
:gesehen-ids="gesehenIds"
v-else
:gesehen-ids="gesehenIds"
:filter="suchFilter"
:ansicht="ansicht"
:start="aehnlichStart"
@@ -701,6 +203,7 @@ onUnmounted(() => {
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
@@ -755,40 +258,40 @@ onUnmounted(() => {
<div v-else class="layout">
<div class="results">
<p v-if="loading" class="note">Lade Katalog…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!movies.length" class="note">
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
</p>
<p v-else-if="!currentCount" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p>
<p v-if="loading" class="note">Lade Katalog…</p>
<p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!movies.length" class="note">
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
</p>
<p v-else-if="!aktuelleListe.length" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p>
<div class="grid" :class="ansicht">
<template v-for="entry in sichtbar" :key="entry.group">
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
:gesehen="gesehenIds.has(entry.parts[0].id)"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht"
@open="filmOeffnen"
/>
<SeriesCard
v-else
:parts="entry.parts"
:gesehen-ids="gesehenIds"
:start="entry.best"
:auf-dem-rad="aufDemRad"
:ansicht="ansicht"
@open="filmOeffnen"
/>
</template>
</div>
<div class="grid" :class="ansicht">
<template v-for="entry in sichtbar" :key="entry.group">
<MovieCard
v-if="entry.parts.length === 1"
:movie="entry.parts[0]"
:gesehen="gesehenIds.has(entry.parts[0].id)"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht"
@open="filmOeffnen"
/>
<SeriesCard
v-else
:parts="entry.parts"
:gesehen-ids="gesehenIds"
:start="entry.best"
:auf-dem-rad="aufDemRad"
:ansicht="ansicht"
@open="filmOeffnen"
/>
</template>
</div>
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
Lädt weitere Filme …
</div>
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
Lädt weitere Filme …
</div>
</div>
<FilterSidebar
@@ -798,6 +301,7 @@ onUnmounted(() => {
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
@@ -817,18 +321,17 @@ onUnmounted(() => {
v-if="selected"
:movie="selected"
:series="series"
@close="selected = null"
@open="filmOeffnen"
:mein-vorschlag="meinVorschlag"
:host="host"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
@close="selected = null"
@open="filmOeffnen"
@gemerkt="ladeMerkliste"
@suggested="nachVorschlag"
@watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche"
/>
</div>
</template>
@@ -839,7 +342,7 @@ onUnmounted(() => {
padding-right: calc(var(--sidebar) + 1.25rem);
}
/* Such- und Rad-Reiter haben keine Filterleiste */
/* Reiter ohne Filterleiste */
.page.breit {
padding-right: 1.25rem;
}
@@ -886,6 +389,7 @@ onUnmounted(() => {
padding: 0.8rem 1rem 0.6rem;
color: var(--muted);
font-size: 0.95rem;
white-space: nowrap;
border-bottom: 2px solid transparent;
}
@@ -898,6 +402,12 @@ onUnmounted(() => {
border-bottom-color: var(--accent);
}
/* Den Filterknopf gibt es nur auf schmalen Schirmen — mit .tabs davor,
sonst gewinnt die Regel für alle Reiterknöpfe */
.tabs .filter-knopf {
display: none;
}
.host {
align-self: center;
margin-left: 0.4rem;
@@ -938,26 +448,6 @@ onUnmounted(() => {
border-left: 1px solid var(--line);
}
@media (max-width: 900px) {
.page {
padding-right: 1.25rem;
}
/* Zu schmal für eine feste Leiste — die Filter wandern über die Kacheln */
.layout {
display: flex;
flex-direction: column;
}
.layout > :last-child {
position: static;
order: -1;
width: auto;
border: 1px solid var(--line);
border-radius: 12px;
}
}
.nachschub {
padding: 2rem 0;
color: var(--muted);
@@ -990,4 +480,82 @@ onUnmounted(() => {
color: var(--accent);
}
/* ── Schmale Schirme ─────────────────────────────────────────────── */
@media (max-width: 900px) {
.page {
padding-right: 1.25rem;
}
.nach-oben {
right: 1rem;
bottom: 1rem;
}
/* Zu schmal für eine feste Leiste — die Filter klappen über den Karten auf */
.layout {
display: flex;
flex-direction: column;
}
.layout > :last-child {
display: none;
position: static;
order: -1;
width: auto;
margin-bottom: 1.25rem;
border: 1px solid var(--line);
border-radius: 12px;
}
.layout > :last-child.offen {
display: flex;
}
.tabs .filter-knopf {
display: flex;
margin-left: auto;
}
.tabs .filter-knopf.an {
color: var(--accent);
}
/* Die Namenswahl rückt neben den Filterknopf */
.filter-knopf + * {
margin-left: 0;
}
}
@media (max-width: 640px) {
.page {
padding: 1rem 0.75rem 2.5rem;
}
/* Die Reiter passen nicht in eine Zeile — sie scrollen seitlich */
.tabs {
gap: 0;
margin: -1rem -0.75rem 1rem;
padding: 0 0.75rem;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
padding: 0.7rem 0.6rem 0.5rem;
font-size: 0.88rem;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.7rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -258,4 +258,21 @@ h2 {
.card.details .shots {
overflow: hidden;
}
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
@media (max-width: 700px) {
.card.details {
grid-template-columns: 110px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.card.details .poster {
width: 110px;
}
.card.details .shots {
grid-column: 1 / -1;
grid-template-columns: repeat(4, 1fr);
}
}
</style>

View File

@@ -701,6 +701,17 @@ h3 {
}
@media (max-width: 620px) {
.backdrop {
padding: 0;
}
.sheet {
min-height: 100%;
padding: 1rem;
border: 0;
border-radius: 0;
}
header {
flex-direction: column;
}
@@ -712,6 +723,10 @@ h3 {
.shots {
grid-template-columns: 1fr;
}
.lupe {
padding: 0.5rem;
}
}
.shot {

View File

@@ -151,6 +151,9 @@ async function open(movie) {
.big-search {
flex: 1;
/* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus
dem Bild, wenn der Schirm schmal ist */
min-width: 0;
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem;
}
@@ -170,6 +173,7 @@ async function open(movie) {
}
.neu {
flex-shrink: 0;
padding: 0 1.4rem;
font-size: 0.95rem;
font-weight: 600;

View File

@@ -360,4 +360,25 @@ h2:hover {
.card.details .shots {
overflow: hidden;
}
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
@media (max-width: 700px) {
.card.details {
grid-template-columns: 110px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.card.details .poster {
width: 110px;
}
.card.details .shots {
grid-area: 2 / 1 / 3 / 3;
grid-template-columns: repeat(4, 1fr);
}
.card.details .body {
grid-area: 1 / 2;
}
}
</style>

View File

@@ -257,6 +257,7 @@ onUnmounted(() => {
.rahmen {
position: relative;
max-width: 100%;
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
max-height: calc(100vh - 5.5rem);
aspect-ratio: 16 / 9;

View File

@@ -0,0 +1,264 @@
<script setup>
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { FARBEN } from '../composables/useZiehung'
const props = defineProps({
items: { type: Array, default: () => [] },
laden: Boolean,
host: Boolean,
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() },
})
const emit = defineEmits(['open', 'fehler'])
// 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 {
emit('fehler', 'Eintragen hat nicht geklappt.')
} finally {
wuerfelt.value = false
}
}
</script>
<template>
<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="emit('open', 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>
</template>
<style scoped>
.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;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
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;
}
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;
}
</style>

View File

@@ -369,7 +369,7 @@ onUnmounted(() => {
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 1.1rem;
}
@@ -716,4 +716,14 @@ h3:hover {
opacity: 0.5;
cursor: default;
}
@media (max-width: 640px) {
.rezension-hintergrund {
padding: 0.5rem;
}
.rezension {
padding: 1rem;
}
}
</style>

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);

View File

@@ -504,6 +504,12 @@ h3 {
opacity: 1;
}
@media (hover: none) {
.aktionen {
opacity: 1;
}
}
.weg {
padding: 0 0.25rem;
color: var(--muted);

View File

@@ -0,0 +1,58 @@
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht,
// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage,
// alles je Browser.
import { onMounted, onUnmounted, ref, watch } from 'vue'
const TAB_IDS = [
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
'wuensche', 'verwaltung', 'stream',
]
function gemerkt(schluessel, standard) {
const wert = ref(localStorage.getItem(schluessel) ?? standard)
watch(wert, (neu) => localStorage.setItem(schluessel, neu))
return wert
}
export function useEinstellungen() {
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
const ansicht = gemerkt('filmabend_ansicht', 'kompakt')
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
watch(ohneGesehene, (an) =>
localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
)
// Nach dem Neuladen soll derselbe Reiter offen sein
const zuletzt = localStorage.getItem('filmabend_tab')
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
// Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
const host = ref(localStorage.getItem('filmabend_host') === '1')
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
watch(host, (an) => {
const weg = an ? 'merkliste' : 'verwaltung'
if (activeTab.value === weg) activeTab.value = 'netflix'
})
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem('filmabend_host', host.value ? '1' : '0')
}
}
onMounted(() => window.addEventListener('keydown', tastatur))
onUnmounted(() => window.removeEventListener('keydown', tastatur))
return { ansicht, ohneGesehene, activeTab, host }
}

View File

@@ -0,0 +1,179 @@
// Die Filterleiste: Genres, Regler, Sortierung, Suche — und die Prädikate,
// mit denen der Katalog danach gesiebt wird.
import { computed, ref, watch } from 'vue'
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
// sonst wandern die Enden bei jeder Auswahl
export const REGLER = [
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
]
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
// ließen — TMDB reicht von den Anfängen bis heute
const SUCH_GRENZEN = {
vote_count: { min: 50, max: 50000 },
runtime: { min: 0, max: 240 },
year: { min: 1900, max: new Date().getFullYear() + 1 },
vote_average: { min: 0, max: 10 },
}
// Die 19 Genres, die TMDB kennt
const SUCH_GENRES = [
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
'Western',
].map((genre) => ({ genre, count: null }))
export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) {
const search = ref('')
const sort = ref('random')
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
// "More like this" zeigt. Im Katalog gibt es die nicht.
const suchSort = ref('relevanz')
const include = ref([])
const exclude = ref([])
const suchReiter = () => activeTab.value === 'search' || activeTab.value === 'aehnlich'
const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value)
const grenzen = computed(() => {
if (suchReiter()) return SUCH_GRENZEN
const out = {}
for (const regler of REGLER) {
const werte = pool().map((m) => m[regler.feld]).filter((v) => v != null)
const min = werte.length ? Math.min(...werte) : 0
const max = werte.length ? Math.max(...werte) : 0
out[regler.key] = {
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
}
}
return out
})
const ranges = ref({})
function resetRanges() {
ranges.value = Object.fromEntries(
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
)
}
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
watch(grenzen, resetRanges, { immediate: true })
function imBereich(movie) {
for (const regler of REGLER) {
const spanne = ranges.value[regler.key]
const grenze = grenzen.value[regler.key]
if (!spanne) continue
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
const wert = movie[regler.feld]
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
}
return true
}
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
const genreOptions = computed(() => {
if (suchReiter()) return SUCH_GENRES
const counts = new Map()
for (const movie of pool()) {
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
}
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
return [...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
.map(([genre, count]) => ({ genre, count }))
})
function toggleGenre(kind, genre) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const at = liste.value.indexOf(genre)
if (at === -1) {
liste.value = [...liste.value, genre]
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
andere.value = andere.value.filter((g) => g !== genre)
} else {
liste.value = liste.value.filter((g) => g !== genre)
}
}
function toggleAll(kind) {
const liste = kind === 'include' ? include : exclude
const andere = kind === 'include' ? exclude : include
const alle = genreOptions.value.map((o) => o.genre)
// Sind schon alle drin, ist der zweite Klick ein Leeren
if (liste.value.length === alle.length) {
liste.value = []
} else {
liste.value = alle
andere.value = []
}
}
function resetGenres() {
include.value = []
exclude.value = []
resetRanges()
}
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
function hasGenre(movie) {
const genres = movie.genres || []
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
return false
}
return !include.value.length || genres.some((g) => include.value.includes(g))
}
function nichtGesehen(movie) {
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
}
function passt(movie) {
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
}
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
const suchFilter = computed(() => {
const spanne = (key) => ranges.value[key] || []
return {
sort: suchSort.value,
include: include.value,
exclude: exclude.value,
stimmen: spanne('vote_count'),
dauer: spanne('runtime'),
jahr: spanne('year'),
note: spanne('vote_average'),
grenzen: grenzen.value,
}
})
return {
search,
sort,
suchSort,
include,
exclude,
grenzen,
ranges,
genreOptions,
toggleGenre,
toggleAll,
resetGenres,
nichtGesehen,
passt,
suchFilter,
}
}

View File

@@ -0,0 +1,150 @@
// Der Katalog: alle Filme aus der Datenbank, aufgeteilt in Netflix und
// Kanon, zu Reihen gruppiert und sortierbar.
import { computed, ref } from 'vue'
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
const MIN_VOTES = 150
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
// Karten bei jedem Filtern neu durcheinander.
const wuerfel = new Map()
function zufall(movie) {
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
return wuerfel.get(movie.id)
}
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
function groupBy(movies, schluessel) {
const byKey = new Map()
const einzeln = []
for (const movie of movies) {
const key = schluessel(movie)
if (key === null || key === undefined) {
einzeln.push(movie)
continue
}
if (!byKey.has(key)) byKey.set(key, [])
byKey.get(key).push(movie)
}
const gruppen = []
for (const [key, parts] of byKey) {
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
if (parts.length === 1) einzeln.push(parts[0])
else gruppen.push({ group: key, parts })
}
return { gruppen, einzeln }
}
export function matches(movie, term) {
return (
movie.title.toLowerCase().includes(term) ||
(movie.original_title || '').toLowerCase().includes(term)
)
}
export function useKatalog() {
const movies = ref([])
const status = ref(null)
const loading = ref(true)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const [moviesRes, statusRes] = await Promise.all([
fetch('/api/movies'),
fetch('/api/status'),
])
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
movies.value = await moviesRes.json()
status.value = await statusRes.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
const canonMovies = computed(() =>
movies.value.filter((m) => m.canon_group !== null)
)
const average = computed(() => {
const rated = movies.value.filter((m) => m.vote_count)
if (!rated.length) return 0
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
})
function score(movie) {
const votes = movie.vote_count || 0
return (
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
)
}
const SORTS = {
random: (a, b) => zufall(a) - zufall(b),
rating: (a, b) => score(b) - score(a),
newest: (a, b) => (b.year || 0) - (a.year || 0),
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
title: (a, b) => a.title.localeCompare(b.title, 'de'),
}
function sortiereReihe(parts) {
const sorted = [...parts].sort(
(a, b) =>
(a.release_date || '').localeCompare(b.release_date || '') ||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
)
// Die Karte startet beim stärksten Teil, danach sortiert auch die
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
let best = 0
sorted.forEach((part, i) => {
if (score(part) > score(sorted[best])) best = i
})
return { parts: sorted, best }
}
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
// Jahr — hier zählt das Erscheinungsjahr.
const canonGroups = computed(() => {
const byGroup = new Map()
for (const movie of canonMovies.value) {
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
byGroup.get(movie.canon_group).push(movie)
}
return [...byGroup.entries()]
.sort((a, b) => a[0] - b[0])
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
})
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
const netflixEntries = computed(() => {
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
return [
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
]
})
return {
movies,
status,
loading,
error,
load,
netflixMovies,
canonMovies,
canonGroups,
netflixEntries,
SORTS,
}
}

View File

@@ -0,0 +1,81 @@
// Eine Liste mit tausenden Karten: gerendert wird nur der Anfang, der Rest
// kommt beim Scrollen nach. Dazu der Knopf, der wieder nach oben bringt.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
// Bilder — der Browser meldet dann "Seite reagiert nicht".
const STAPEL = 100
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
const OBEN_DAUER = 450
export function useLangeListe({ liste, ansicht }) {
const gezeigt = ref(STAPEL)
const sichtbar = computed(() => liste.value.slice(0, gezeigt.value))
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
watch([liste, ansicht], () => {
gezeigt.value = STAPEL
})
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
const nachschub = ref(null)
const beobachter = new IntersectionObserver(
(eintraege) => {
if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
},
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
{ rootMargin: '600px' }
)
async function mehrLaden() {
if (gezeigt.value >= liste.value.length) return
gezeigt.value += STAPEL
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
// käme sonst nie ein zweiter Stapel.
await nextTick()
const el = nachschub.value
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
}
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
// Wechsel neu anmelden
watch(nachschub, (el, vorher) => {
if (vorher) beobachter.unobserve(vorher)
if (el) beobachter.observe(el)
})
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
const obenKnopf = ref(false)
function beimScrollen() {
obenKnopf.value = window.scrollY > 800
}
function nachOben() {
const start = window.scrollY
const beginn = performance.now()
function schritt(jetzt) {
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
// Sanft an- und auslaufend
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
window.scrollTo(0, start * (1 - p))
if (t < 1) requestAnimationFrame(schritt)
}
requestAnimationFrame(schritt)
}
onMounted(() => window.addEventListener('scroll', beimScrollen, { passive: true }))
onUnmounted(() => {
window.removeEventListener('scroll', beimScrollen)
beobachter.disconnect()
})
return { gezeigt, sichtbar, nachschub, obenKnopf, nachOben }
}

View File

@@ -0,0 +1,124 @@
// Wer ich bin und was an mir hängt: die Runde, meine Sammlung, mein
// Vorschlag, die gesehenen Filme und die Wünsche. Alles wird beim Start
// geladen und über den Live-Kanal frisch gehalten.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
async function hole(url) {
try {
const resp = await fetch(url)
return resp.ok ? await resp.json() : null
} catch {
return null
}
}
export function useMeins({ host }) {
// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus.
// Den Namen setzt danach der Server an Vorschläge und Wertungen.
const runde = ref([])
const ich = ref(null)
async function ladeRunde() {
const daten = await hole('/api/users')
if (!daten) return
runde.value = daten.items
ich.value = daten.mine
}
async function michSetzen(id) {
await fetch('/api/users/waehlen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
})
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
await ladeRunde()
ladeVorschlag()
ladeMerkliste()
ladeGesehen()
ladeWuensche()
}
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,
// ob ein Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
gesehen.value = (await hole('/api/watched')) ?? gesehen.value
}
// Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
const gesehenIds = computed(() => new Set(gesehen.value.map((e) => e.movie_id)))
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(
() => gesehen.value.filter((e) => host.value || !e.hidden).length
)
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen —
// und alle Filme auf der Bahn, damit die Karten es zeigen können
const meinVorschlag = ref(null)
const aufDemRad = ref([])
async function ladeVorschlag() {
const daten = await hole('/api/suggestions')
if (!daten) {
meinVorschlag.value = null
aufDemRad.value = []
return
}
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
aufDemRad.value = daten.items.map((i) => i.movie_id)
}
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
const merkliste = ref([])
async function ladeMerkliste() {
merkliste.value = (await hole('/api/wishlist')) ?? merkliste.value
}
// Was der Seite noch fehlt
const wuensche = ref([])
async function ladeWuensche() {
wuensche.value = (await hole('/api/features')) ?? wuensche.value
}
let abbestellen = null
onMounted(() => {
ladeVorschlag()
ladeGesehen()
ladeRunde()
ladeMerkliste()
ladeWuensche()
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
else if (nachricht.type === 'users') ladeRunde()
else if (nachricht.type === 'features') ladeWuensche()
})
})
onUnmounted(() => abbestellen?.())
return {
runde,
ich,
michSetzen,
gesehen,
gesehenIds,
gesehenZahl,
ladeGesehen,
meinVorschlag,
aufDemRad,
ladeVorschlag,
merkliste,
ladeMerkliste,
wuensche,
ladeWuensche,
}
}

View File

@@ -0,0 +1,287 @@
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus Postern
// rast unter der Marke durch und bremst auf dem Gewinner aus.
//
// 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.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { anlaufTon, fanfare, knacken, tick } from '../klang'
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
// 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)',
}
// Auf dem Handy sind die Poster kleiner, sonst passen keine drei ins Bild
const MASSE = {
breit: { kachel: 220, luecke: 10 },
schmal: { kachel: 140, luecke: 8 },
}
const SCHMAL = '(max-width: 640px)'
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
// verschwimmt die Marke mit dem Rand
export const FARBEN = [
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
]
export function useZiehung({ items }) {
const schmal = ref(window.matchMedia(SCHMAL).matches)
const masse = computed(() => {
const m = schmal.value ? MASSE.schmal : MASSE.breit
return { ...m, schritt: m.kachel + m.luecke }
})
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)
const kurve = ref(KURVEN.fahrt)
const bahn = ref(null)
let timer = null
let enthuellung = null
// 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) {
const { kachel, schritt } = masse.value
return -(stopp.position * schritt + kachel / 2 + stopp.versatz * kachel)
}
// Vor der ersten Ziehung stehen die Vorschläge einfach in einer Reihe
function ruheX() {
const { luecke, schritt } = masse.value
return -(items.value.length * schritt - luecke) / 2
}
function fahren(sekunden, k, ziel) {
kurve.value = k
dauer.value = sekunden
x.value = ziel
}
function ruhen() {
if (!ergebnis.value) fahren(0, KURVEN.fahrt, ruheX())
}
// 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 { luecke, schritt } = masse.value
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)
}
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)
}
function abbrechen() {
clearTimeout(timer)
clearTimeout(enthuellung)
}
async function abspielen(erg, uebersprungen = 0) {
abbrechen()
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 * masse.value.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 {
// Ziehung 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
}
} catch {
/* dann eben ohne */
}
}
function zurueckgesetzt() {
abbrechen()
anlauf.value = false
bruch.value = false
ergebnis.value = null
gewinner.value = null
dreht.value = false
}
// 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,
}))
})
// Dreht jemand das Handy, ändern sich die Maße — die Bahn rückt nach
const medien = window.matchMedia(SCHMAL)
const beiWechsel = (e) => {
schmal.value = e.matches
}
watch(schmal, () => {
if (dreht.value) return
if (ergebnis.value) fahren(0, KURVEN.fahrt, stoppX(ergebnis.value.stopps.at(-1)))
else ruhen()
})
onMounted(() => medien.addEventListener('change', beiWechsel))
onUnmounted(() => {
abbrechen()
medien.removeEventListener('change', beiWechsel)
})
return {
masse,
ergebnis,
dreht,
gewinner,
dauer,
x,
anlauf,
bruch,
kurve,
bahn,
kacheln,
ruhen,
abspielen,
laufendenDrehHolen,
zurueckgesetzt,
}
}

50
frontend/src/klang.js Normal file
View File

@@ -0,0 +1,50 @@
// Die Geräusche der Ziehung: ein Tick je Poster, das unter der Marke
// durchläuft, ein Aufladen im Anlauf, ein Knacken beim Bruch 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
export 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.
export 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)
}
export function anlaufTon(sekunden) {
ton(150, sekunden, 0.08, 'sine', 0, 520)
}
export function knacken() {
ton(120, 0.12, 0.25, 'square')
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
}
export function fanfare() {
ton(660, 0.5, 0.12, 'sine')
ton(990, 0.7, 0.1, 'sine', 0.12)
}