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

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ node_modules/
frontend/dist/ frontend/dist/
backend/static/ backend/static/
data/ data/
backend/backups/
data/backups/

View File

@@ -1,9 +1,12 @@
.PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop .PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop test
install: install:
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cd frontend && npm install cd frontend && npm install
test:
cd backend && .venv/bin/python -m pytest -q
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins # MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
# Leere und OBS findet keinen Server. # Leere und OBS findet keinen Server.
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst # Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst

39
backend/app/backup.py Normal file
View File

@@ -0,0 +1,39 @@
"""Nächtliche Kopie der Datenbank.
Sammlungen, Wertungen und Wünsche liegen nur in dieser einen Datei. Die
Kopie entsteht über SQLites eigene Backup-Schnittstelle — die ist auch
dann konsistent, wenn gerade jemand schreibt. Es bleiben die letzten
vierzehn Stände, jeder Tag einer.
"""
import logging
import sqlite3
from datetime import date
from .config import DB_PATH
log = logging.getLogger("app.backup")
BEHALTEN = 14
def sichern():
ordner = DB_PATH.parent / "backups"
ordner.mkdir(parents=True, exist_ok=True)
ziel = ordner / f"{DB_PATH.stem}-{date.today():%Y-%m-%d}{DB_PATH.suffix}"
quelle = sqlite3.connect(DB_PATH)
kopie = sqlite3.connect(ziel)
try:
quelle.backup(kopie)
finally:
kopie.close()
quelle.close()
# Die Namen sortieren nach Datum, das Alte steht vorn
alte = sorted(ordner.glob(f"{DB_PATH.stem}-*{DB_PATH.suffix}"))[:-BEHALTEN]
for alt in alte:
alt.unlink()
log.info("Datenbank gesichert: %s", ziel.name)
return ziel

View File

@@ -10,6 +10,7 @@ from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import ( from . import (
backup,
db, db,
events, events,
features, features,
@@ -69,6 +70,11 @@ app.add_middleware(
) )
async def sicherung():
# Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread
await asyncio.to_thread(backup.sichern)
async def guarded_sync(): async def guarded_sync():
if sync_lock.locked(): if sync_lock.locked():
log.info("Sync läuft bereits") log.info("Sync läuft bereits")
@@ -85,6 +91,9 @@ async def startup():
watched.init() watched.init()
wishlist.init() wishlist.init()
features.init() features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
# ein sauberer Stand
scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup")
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync") scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start() scheduler.start()
if not db.get_meta("last_sync"): if not db.get_meta("last_sync"):

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

@@ -0,0 +1 @@
pytest==8.4.2

91
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,91 @@
"""Testumgebung: eigene Datenbank, kein Netz.
Die Umgebung steht, bevor app.* importiert wird — config.py liest DB_PATH
beim Import. Die Schlüssel prüft config.py nur auf Vorhandensein.
"""
import os
import tempfile
from pathlib import Path
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
os.environ.setdefault("TMDB_TOKEN", "test")
os.environ.setdefault("MINIMAX_KEY", "test")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app import db, features, main, suggestions, users, watched, wishlist # noqa: E402
# Zählt, ob der Startup doch einen Sync anstoßen wollte
_SYNC_AUFRUFE = []
async def kein_sync():
_SYNC_AUFRUFE.append(True)
@pytest.fixture(scope="session", autouse=True)
def app_start():
"""Startup einmal je Lauf. last_sync steht vorher, damit main.startup
keinen TMDB-Sync startet; guarded_sync ist zur Sicherheit ersetzt."""
db.init()
with db.connect() as conn:
db.set_meta(conn, "last_sync", "test")
main.guarded_sync = kein_sync
with TestClient(main.app):
yield
@pytest.fixture
def sync_aufrufe():
return _SYNC_AUFRUFE
@pytest.fixture(autouse=True)
def frische_db(app_start):
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
db.DB_PATH.unlink(missing_ok=True)
for modul in (db, users, suggestions, watched, wishlist, features):
modul.init()
main.letzter_dreh.update(ergebnis=None, start=0.0)
# Ein Client je Person — jeder hält sein eigenes Cookie
@pytest.fixture
def client():
return TestClient(main.app)
@pytest.fixture
def client2():
return TestClient(main.app)
@pytest.fixture
def film():
"""Testfilm direkt in der Datenbank, ohne TMDB. Gibt die id zurück."""
def anlegen(movie_id, title=None):
with db.connect() as conn:
db.upsert(conn, {"id": movie_id, "title": title or f"Film {movie_id}"})
return movie_id
return anlegen
@pytest.fixture
def anmelden():
"""Client wählt einen Namen; legt ihn an, falls es ihn noch nicht gibt."""
def waehlen(client, name):
antwort = client.post("/api/users", json={"name": name})
if antwort.status_code == 200:
user_id = antwort.json()["id"]
else:
runde = client.get("/api/users").json()["items"]
user_id = next(u["id"] for u in runde if u["name"] == name)
return client.post("/api/users/waehlen", json={"user_id": user_id}).json()["mine"]
return waehlen

View File

@@ -0,0 +1,74 @@
"""Wünsche: Stimmen, Abhaken, Anmerkungen."""
from app import db
def wunsch(client, text="Dunkles Design"):
liste = client.post("/api/features", json={"text": text}).json()
return next(w for w in liste if w["text"] == text)
def test_leerer_wunsch_wird_abgelehnt(client):
assert client.post("/api/features", json={"text": " "}).status_code == 400
def test_stimme_toggelt(client):
w = wunsch(client)
assert w["mine"] is True
assert w["votes"] == 0
assert w["voted"] is False
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": True}
[w] = client.get("/api/features").json()
assert w["votes"] == 1
assert w["voted"] is True
assert client.post(f"/api/features/{w['id']}/vote").json() == {"voted": False}
[w] = client.get("/api/features").json()
assert w["votes"] == 0
assert w["voted"] is False
def test_erledigt_rutscht_ans_ende(client):
erster = wunsch(client, "A")
wunsch(client, "B")
# Mit Stimme steht A vorn
client.post(f"/api/features/{erster['id']}/vote")
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
antwort = client.patch(f"/api/features/{erster['id']}/done", json={"done": True})
assert antwort.json() == {"ok": True}
liste = client.get("/api/features").json()
assert [w["text"] for w in liste] == ["B", "A"]
assert liste[1]["done_at"] is not None
client.patch(f"/api/features/{erster['id']}/done", json={"done": False})
assert [w["text"] for w in client.get("/api/features").json()] == ["A", "B"]
def test_anmerkungen_anlegen_und_loeschen(client, client2):
w = wunsch(client)
antwort = client.post(f"/api/features/{w['id']}/notes", json={"text": "Mit Kontrast"})
assert antwort.json() == {"ok": True}
[w] = client.get("/api/features").json()
[notiz] = w["notes"]
assert notiz["text"] == "Mit Kontrast"
assert notiz["mine"] is True
[fremd] = client2.get("/api/features").json()
assert fremd["notes"][0]["mine"] is False
assert client.delete(f"/api/feature-notes/{notiz['id']}").json() == {"removed": 1}
assert client.get("/api/features").json()[0]["notes"] == []
def test_loeschen_raeumt_stimmen_und_anmerkungen_mit(client):
w = wunsch(client)
client.post(f"/api/features/{w['id']}/vote")
client.post(f"/api/features/{w['id']}/notes", json={"text": "Anmerkung"})
assert client.delete(f"/api/features/{w['id']}").json() == {"removed": 1}
assert client.get("/api/features").json() == []
with db.connect() as conn:
for tabelle in ("feature_votes", "feature_notes"):
assert conn.execute(f"SELECT COUNT(*) FROM {tabelle}").fetchone()[0] == 0

View File

@@ -0,0 +1,6 @@
"""Der Startup läuft im Test ohne TMDB-Sync."""
def test_startup_startet_keinen_sync(client, sync_aufrufe):
assert client.get("/api/health").json() == {"ok": True}
assert sync_aufrufe == []

View File

@@ -0,0 +1,94 @@
"""Die Ziehung: Streifen, Stopps, Gewinner."""
from app import suggestions
from app.suggestions import BRUCH_DAUER, DREH_DAUER, STREIFEN_LAENGE, STREIFEN_ZIEL
def vorschlagen(client, movie_id):
antwort = client.post("/api/suggestions", json={"movie_id": movie_id})
assert antwort.status_code == 200
return antwort.json()
def test_zweiter_vorschlag_ersetzt_den_ersten(client, film):
vorschlagen(client, film(1))
neu = vorschlagen(client, film(2))
liste = client.get("/api/suggestions").json()
assert [e["movie_id"] for e in liste["items"]] == [2]
assert liste["mine"] == neu["id"]
def test_dreh_liefert_streifen_und_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
ergebnis = client.post("/api/spin").json()
assert ergebnis["started"] is True
assert [k["movie_id"] for k in ergebnis["karten"]] == [1, 2]
assert len(ergebnis["streifen"]) == STREIFEN_LAENGE
assert set(ergebnis["streifen"]) <= {0, 1}
stopps = ergebnis["stopps"]
assert len(stopps) in (1, 2)
assert all(set(s) == {"position", "versatz"} for s in stopps)
assert stopps[0]["position"] == STREIFEN_ZIEL
assert ergebnis["gewinner"] == ergebnis["karten"][ergebnis["index"]]
def test_ziehung_haelt_auf_dem_gewinner(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
stopp_anzahlen = set()
for _ in range(60):
e = suggestions.ziehen()
stopps = e["stopps"]
stopp_anzahlen.add(len(stopps))
# Der letzte Halt zeigt den Gewinner
assert e["streifen"][stopps[-1]["position"]] == e["index"]
assert all(-0.5 < s["versatz"] < 0.5 for s in stopps)
if len(stopps) == 2:
# Bruch: ein paar Poster zurück, der erste Halt zeigt einen anderen
assert 1 <= stopps[0]["position"] - stopps[1]["position"] <= 4
assert e["streifen"][stopps[0]["position"]] != e["index"]
assert e["dauer"] == DREH_DAUER + BRUCH_DAUER
else:
assert e["dauer"] == DREH_DAUER
# Beide Fälle kamen vor
assert stopp_anzahlen == {1, 2}
def test_zweiter_dreh_waehrend_laufender_ziehung(client, film):
vorschlagen(client, film(1))
assert client.post("/api/spin").json()["started"] is True
assert client.post("/api/spin").json()["started"] is False
stand = client.get("/api/spin").json()
assert stand["running"] is True
assert stand["elapsed"] >= 0
assert stand["result"]["gewinner"]["movie_id"] == 1
def test_ohne_vorschlaege_kein_dreh(client):
assert client.post("/api/spin").status_code == 400
assert client.get("/api/spin").json() == {"running": False, "result": None}
def test_gesehene_filme_gewinnen_nicht(client, client2, film):
vorschlagen(client, film(1))
vorschlagen(client2, film(2))
client.post("/api/watched", json={"movie_id": 1})
for _ in range(50):
assert suggestions.ziehen()["gewinner"]["movie_id"] == 2
def test_runde_zuruecksetzen(client, film):
vorschlagen(client, film(1))
client.post("/api/spin")
assert client.delete("/api/suggestions/alle").json() == {"removed": 1}
assert client.get("/api/spin").json()["running"] is False
assert client.get("/api/suggestions").json()["items"] == []

View File

@@ -0,0 +1,46 @@
"""Besitz: Einträge hängen am Namen, ohne Namen an der Sitzung."""
def test_gleicher_name_in_anderem_browser_sieht_eigene_eintraege(
client, client2, anmelden, film
):
anmelden(client, "Anna")
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
client.post("/api/wishlist", json={"movie_id": film(2)})
client.post("/api/features", json={"text": "Dunkles Design"})
# Anna am zweiten Gerät
anmelden(client2, "Anna")
assert client2.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/wishlist").json()[0]["mine"] is True
assert client2.get("/api/features").json()[0]["mine"] is True
# Ben nicht
anmelden(client2, "Ben")
assert client2.get("/api/suggestions").json()["mine"] is None
assert client2.get("/api/wishlist").json()[0]["mine"] is False
assert client2.get("/api/features").json()[0]["mine"] is False
def test_namenswechsel_laesst_alte_eintraege_beim_alten_namen(client, anmelden, film):
anmelden(client, "Anna")
als_anna = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert als_anna["name"] == "Anna"
anmelden(client, "Ben")
assert client.get("/api/suggestions").json()["mine"] is None
# Bens Vorschlag ersetzt Annas nicht — einer je Name, nicht je Browser
als_ben = client.post("/api/suggestions", json={"movie_id": film(2)}).json()
liste = client.get("/api/suggestions").json()
assert [e["name"] for e in liste["items"]] == ["Anna", "Ben"]
assert liste["mine"] == als_ben["id"]
anmelden(client, "Anna")
assert client.get("/api/suggestions").json()["mine"] == als_anna["id"]
def test_ohne_namen_zaehlt_die_sitzung(client, client2, film):
vorschlag = client.post("/api/suggestions", json={"movie_id": film(1)}).json()
assert vorschlag["name"] is None
assert client.get("/api/suggestions").json()["mine"] == vorschlag["id"]
assert client2.get("/api/suggestions").json()["mine"] is None

View File

@@ -0,0 +1,63 @@
"""Wertungen: Sterne, Text, Schnitt."""
from app.watched import MAX_TEXT
def gesehen(client, film, movie_id=1):
"""Film anlegen und als gesehen eintragen. Gibt die watched-id zurück."""
film(movie_id)
return client.post("/api/watched", json={"movie_id": movie_id}).json()["id"]
def test_wertung_mit_text(client, film):
wid = gesehen(client, film)
antwort = client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
assert antwort.json() == {"watched_id": wid, "stars": 4, "text": "Gruselig"}
[eintrag] = client.get("/api/watched").json()
assert eintrag["average"] == 4.0
assert eintrag["count"] == 1
assert eintrag["mine"] == 4
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["ratings"][0]["mine"] is True
def test_text_none_behaelt_leer_loescht(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4, "text": "Gruselig"})
# Nur die Sterne nachjustiert: der Satz bleibt, es bleibt eine Stimme
client.post(f"/api/watched/{wid}/rating", json={"stars": 3})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine"] == 3
assert eintrag["mine_text"] == "Gruselig"
assert eintrag["count"] == 1
client.post(f"/api/watched/{wid}/rating", json={"stars": 3, "text": ""})
[eintrag] = client.get("/api/watched").json()
assert eintrag["mine_text"] is None
def test_text_wird_gekuerzt(client, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 5, "text": "x" * (MAX_TEXT + 100)})
[eintrag] = client.get("/api/watched").json()
assert len(eintrag["mine_text"]) == MAX_TEXT
def test_fremde_wertung_ist_nicht_meine(client, client2, film):
wid = gesehen(client, film)
client.post(f"/api/watched/{wid}/rating", json={"stars": 4})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["mine"] is None
assert eintrag["count"] == 1
assert eintrag["ratings"][0]["mine"] is False
client2.post(f"/api/watched/{wid}/rating", json={"stars": 2})
[eintrag] = client2.get("/api/watched").json()
assert eintrag["average"] == 3.0
assert eintrag["count"] == 2
assert eintrag["mine"] == 2

View File

@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { abonnieren } from './live'
import MovieCard from './components/MovieCard.vue' import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue' import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue' import AehnlichTab from './components/AehnlichTab.vue'
@@ -14,507 +13,31 @@ import StreamTab from './components/StreamTab.vue'
import WatchedTab from './components/WatchedTab.vue' import WatchedTab from './components/WatchedTab.vue'
import WheelTab from './components/WheelTab.vue' import WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.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 { ansicht, ohneGesehene, activeTab, host } = useEinstellungen()
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')
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film const {
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht' runde, ich, michSetzen,
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt') 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. const {
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten. search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene' toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus') } = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
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))
}
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über // Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil // ihren stärksten Teil
@@ -538,55 +61,58 @@ const visibleCanon = computed(() =>
filterGruppen(canonGroups.value, search.value.trim().toLowerCase()) 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(() => const aktuelleListe = computed(() =>
activeTab.value === 'netflix' ? visible.value : visibleCanon.value activeTab.value === 'netflix' ? visible.value : visibleCanon.value
) )
const sichtbar = computed(() => aktuelleListe.value.slice(0, gezeigt.value)) const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
liste: aktuelleListe,
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an ansicht,
watch([aktuelleListe, ansicht], () => {
gezeigt.value = STAPEL
}) })
async function mehrLaden() { const tabs = computed(() => [
if (gezeigt.value >= aktuelleListe.value.length) return { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
gezeigt.value += STAPEL {
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt id: 'canon',
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —, label: 'Horror-Kanon',
// käme sonst nie ein zweiter Stapel. count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
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()
}, },
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet { id: 'search', label: 'Suche', count: null },
{ rootMargin: '600px' } { 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 // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
// Wechsel neu anmelden // den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
watch(nachschub, (el, vorher) => { // Bilder kommen gleich danach.
if (vorher) beobachter.unobserve(vorher) //
if (el) beobachter.observe(el) // Ü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, // 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. // sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
@@ -607,60 +133,27 @@ const series = computed(() => {
) )
}) })
const currentCount = computed(() => // Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length const aehnlichStart = ref(null)
)
async function load() { function zurAehnlichkeitsuche(movie) {
loading.value = true aehnlichStart.value = movie
error.value = '' selected.value = null
try { activeTab.value = 'aehnlich'
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
}
} }
let abbestellen = null const rad = ref(null)
onMounted(() => { function nachVorschlag() {
load()
ladeVorschlag() ladeVorschlag()
ladeGesehen() rad.value?.neu()
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 })
})
onUnmounted(() => { onMounted(load)
window.removeEventListener('keydown', tastatur)
window.removeEventListener('scroll', beimScrollen)
abbestellen?.()
})
</script> </script>
<template> <template>
<div class="page" :class="{ <div class="page" :class="{ breit: !mitFilter }">
breit: [
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
].includes(activeTab),
}">
<nav class="tabs"> <nav class="tabs">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
@@ -672,6 +165,15 @@ onUnmounted(() => {
{{ tab.label }} {{ tab.label }}
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span> <span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button> </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" /> <NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
<span v-else class="host">Host</span> <span v-else class="host">Host</span>
</nav> </nav>
@@ -687,8 +189,8 @@ onUnmounted(() => {
/> />
<AehnlichTab <AehnlichTab
:gesehen-ids="gesehenIds"
v-else v-else
:gesehen-ids="gesehenIds"
:filter="suchFilter" :filter="suchFilter"
:ansicht="ansicht" :ansicht="ansicht"
:start="aehnlichStart" :start="aehnlichStart"
@@ -701,6 +203,7 @@ onUnmounted(() => {
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene" v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges" v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER" :regler="REGLER"
:grenzen="grenzen" :grenzen="grenzen"
:options="genreOptions" :options="genreOptions"
@@ -755,40 +258,40 @@ onUnmounted(() => {
<div v-else class="layout"> <div v-else class="layout">
<div class="results"> <div class="results">
<p v-if="loading" class="note">Lade Katalog…</p> <p v-if="loading" class="note">Lade Katalog…</p>
<p v-else-if="error" class="note error">{{ error }}</p> <p v-else-if="error" class="note error">{{ error }}</p>
<p v-else-if="!movies.length" class="note"> <p v-else-if="!movies.length" class="note">
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch. Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
</p> </p>
<p v-else-if="!currentCount" class="note"> <p v-else-if="!aktuelleListe.length" class="note">
Nichts gefunden{{ search ? ` für „${search}“` : '' }}. Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
</p> </p>
<div class="grid" :class="ansicht"> <div class="grid" :class="ansicht">
<template v-for="entry in sichtbar" :key="entry.group"> <template v-for="entry in sichtbar" :key="entry.group">
<MovieCard <MovieCard
v-if="entry.parts.length === 1" v-if="entry.parts.length === 1"
:movie="entry.parts[0]" :movie="entry.parts[0]"
:gesehen="gesehenIds.has(entry.parts[0].id)" :gesehen="gesehenIds.has(entry.parts[0].id)"
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)" :auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
:ansicht="ansicht" :ansicht="ansicht"
@open="filmOeffnen" @open="filmOeffnen"
/> />
<SeriesCard <SeriesCard
v-else v-else
:parts="entry.parts" :parts="entry.parts"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"
:start="entry.best" :start="entry.best"
:auf-dem-rad="aufDemRad" :auf-dem-rad="aufDemRad"
:ansicht="ansicht" :ansicht="ansicht"
@open="filmOeffnen" @open="filmOeffnen"
/> />
</template> </template>
</div> </div>
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub"> <div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
Lädt weitere Filme … Lädt weitere Filme …
</div> </div>
</div> </div>
<FilterSidebar <FilterSidebar
@@ -798,6 +301,7 @@ onUnmounted(() => {
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene" v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges" v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER" :regler="REGLER"
:grenzen="grenzen" :grenzen="grenzen"
:options="genreOptions" :options="genreOptions"
@@ -817,18 +321,17 @@ onUnmounted(() => {
v-if="selected" v-if="selected"
:movie="selected" :movie="selected"
:series="series" :series="series"
@close="selected = null"
@open="filmOeffnen"
:mein-vorschlag="meinVorschlag" :mein-vorschlag="meinVorschlag"
:host="host" :host="host"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)" :gesehen="gesehen.some((e) => e.movie_id === selected.id)"
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)" :gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
@close="selected = null"
@open="filmOeffnen"
@gemerkt="ladeMerkliste" @gemerkt="ladeMerkliste"
@suggested="nachVorschlag" @suggested="nachVorschlag"
@watched="ladeGesehen" @watched="ladeGesehen"
@aehnlich="zurAehnlichkeitsuche" @aehnlich="zurAehnlichkeitsuche"
/> />
</div> </div>
</template> </template>
@@ -839,7 +342,7 @@ onUnmounted(() => {
padding-right: calc(var(--sidebar) + 1.25rem); padding-right: calc(var(--sidebar) + 1.25rem);
} }
/* Such- und Rad-Reiter haben keine Filterleiste */ /* Reiter ohne Filterleiste */
.page.breit { .page.breit {
padding-right: 1.25rem; padding-right: 1.25rem;
} }
@@ -886,6 +389,7 @@ onUnmounted(() => {
padding: 0.8rem 1rem 0.6rem; padding: 0.8rem 1rem 0.6rem;
color: var(--muted); color: var(--muted);
font-size: 0.95rem; font-size: 0.95rem;
white-space: nowrap;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
} }
@@ -898,6 +402,12 @@ onUnmounted(() => {
border-bottom-color: var(--accent); 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 { .host {
align-self: center; align-self: center;
margin-left: 0.4rem; margin-left: 0.4rem;
@@ -938,26 +448,6 @@ onUnmounted(() => {
border-left: 1px solid var(--line); 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 { .nachschub {
padding: 2rem 0; padding: 2rem 0;
color: var(--muted); color: var(--muted);
@@ -990,4 +480,82 @@ onUnmounted(() => {
color: var(--accent); 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> </style>

View File

@@ -258,4 +258,21 @@ h2 {
.card.details .shots { .card.details .shots {
overflow: hidden; 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> </style>

View File

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

View File

@@ -151,6 +151,9 @@ async function open(movie) {
.big-search { .big-search {
flex: 1; 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; padding: 0.9rem 2.6rem 0.9rem 1.1rem;
font-size: 1.05rem; font-size: 1.05rem;
} }
@@ -170,6 +173,7 @@ async function open(movie) {
} }
.neu { .neu {
flex-shrink: 0;
padding: 0 1.4rem; padding: 0 1.4rem;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;

View File

@@ -360,4 +360,25 @@ h2:hover {
.card.details .shots { .card.details .shots {
overflow: hidden; 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> </style>

View File

@@ -257,6 +257,7 @@ onUnmounted(() => {
.rahmen { .rahmen {
position: relative; position: relative;
max-width: 100%;
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */ /* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
max-height: calc(100vh - 5.5rem); max-height: calc(100vh - 5.5rem);
aspect-ratio: 16 / 9; 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> <style scoped>
.grid { .grid {
display: 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; gap: 1.1rem;
} }
@@ -716,4 +716,14 @@ h3:hover {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
@media (max-width: 640px) {
.rezension-hintergrund {
padding: 0.5rem;
}
.rezension {
padding: 1rem;
}
}
</style> </style>

View File

@@ -1,9 +1,12 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images' import { posterUrl } from '../images'
import { abonnieren } from '../live' import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
const props = defineProps({ defineProps({
host: Boolean, host: Boolean,
// Beides hält die App schon aktuell — auch nach einem Namenswechsel // Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] }, merkliste: { type: Array, default: () => [] },
@@ -16,118 +19,10 @@ const items = ref([])
const laden = ref(true) const laden = ref(true)
const fehler = ref('') const fehler = ref('')
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus const {
// Postern rast unter der Marke durch und bremst auf dem Gewinner aus. masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
const KACHEL = 220 kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
const LUECKE = 10 } = useZiehung({ items })
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',
]
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis // Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let abbestellen = null let abbestellen = null
@@ -136,138 +31,12 @@ function verbinden() {
abbestellen = abonnieren((nachricht) => { abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions') laden_() if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data) else if (nachricht.type === 'spin') abspielen(nachricht.data)
else if (nachricht.type === 'reset') zurueckgesetzt() else if (nachricht.type === 'reset') {
}) zurueckgesetzt()
} emit('reset')
laden_()
// 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
} }
} catch { })
/* dann eben ohne */
}
} }
async function laden_() { async function laden_() {
@@ -278,10 +47,7 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden') if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json() const daten = await resp.json()
items.value = daten.items items.value = daten.items
if (!ergebnis.value) { ruhen()
dauer.value = 0
x.value = ruheX()
}
} catch (err) { } catch (err) {
fehler.value = err.message fehler.value = err.message
} finally { } 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() { async function drehen() {
if (dreht.value || !items.value.length) return 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() klang()
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
try { try {
const resp = await fetch('/api/spin', { method: 'POST' }) const resp = await fetch('/api/spin', { method: 'POST' })
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.' 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() { async function zuruecksetzen() {
// Der Server meldet den Reset an alle zurück, auch an uns // Der Server meldet den Reset an alle zurück, auch an uns
try { 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) { async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`) const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json()) if (resp.ok) emit('open', await resp.json())
} }
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
verbinden()
})
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
</script> </script>
<template> <template>
@@ -402,7 +98,10 @@ async function oeffnen(item) {
<div class="buehne"> <div class="buehne">
<p v-if="fehler" class="note error">{{ fehler }}</p> <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 class="marke" :class="{ anlauf, bruch }"></div>
<div <div
ref="bahn" ref="bahn"
@@ -468,61 +167,16 @@ async function oeffnen(item) {
</div> </div>
</div> </div>
<aside class="liste"> <VorschlagsListe
<div v-if="!host" class="sammlung-wuerfeln"> :items="items"
<div class="schalter"> :laden="laden"
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true"> :host="host"
Horror ({{ anzahlHorror }}) :merkliste="merkliste"
</button> :mine="mine"
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false"> :gesehen-ids="gesehenIds"
Kein Horror ({{ anzahlOhne }}) @open="oeffnen"
</button> @fehler="fehler = $event"
</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>
</div> </div>
</template> </template>
@@ -546,7 +200,8 @@ async function oeffnen(item) {
.fenster { .fenster {
position: relative; position: relative;
align-self: stretch; 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; overflow: hidden;
/* Links und rechts läuft der Streifen ins Dunkel aus */ /* Links und rechts läuft der Streifen ins Dunkel aus */
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
@@ -557,7 +212,7 @@ async function oeffnen(item) {
top: 20px; top: 20px;
left: 50%; left: 50%;
display: flex; display: flex;
gap: 10px; gap: var(--luecke);
will-change: transform; will-change: transform;
/* Die Kurve setzt das Skript je Phase */ /* Die Kurve setzt das Skript je Phase */
transition-property: transform; transition-property: transform;
@@ -565,8 +220,8 @@ async function oeffnen(item) {
.kachel { .kachel {
position: relative; position: relative;
flex: 0 0 220px; flex: 0 0 var(--kachel);
height: 330px; height: calc(var(--kachel) * 1.5);
overflow: hidden; overflow: hidden;
background: var(--bg-soft); background: var(--bg-soft);
border: 2px solid; border: 2px solid;
@@ -634,6 +289,28 @@ async function oeffnen(item) {
pointer-events: none; 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 */ /* Im Anlauf pulst die Marke dreimal — gleich geht es los */
.marke.anlauf { .marke.anlauf {
animation: puls 0.33s ease-in-out 3; 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 { .leer-bahn {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -702,6 +357,21 @@ async function oeffnen(item) {
gap: 0.7rem; 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 { .reset {
padding: 0.7rem 1.4rem; padding: 0.7rem 1.4rem;
color: var(--muted); color: var(--muted);
@@ -721,73 +391,6 @@ async function oeffnen(item) {
cursor: default; 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 { .gewinner {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -837,111 +440,6 @@ async function oeffnen(item) {
font-size: 0.85rem; 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 { .note {
padding: 3rem 0; padding: 3rem 0;
color: var(--muted); color: var(--muted);

View File

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