update
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@ node_modules/
|
||||
frontend/dist/
|
||||
backend/static/
|
||||
data/
|
||||
|
||||
backend/backups/
|
||||
data/backups/
|
||||
|
||||
5
Makefile
5
Makefile
@@ -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:
|
||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
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
|
||||
# Leere und OBS findet keinen Server.
|
||||
# Räumt immer erst auf: ein gestoppter Container von vorhin belegt sonst
|
||||
|
||||
39
backend/app/backup.py
Normal file
39
backend/app/backup.py
Normal 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
|
||||
@@ -10,6 +10,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import (
|
||||
backup,
|
||||
db,
|
||||
events,
|
||||
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():
|
||||
if sync_lock.locked():
|
||||
log.info("Sync läuft bereits")
|
||||
@@ -85,6 +91,9 @@ async def startup():
|
||||
watched.init()
|
||||
wishlist.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.start()
|
||||
if not db.get_meta("last_sync"):
|
||||
|
||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
1
backend/requirements-dev.txt
Normal file
1
backend/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
pytest==8.4.2
|
||||
91
backend/tests/conftest.py
Normal file
91
backend/tests/conftest.py
Normal 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
|
||||
74
backend/tests/test_features.py
Normal file
74
backend/tests/test_features.py
Normal 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
|
||||
6
backend/tests/test_start.py
Normal file
6
backend/tests/test_start.py
Normal 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 == []
|
||||
94
backend/tests/test_suggestions.py
Normal file
94
backend/tests/test_suggestions.py
Normal 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"] == []
|
||||
46
backend/tests/test_users.py
Normal file
46
backend/tests/test_users.py
Normal 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
|
||||
63
backend/tests/test_watched.py
Normal file
63
backend/tests/test_watched.py
Normal 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
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { abonnieren } from './live'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import AehnlichTab from './components/AehnlichTab.vue'
|
||||
@@ -14,507 +13,31 @@ import StreamTab from './components/StreamTab.vue'
|
||||
import WatchedTab from './components/WatchedTab.vue'
|
||||
import WheelTab from './components/WheelTab.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
import { useEinstellungen } from './composables/useEinstellungen'
|
||||
import { useMeins } from './composables/useMeins'
|
||||
import { matches, useKatalog } from './composables/useKatalog'
|
||||
import { REGLER, useFilter } from './composables/useFilter'
|
||||
import { useLangeListe } from './composables/useLangeListe'
|
||||
|
||||
const movies = ref([])
|
||||
const status = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('random')
|
||||
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
||||
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
|
||||
// "More like this" zeigt. Im Katalog gibt es die nicht.
|
||||
const suchSort = ref('relevanz')
|
||||
const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen()
|
||||
|
||||
// Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film
|
||||
const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
||||
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
||||
const {
|
||||
runde, ich, michSetzen,
|
||||
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
|
||||
meinVorschlag, aufDemRad, ladeVorschlag,
|
||||
merkliste, ladeMerkliste,
|
||||
wuensche, ladeWuensche,
|
||||
} = useMeins({ host })
|
||||
|
||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
||||
const {
|
||||
movies, loading, error, load,
|
||||
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
||||
} = useKatalog()
|
||||
|
||||
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
|
||||
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
||||
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene'
|
||||
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus')
|
||||
|
||||
watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus'))
|
||||
const selected = ref(null)
|
||||
|
||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
||||
// Bilder kommen gleich danach.
|
||||
//
|
||||
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
||||
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
||||
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
||||
async function filmOeffnen(movie) {
|
||||
selected.value = movie
|
||||
if (!movie?.id) return
|
||||
try {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben mit den Listendaten */
|
||||
}
|
||||
}
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = [
|
||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||
'wuensche', 'verwaltung', 'stream',
|
||||
]
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||
|
||||
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||
const canonMovies = computed(() =>
|
||||
movies.value.filter((m) => m.canon_group !== null)
|
||||
)
|
||||
|
||||
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||
// sonst wandern die Enden bei jeder Auswahl
|
||||
const REGLER = [
|
||||
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
|
||||
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
|
||||
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
|
||||
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
|
||||
]
|
||||
|
||||
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
|
||||
// ließen — TMDB reicht von den Anfängen bis heute
|
||||
const SUCH_GRENZEN = {
|
||||
vote_count: { min: 50, max: 50000 },
|
||||
runtime: { min: 0, max: 240 },
|
||||
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||
vote_average: { min: 0, max: 10 },
|
||||
}
|
||||
|
||||
// Die 19 Genres, die TMDB kennt
|
||||
const SUCH_GENRES = [
|
||||
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
|
||||
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
|
||||
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
|
||||
'Western',
|
||||
].map((genre) => ({ genre, count: null }))
|
||||
|
||||
const grenzen = computed(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const out = {}
|
||||
for (const regler of REGLER) {
|
||||
const werte = pool.map((m) => m[regler.feld]).filter((v) => v != null)
|
||||
const min = werte.length ? Math.min(...werte) : 0
|
||||
const max = werte.length ? Math.max(...werte) : 0
|
||||
out[regler.key] = {
|
||||
min: regler.step < 1 ? Math.floor(min * 10) / 10 : Math.floor(min),
|
||||
max: regler.step < 1 ? Math.ceil(max * 10) / 10 : Math.ceil(max),
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const ranges = ref({})
|
||||
|
||||
// Beim Reiterwechsel passen die alten Werte nicht mehr zur neuen Skala
|
||||
watch(
|
||||
grenzen,
|
||||
(neu) => {
|
||||
ranges.value = Object.fromEntries(
|
||||
Object.entries(neu).map(([k, v]) => [k, [v.min, v.max]])
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function resetRanges() {
|
||||
ranges.value = Object.fromEntries(
|
||||
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
|
||||
)
|
||||
}
|
||||
|
||||
function imBereich(movie) {
|
||||
for (const regler of REGLER) {
|
||||
const spanne = ranges.value[regler.key]
|
||||
const grenze = grenzen.value[regler.key]
|
||||
if (!spanne) continue
|
||||
// Ein unbegrenzter Regler darf Filme ohne Wert nicht aussortieren
|
||||
if (spanne[0] === grenze.min && spanne[1] === grenze.max) continue
|
||||
const wert = movie[regler.feld]
|
||||
if (wert == null || wert < spanne[0] || wert > spanne[1]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleGenre(kind, genre) {
|
||||
const liste = kind === 'include' ? include : exclude
|
||||
const andere = kind === 'include' ? exclude : include
|
||||
const at = liste.value.indexOf(genre)
|
||||
if (at === -1) {
|
||||
liste.value = [...liste.value, genre]
|
||||
// Ein Genre kann nicht gleichzeitig gezeigt und ausgeblendet werden
|
||||
andere.value = andere.value.filter((g) => g !== genre)
|
||||
} else {
|
||||
liste.value = liste.value.filter((g) => g !== genre)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll(kind) {
|
||||
const liste = kind === 'include' ? include : exclude
|
||||
const andere = kind === 'include' ? exclude : include
|
||||
const alle = genreOptions.value.map((o) => o.genre)
|
||||
// Sind schon alle drin, ist der zweite Klick ein Leeren
|
||||
if (liste.value.length === alle.length) {
|
||||
liste.value = []
|
||||
} else {
|
||||
liste.value = alle
|
||||
andere.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function resetGenres() {
|
||||
include.value = []
|
||||
exclude.value = []
|
||||
resetRanges()
|
||||
}
|
||||
|
||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||
const genreOptions = computed(() => {
|
||||
if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES
|
||||
const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value
|
||||
const counts = new Map()
|
||||
for (const movie of pool) {
|
||||
for (const genre of movie.genres || []) counts.set(genre, (counts.get(genre) || 0) + 1)
|
||||
}
|
||||
// Alphabetisch, damit man ein Genre an derselben Stelle wiederfindet.
|
||||
// Nach Häufigkeit sortiert wanderten die Badges bei jedem Reiterwechsel.
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0], 'de'))
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
})
|
||||
|
||||
// Reihen bleiben zusammen, damit eine Karte durch ihre Teile blättert.
|
||||
// Grundlage ist TMDBs Collection — Herr der Ringe belegt sonst drei Kacheln.
|
||||
function groupBy(movies, schluessel) {
|
||||
const byKey = new Map()
|
||||
const einzeln = []
|
||||
for (const movie of movies) {
|
||||
const key = schluessel(movie)
|
||||
if (key === null || key === undefined) {
|
||||
einzeln.push(movie)
|
||||
continue
|
||||
}
|
||||
if (!byKey.has(key)) byKey.set(key, [])
|
||||
byKey.get(key).push(movie)
|
||||
}
|
||||
|
||||
const gruppen = []
|
||||
for (const [key, parts] of byKey) {
|
||||
// Eine Reihe mit nur einem verfügbaren Teil ist keine Reihe
|
||||
if (parts.length === 1) einzeln.push(parts[0])
|
||||
else gruppen.push({ group: key, parts })
|
||||
}
|
||||
return { gruppen, einzeln }
|
||||
}
|
||||
|
||||
function sortiereReihe(parts) {
|
||||
const sorted = [...parts].sort(
|
||||
(a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||
(a.canon_pos ?? 0) - (b.canon_pos ?? 0)
|
||||
)
|
||||
// Die Karte startet beim stärksten Teil, danach sortiert auch die
|
||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt
|
||||
let best = 0
|
||||
sorted.forEach((part, i) => {
|
||||
if (score(part) > score(sorted[best])) best = i
|
||||
})
|
||||
return { parts: sorted, best }
|
||||
}
|
||||
|
||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe.
|
||||
// Die Vorlage gruppiert das Conjuring-Universum nach Sub-Reihen statt nach
|
||||
// Jahr — hier zählt das Erscheinungsjahr.
|
||||
const canonGroups = computed(() => {
|
||||
const byGroup = new Map()
|
||||
for (const movie of canonMovies.value) {
|
||||
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
|
||||
byGroup.get(movie.canon_group).push(movie)
|
||||
}
|
||||
return [...byGroup.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([group, parts]) => ({ group, ...sortiereReihe(parts) }))
|
||||
})
|
||||
|
||||
// Im Netflix-Reiter bilden TMDBs Collections die Reihen
|
||||
const netflixEntries = computed(() => {
|
||||
const { gruppen, einzeln } = groupBy(netflixMovies.value, (m) => m.collection_id)
|
||||
return [
|
||||
...gruppen.map((g) => ({ group: 'c' + g.group, ...sortiereReihe(g.parts) })),
|
||||
...einzeln.map((m) => ({ group: 'm' + m.id, parts: [m], best: 0 })),
|
||||
]
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
||||
{
|
||||
id: 'canon',
|
||||
label: 'Horror-Kanon',
|
||||
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
||||
},
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
...(host.value
|
||||
? []
|
||||
: [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||
])
|
||||
|
||||
// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB
|
||||
const suchFilter = computed(() => {
|
||||
const spanne = (key) => ranges.value[key] || []
|
||||
return {
|
||||
sort: suchSort.value,
|
||||
include: include.value,
|
||||
exclude: exclude.value,
|
||||
stimmen: spanne('vote_count'),
|
||||
dauer: spanne('runtime'),
|
||||
jahr: spanne('year'),
|
||||
note: spanne('vote_average'),
|
||||
grenzen: grenzen.value,
|
||||
}
|
||||
})
|
||||
|
||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||
const aehnlichStart = ref(null)
|
||||
|
||||
function zurAehnlichkeitsuche(movie) {
|
||||
aehnlichStart.value = movie
|
||||
selected.value = null
|
||||
activeTab.value = 'aehnlich'
|
||||
}
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
// Bei 2500 Karten kommt man sonst nicht mehr nach oben
|
||||
const obenKnopf = ref(false)
|
||||
|
||||
function beimScrollen() {
|
||||
obenKnopf.value = window.scrollY > 800
|
||||
}
|
||||
|
||||
// Eigene Animation statt behavior: 'smooth'. Das rechnet mit fester
|
||||
// Geschwindigkeit und bräuchte über 300.000 Pixel Seitenhöhe Minuten.
|
||||
const OBEN_DAUER = 450
|
||||
|
||||
function nachOben() {
|
||||
const start = window.scrollY
|
||||
const beginn = performance.now()
|
||||
|
||||
function schritt(jetzt) {
|
||||
const t = Math.min(1, (jetzt - beginn) / OBEN_DAUER)
|
||||
// Sanft an- und auslaufend
|
||||
const p = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
|
||||
window.scrollTo(0, start * (1 - p))
|
||||
if (t < 1) requestAnimationFrame(schritt)
|
||||
}
|
||||
|
||||
requestAnimationFrame(schritt)
|
||||
}
|
||||
|
||||
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
|
||||
// ob der Film schon lief
|
||||
const gesehen = ref([])
|
||||
|
||||
async function ladeGesehen() {
|
||||
try {
|
||||
const resp = await fetch('/api/watched')
|
||||
if (resp.ok) gesehen.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
// Für die Karten: nur die Kennungen, als Menge zum schnellen Nachschlagen
|
||||
const gesehenIds = computed(
|
||||
() => new Set(gesehen.value.map((e) => e.movie_id))
|
||||
)
|
||||
|
||||
// Ausgeblendete Filme zählen für Gäste nicht mit
|
||||
const gesehenZahl = computed(
|
||||
() => gesehen.value.filter((e) => host.value || !e.hidden).length
|
||||
)
|
||||
|
||||
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
|
||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
||||
// Die Runde: der Host pflegt die Namen, alle anderen wählen sich einen aus.
|
||||
// Den Namen setzt danach der Server an Vorschläge und Wertungen.
|
||||
const runde = ref([])
|
||||
const ich = ref(null)
|
||||
|
||||
async function michSetzen(id) {
|
||||
await fetch('/api/users/waehlen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id ?? null }),
|
||||
})
|
||||
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
|
||||
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
|
||||
await ladeRunde()
|
||||
ladeVorschlag()
|
||||
ladeMerkliste()
|
||||
ladeGesehen()
|
||||
ladeWuensche()
|
||||
}
|
||||
|
||||
async function ladeRunde() {
|
||||
try {
|
||||
const resp = await fetch('/api/users')
|
||||
if (!resp.ok) return
|
||||
const daten = await resp.json()
|
||||
runde.value = daten.items
|
||||
ich.value = daten.mine
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_SCHLUESSEL = 'filmabend_host'
|
||||
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
|
||||
|
||||
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
|
||||
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
|
||||
watch(host, (an) => {
|
||||
// Verwaltung sieht nur der Host, Vorschläge nur die Gäste
|
||||
const weg = an ? 'merkliste' : 'verwaltung'
|
||||
if (activeTab.value === weg) activeTab.value = 'netflix'
|
||||
})
|
||||
|
||||
function tastatur(e) {
|
||||
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
||||
// je nach System ein anderes Zeichen
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
||||
e.preventDefault()
|
||||
host.value = !host.value
|
||||
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
|
||||
}
|
||||
}
|
||||
|
||||
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||
const meinVorschlag = ref(null)
|
||||
|
||||
// Alle Filme auf dem Rad, damit die Karten es zeigen können
|
||||
const aufDemRad = ref([])
|
||||
|
||||
// Die eigene Merkliste — was man selbst sehen möchte, unabhängig vom Rad
|
||||
const merkliste = ref([])
|
||||
|
||||
// Was der Seite noch fehlt
|
||||
const wuensche = ref([])
|
||||
|
||||
async function ladeWuensche() {
|
||||
try {
|
||||
const resp = await fetch('/api/features')
|
||||
if (resp.ok) wuensche.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeMerkliste() {
|
||||
try {
|
||||
const resp = await fetch('/api/wishlist')
|
||||
if (resp.ok) merkliste.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeVorschlag() {
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions')
|
||||
if (!resp.ok) return
|
||||
const daten = await resp.json()
|
||||
meinVorschlag.value = daten.items.find((i) => i.id === daten.mine) || null
|
||||
aufDemRad.value = daten.items.map((i) => i.movie_id)
|
||||
} catch {
|
||||
meinVorschlag.value = null
|
||||
aufDemRad.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function nachVorschlag() {
|
||||
ladeVorschlag()
|
||||
rad.value?.neu()
|
||||
}
|
||||
|
||||
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
||||
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||
const MIN_VOTES = 150
|
||||
|
||||
const average = computed(() => {
|
||||
const rated = movies.value.filter((m) => m.vote_count)
|
||||
if (!rated.length) return 0
|
||||
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
|
||||
})
|
||||
|
||||
function score(movie) {
|
||||
const votes = movie.vote_count || 0
|
||||
return (
|
||||
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
|
||||
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
|
||||
)
|
||||
}
|
||||
|
||||
// Ein fester Wurf je Film. Mit Math.random() im Vergleich springen die
|
||||
// Karten bei jedem Filtern neu durcheinander.
|
||||
const wuerfel = new Map()
|
||||
|
||||
function zufall(movie) {
|
||||
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
|
||||
return wuerfel.get(movie.id)
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
random: (a, b) => zufall(a) - zufall(b),
|
||||
rating: (a, b) => score(b) - score(a),
|
||||
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||
}
|
||||
|
||||
function matches(movie, term) {
|
||||
return (
|
||||
movie.title.toLowerCase().includes(term) ||
|
||||
(movie.original_title || '').toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
|
||||
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
|
||||
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||
function nichtGesehen(movie) {
|
||||
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
|
||||
}
|
||||
|
||||
function passt(movie) {
|
||||
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
|
||||
}
|
||||
|
||||
function hasGenre(movie) {
|
||||
const genres = movie.genres || []
|
||||
if (exclude.value.length && genres.some((g) => exclude.value.includes(g))) {
|
||||
return false
|
||||
}
|
||||
return !include.value.length || genres.some((g) => include.value.includes(g))
|
||||
}
|
||||
const {
|
||||
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
|
||||
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
|
||||
} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
||||
|
||||
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
||||
// ihren stärksten Teil
|
||||
@@ -538,55 +61,58 @@ const visibleCanon = computed(() =>
|
||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
||||
)
|
||||
|
||||
// Gefiltert wird über den ganzen Katalog, gerendert nur der Anfang davon.
|
||||
// Alle 2554 Karten auf einmal sind in der Detailansicht über zehntausend
|
||||
// Bilder — der Browser meldet dann "Seite reagiert nicht".
|
||||
const STAPEL = 100
|
||||
const gezeigt = ref(STAPEL)
|
||||
|
||||
const aktuelleListe = computed(() =>
|
||||
activeTab.value === 'netflix' ? visible.value : visibleCanon.value
|
||||
)
|
||||
|
||||
const sichtbar = computed(() => aktuelleListe.value.slice(0, gezeigt.value))
|
||||
|
||||
// Jede neue Suche, jeder Filter und jeder Reiterwechsel fängt oben an
|
||||
watch([aktuelleListe, ansicht], () => {
|
||||
gezeigt.value = STAPEL
|
||||
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
|
||||
liste: aktuelleListe,
|
||||
ansicht,
|
||||
})
|
||||
|
||||
async function mehrLaden() {
|
||||
if (gezeigt.value >= aktuelleListe.value.length) return
|
||||
gezeigt.value += STAPEL
|
||||
// Der Beobachter meldet sich nur beim Wechsel sichtbar/unsichtbar. Bleibt
|
||||
// der Fuß nach dem Nachladen im Blick — großer Bildschirm, kurze Liste —,
|
||||
// käme sonst nie ein zweiter Stapel.
|
||||
await nextTick()
|
||||
const el = nachschub.value
|
||||
if (el && el.getBoundingClientRect().top < window.innerHeight + 600) mehrLaden()
|
||||
}
|
||||
|
||||
// Der Fuß der Liste meldet sich, sobald er in Sichtweite kommt. Ein
|
||||
// Scroll-Handler müsste bei jedem Pixel rechnen, das hier nicht.
|
||||
// Gleich hier angelegt, nicht in onMounted: die Vorlage setzt die Referenz
|
||||
// schon beim ersten Zeichnen, da muss der Beobachter bereits stehen.
|
||||
const nachschub = ref(null)
|
||||
const beobachter = new IntersectionObserver(
|
||||
(eintraege) => {
|
||||
if (eintraege.some((e) => e.isIntersecting)) mehrLaden()
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
||||
{
|
||||
id: 'canon',
|
||||
label: 'Horror-Kanon',
|
||||
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
||||
},
|
||||
// Vorlauf, damit der Nachschub schon da ist, bevor die Liste endet
|
||||
{ rootMargin: '600px' }
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||
])
|
||||
|
||||
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
|
||||
// klappt sie über einen Knopf auf
|
||||
const filterOffen = ref(false)
|
||||
const mitFilter = computed(() =>
|
||||
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
|
||||
)
|
||||
|
||||
onUnmounted(() => beobachter.disconnect())
|
||||
const selected = ref(null)
|
||||
|
||||
// Der Fuß verschwindet und kommt wieder, je nach Filter — also bei jedem
|
||||
// Wechsel neu anmelden
|
||||
watch(nachschub, (el, vorher) => {
|
||||
if (vorher) beobachter.unobserve(vorher)
|
||||
if (el) beobachter.observe(el)
|
||||
})
|
||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||
// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen
|
||||
// Bilder kommen gleich danach.
|
||||
//
|
||||
// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu
|
||||
// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB,
|
||||
// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf.
|
||||
async function filmOeffnen(movie) {
|
||||
selected.value = movie
|
||||
if (!movie?.id) return
|
||||
try {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben mit den Listendaten */
|
||||
}
|
||||
}
|
||||
|
||||
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
||||
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
||||
@@ -607,60 +133,27 @@ const series = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const currentCount = computed(() =>
|
||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
||||
)
|
||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||
const aehnlichStart = ref(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [moviesRes, statusRes] = await Promise.all([
|
||||
fetch('/api/movies'),
|
||||
fetch('/api/status'),
|
||||
])
|
||||
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
|
||||
movies.value = await moviesRes.json()
|
||||
status.value = await statusRes.json()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
function zurAehnlichkeitsuche(movie) {
|
||||
aehnlichStart.value = movie
|
||||
selected.value = null
|
||||
activeTab.value = 'aehnlich'
|
||||
}
|
||||
|
||||
let abbestellen = null
|
||||
const rad = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
function nachVorschlag() {
|
||||
ladeVorschlag()
|
||||
ladeGesehen()
|
||||
ladeRunde()
|
||||
ladeMerkliste()
|
||||
ladeWuensche()
|
||||
// Schlägt jemand anders etwas vor, sollen es auch die Karten zeigen
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
|
||||
else if (nachricht.type === 'users') ladeRunde()
|
||||
else if (nachricht.type === 'features') ladeWuensche()
|
||||
})
|
||||
window.addEventListener('keydown', tastatur)
|
||||
window.addEventListener('scroll', beimScrollen, { passive: true })
|
||||
})
|
||||
rad.value?.neu()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', tastatur)
|
||||
window.removeEventListener('scroll', beimScrollen)
|
||||
abbestellen?.()
|
||||
})
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" :class="{
|
||||
breit: [
|
||||
'wheel', 'stream', 'watched', 'merkliste', 'wuensche', 'verwaltung',
|
||||
].includes(activeTab),
|
||||
}">
|
||||
<div class="page" :class="{ breit: !mitFilter }">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@@ -672,6 +165,15 @@ onUnmounted(() => {
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="mitFilter"
|
||||
type="button"
|
||||
class="filter-knopf"
|
||||
:class="{ an: filterOffen }"
|
||||
@click="filterOffen = !filterOffen"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
<NamensWahl v-if="!host" :items="runde" :mine="ich" @waehlen="michSetzen" />
|
||||
<span v-else class="host">Host</span>
|
||||
</nav>
|
||||
@@ -687,8 +189,8 @@ onUnmounted(() => {
|
||||
/>
|
||||
|
||||
<AehnlichTab
|
||||
:gesehen-ids="gesehenIds"
|
||||
v-else
|
||||
:gesehen-ids="gesehenIds"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
:start="aehnlichStart"
|
||||
@@ -701,6 +203,7 @@ onUnmounted(() => {
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
:class="{ offen: filterOffen }"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
:options="genreOptions"
|
||||
@@ -755,40 +258,40 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="results">
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!currentCount" class="note">
|
||||
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||
</p>
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!aktuelleListe.length" class="note">
|
||||
Nichts gefunden{{ search ? ` für „${search}“` : '' }}.
|
||||
</p>
|
||||
|
||||
<div class="grid" :class="ansicht">
|
||||
<template v-for="entry in sichtbar" :key="entry.group">
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
:gesehen="gesehenIds.has(entry.parts[0].id)"
|
||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||
:ansicht="ansicht"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:start="entry.best"
|
||||
:auf-dem-rad="aufDemRad"
|
||||
:ansicht="ansicht"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="grid" :class="ansicht">
|
||||
<template v-for="entry in sichtbar" :key="entry.group">
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
:gesehen="gesehenIds.has(entry.parts[0].id)"
|
||||
:auf-dem-rad="aufDemRad.includes(entry.parts[0].id)"
|
||||
:ansicht="ansicht"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:start="entry.best"
|
||||
:auf-dem-rad="aufDemRad"
|
||||
:ansicht="ansicht"
|
||||
@open="filmOeffnen"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
|
||||
Lädt weitere Filme …
|
||||
</div>
|
||||
<div v-if="gezeigt < aktuelleListe.length" ref="nachschub" class="nachschub">
|
||||
Lädt weitere Filme …
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterSidebar
|
||||
@@ -798,6 +301,7 @@ onUnmounted(() => {
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
:class="{ offen: filterOffen }"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
:options="genreOptions"
|
||||
@@ -817,18 +321,17 @@ onUnmounted(() => {
|
||||
v-if="selected"
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="filmOeffnen"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
:gemerkt="merkliste.some((e) => e.mine && e.movie_id === selected.id)"
|
||||
@close="selected = null"
|
||||
@open="filmOeffnen"
|
||||
@gemerkt="ladeMerkliste"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
@aehnlich="zurAehnlichkeitsuche"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -839,7 +342,7 @@ onUnmounted(() => {
|
||||
padding-right: calc(var(--sidebar) + 1.25rem);
|
||||
}
|
||||
|
||||
/* Such- und Rad-Reiter haben keine Filterleiste */
|
||||
/* Reiter ohne Filterleiste */
|
||||
.page.breit {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
@@ -886,6 +389,7 @@ onUnmounted(() => {
|
||||
padding: 0.8rem 1rem 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
@@ -898,6 +402,12 @@ onUnmounted(() => {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Den Filterknopf gibt es nur auf schmalen Schirmen — mit .tabs davor,
|
||||
sonst gewinnt die Regel für alle Reiterknöpfe */
|
||||
.tabs .filter-knopf {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.host {
|
||||
align-self: center;
|
||||
margin-left: 0.4rem;
|
||||
@@ -938,26 +448,6 @@ onUnmounted(() => {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
/* Zu schmal für eine feste Leiste — die Filter wandern über die Kacheln */
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout > :last-child {
|
||||
position: static;
|
||||
order: -1;
|
||||
width: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.nachschub {
|
||||
padding: 2rem 0;
|
||||
color: var(--muted);
|
||||
@@ -990,4 +480,82 @@ onUnmounted(() => {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Schmale Schirme ─────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.page {
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.nach-oben {
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Zu schmal für eine feste Leiste — die Filter klappen über den Karten auf */
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout > :last-child {
|
||||
display: none;
|
||||
position: static;
|
||||
order: -1;
|
||||
width: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.layout > :last-child.offen {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tabs .filter-knopf {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tabs .filter-knopf.an {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Die Namenswahl rückt neben den Filterknopf */
|
||||
.filter-knopf + * {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 1rem 0.75rem 2.5rem;
|
||||
}
|
||||
|
||||
/* Die Reiter passen nicht in eine Zeile — sie scrollen seitlich */
|
||||
.tabs {
|
||||
gap: 0;
|
||||
margin: -1rem -0.75rem 1rem;
|
||||
padding: 0 0.75rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
padding: 0.7rem 0.6rem 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -258,4 +258,21 @@ h2 {
|
||||
.card.details .shots {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
|
||||
@media (max-width: 700px) {
|
||||
.card.details {
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.card.details .poster {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.card.details .shots {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -701,6 +701,17 @@ h3 {
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -712,6 +723,10 @@ h3 {
|
||||
.shots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lupe {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.shot {
|
||||
|
||||
@@ -151,6 +151,9 @@ async function open(movie) {
|
||||
|
||||
.big-search {
|
||||
flex: 1;
|
||||
/* Sonst hält das Feld seine Mindestbreite und schiebt den Knopf aus
|
||||
dem Bild, wenn der Schirm schmal ist */
|
||||
min-width: 0;
|
||||
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
@@ -170,6 +173,7 @@ async function open(movie) {
|
||||
}
|
||||
|
||||
.neu {
|
||||
flex-shrink: 0;
|
||||
padding: 0 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -360,4 +360,25 @@ h2:hover {
|
||||
.card.details .shots {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Schmal: Cover und Text nebeneinander, die Bilder in einer Reihe darunter */
|
||||
@media (max-width: 700px) {
|
||||
.card.details {
|
||||
grid-template-columns: 110px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.card.details .poster {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.card.details .shots {
|
||||
grid-area: 2 / 1 / 3 / 3;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.card.details .body {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -257,6 +257,7 @@ onUnmounted(() => {
|
||||
|
||||
.rahmen {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
/* Sonst ragt das Bild unter den Fensterrand und die Leiste mit ihm */
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
264
frontend/src/components/VorschlagsListe.vue
Normal file
264
frontend/src/components/VorschlagsListe.vue
Normal 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>
|
||||
@@ -369,7 +369,7 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
@@ -716,4 +716,14 @@ h3:hover {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rezension-hintergrund {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.rezension {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
import { klang } from '../klang'
|
||||
import { useZiehung } from '../composables/useZiehung'
|
||||
import VorschlagsListe from './VorschlagsListe.vue'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
host: Boolean,
|
||||
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
||||
merkliste: { type: Array, default: () => [] },
|
||||
@@ -16,118 +19,10 @@ const items = ref([])
|
||||
const laden = ref(true)
|
||||
const fehler = ref('')
|
||||
|
||||
// Die Ziehung läuft wie das Öffnen einer Lootbox: ein Streifen aus
|
||||
// Postern rast unter der Marke durch und bremst auf dem Gewinner aus.
|
||||
const KACHEL = 220
|
||||
const LUECKE = 10
|
||||
const SCHRITT = KACHEL + LUECKE
|
||||
// Die Ziehung läuft in Phasen: Anlauf (die Marke pulst), Fahrt (die Bahn
|
||||
// rast los und kriecht am Ende). Jedes zweite Mal bricht danach die
|
||||
// Halterung: kurze Ruhe, dann rollt die Bahn ein paar Poster zurück. Der
|
||||
// Server nennt die Gesamtdauer und die Halte, die Phasenlängen stehen
|
||||
// hier — Ruhe plus Rückfahrt müssen zu BRUCH_DAUER im Backend passen.
|
||||
const ANLAUF = 1.0
|
||||
const BRUCH_RUHE = 1.2
|
||||
const BRUCH_FAHRT = 2.5
|
||||
// Kurze Ruhe zwischen Stillstand und Enthüllung — der Moment vor dem Glow
|
||||
const ENTHUELLUNG = 0.6
|
||||
|
||||
const ergebnis = ref(null)
|
||||
const dreht = ref(false)
|
||||
const gewinner = ref(null)
|
||||
const dauer = ref(0)
|
||||
const x = ref(0)
|
||||
const anlauf = ref(false)
|
||||
const bruch = ref(false)
|
||||
// Fahrt: schnell weg, lange kriechen. Bruch: sanft ausrollen.
|
||||
const KURVEN = {
|
||||
fahrt: 'cubic-bezier(0.15, 0.7, 0.3, 1)',
|
||||
bruch: 'cubic-bezier(0.2, 0.6, 0.3, 1)',
|
||||
}
|
||||
const kurve = ref(KURVEN.fahrt)
|
||||
const bahn = ref(null)
|
||||
let timer = null
|
||||
let enthuellung = null
|
||||
|
||||
// ── Ton ─────────────────────────────────────────────────────────────
|
||||
// Ein Tick je Poster, das unter der Marke durchläuft, und ein Klang zur
|
||||
// Enthüllung. Alles synthetisch, keine Dateien. Der Browser gibt Ton erst
|
||||
// nach einer Eingabe frei — wer nur zuschaut und nie geklickt hat, hört
|
||||
// nichts, sieht aber alles.
|
||||
let audio = null
|
||||
|
||||
function klang() {
|
||||
if (!audio) audio = new AudioContext()
|
||||
if (audio.state === 'suspended') audio.resume()
|
||||
return audio
|
||||
}
|
||||
|
||||
function ton(frequenz, dauer, lautstaerke, art = 'triangle', start = 0, bis = null) {
|
||||
const a = klang()
|
||||
const t = a.currentTime + start
|
||||
const osc = a.createOscillator()
|
||||
const gain = a.createGain()
|
||||
osc.type = art
|
||||
osc.frequency.value = frequenz
|
||||
if (bis) osc.frequency.linearRampToValueAtTime(bis, t + dauer)
|
||||
gain.gain.setValueAtTime(lautstaerke, t)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0005, t + dauer)
|
||||
osc.connect(gain).connect(a.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + dauer)
|
||||
}
|
||||
|
||||
// Schnell: hell und knapp. Beim Kriechen: tiefer, leiser, länger —
|
||||
// man hört das Bremsen.
|
||||
function tick(tempo) {
|
||||
const a = Math.min(1, tempo / 3000)
|
||||
ton(700 + 800 * a, 0.04 + 0.06 * (1 - a), 0.05 + 0.07 * a)
|
||||
}
|
||||
|
||||
function anlaufTon(sekunden) {
|
||||
ton(150, sekunden, 0.08, 'sine', 0, 520)
|
||||
}
|
||||
|
||||
function knacken() {
|
||||
ton(120, 0.12, 0.25, 'square')
|
||||
ton(80, 0.3, 0.2, 'sawtooth', 0.08)
|
||||
}
|
||||
|
||||
function fanfare() {
|
||||
ton(660, 0.5, 0.12, 'sine')
|
||||
ton(990, 0.7, 0.1, 'sine', 0.12)
|
||||
}
|
||||
|
||||
// Liest die laufende Position aus dem Übergang und tickt bei jedem
|
||||
// Kachelwechsel unter der Marke. Das Tempo kommt aus zwei Frames.
|
||||
let zuletzt = -1
|
||||
let letzteX = 0
|
||||
let letzteZeit = 0
|
||||
|
||||
function ticken(zeit) {
|
||||
if (!dreht.value || !bahn.value) return
|
||||
const m = new DOMMatrixReadOnly(getComputedStyle(bahn.value).transform)
|
||||
const idx = Math.floor((-m.m41 + LUECKE / 2) / SCHRITT)
|
||||
const dt = (zeit - letzteZeit) / 1000
|
||||
const tempo = letzteZeit && dt > 0 ? Math.abs(m.m41 - letzteX) / dt : 0
|
||||
letzteX = m.m41
|
||||
letzteZeit = zeit
|
||||
if (zuletzt === -1) {
|
||||
// Die Startkachel zählt nicht — sonst tickt es vor dem Losfahren
|
||||
zuletzt = idx
|
||||
} else if (idx !== zuletzt) {
|
||||
zuletzt = idx
|
||||
tick(tempo)
|
||||
}
|
||||
requestAnimationFrame(ticken)
|
||||
}
|
||||
|
||||
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
||||
// verschwimmt die Marke mit dem Rand
|
||||
const FARBEN = [
|
||||
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
||||
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
||||
]
|
||||
const {
|
||||
masse, ergebnis, dreht, gewinner, dauer, x, anlauf, bruch, kurve, bahn,
|
||||
kacheln, ruhen, abspielen, laufendenDrehHolen, zurueckgesetzt,
|
||||
} = useZiehung({ items })
|
||||
|
||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||
let abbestellen = null
|
||||
@@ -136,138 +31,12 @@ function verbinden() {
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type === 'suggestions') laden_()
|
||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||
else if (nachricht.type === 'reset') zurueckgesetzt()
|
||||
})
|
||||
}
|
||||
|
||||
// Die Bahn hängt mit ihrem Anfang an der Fenstermitte. So landet die
|
||||
// Zielkachel unter der Marke, egal wie breit das Fenster ist.
|
||||
function stoppX(stopp) {
|
||||
return -(stopp.position * SCHRITT + KACHEL / 2 + stopp.versatz * KACHEL)
|
||||
}
|
||||
|
||||
// Vor dem ersten Dreh stehen die Vorschläge einfach in einer Reihe
|
||||
function ruheX() {
|
||||
return -(items.value.length * SCHRITT - LUECKE) / 2
|
||||
}
|
||||
|
||||
function fahren(sekunden, k, ziel) {
|
||||
kurve.value = k
|
||||
dauer.value = sekunden
|
||||
x.value = ziel
|
||||
}
|
||||
|
||||
function ticksStarten() {
|
||||
zuletzt = -1
|
||||
letzteZeit = 0
|
||||
requestAnimationFrame(ticken)
|
||||
}
|
||||
|
||||
// Jede Phase hat eine Dauer, einen Start (mit der Restzeit, die sie noch
|
||||
// hat) und ein Ende. Wer später dazukommt, überspringt die vorderen.
|
||||
function phasen(erg) {
|
||||
const erst = erg.stopps[0]
|
||||
const ende = erg.stopps.at(-1)
|
||||
const extra = erg.stopps.length > 1 ? BRUCH_RUHE + BRUCH_FAHRT : 0
|
||||
const fahrt = erg.dauer - ANLAUF - extra
|
||||
const liste = [
|
||||
{
|
||||
d: ANLAUF,
|
||||
start(rest) {
|
||||
anlauf.value = true
|
||||
if (rest > 0) anlaufTon(rest)
|
||||
},
|
||||
ende() {
|
||||
anlauf.value = false
|
||||
},
|
||||
},
|
||||
{
|
||||
d: fahrt,
|
||||
start(rest) {
|
||||
fahren(rest, KURVEN.fahrt, stoppX(erst))
|
||||
ticksStarten()
|
||||
},
|
||||
},
|
||||
]
|
||||
if (erg.stopps.length > 1) {
|
||||
liste.push(
|
||||
{ d: BRUCH_RUHE },
|
||||
{
|
||||
d: BRUCH_FAHRT,
|
||||
start(rest) {
|
||||
bruch.value = true
|
||||
if (rest > 0) knacken()
|
||||
fahren(rest, KURVEN.bruch, stoppX(ende))
|
||||
},
|
||||
ende() {
|
||||
bruch.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
function ablauf(erg, liste, i, uebersprungen) {
|
||||
if (i >= liste.length) {
|
||||
dreht.value = false
|
||||
// Erst Stille, dann der Glow
|
||||
enthuellung = window.setTimeout(() => {
|
||||
gewinner.value = erg.gewinner
|
||||
fanfare()
|
||||
}, ENTHUELLUNG * 1000)
|
||||
return
|
||||
}
|
||||
const ph = liste[i]
|
||||
if (uebersprungen >= ph.d) {
|
||||
// Schon vorbei: nur den Endzustand setzen
|
||||
ph.start?.(0)
|
||||
ph.ende?.()
|
||||
ablauf(erg, liste, i + 1, uebersprungen - ph.d)
|
||||
return
|
||||
}
|
||||
const rest = ph.d - uebersprungen
|
||||
ph.start?.(rest)
|
||||
timer = window.setTimeout(() => {
|
||||
ph.ende?.()
|
||||
ablauf(erg, liste, i + 1, 0)
|
||||
}, rest * 1000)
|
||||
}
|
||||
|
||||
async function abspielen(erg, uebersprungen = 0) {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(enthuellung)
|
||||
gewinner.value = null
|
||||
ergebnis.value = erg
|
||||
dreht.value = true
|
||||
|
||||
// Erst ohne Übergang an den Anfang, sonst fährt die Bahn vom letzten
|
||||
// Ergebnis aus los
|
||||
fahren(0, KURVEN.fahrt, -3 * SCHRITT)
|
||||
await nextTick()
|
||||
void bahn.value?.offsetWidth
|
||||
|
||||
ablauf(erg, phasen(erg), 0, uebersprungen)
|
||||
}
|
||||
|
||||
async function laufendenDrehHolen() {
|
||||
try {
|
||||
const resp = await fetch('/api/spin')
|
||||
if (!resp.ok) return
|
||||
const daten = await resp.json()
|
||||
if (!daten.result) return
|
||||
if (daten.running) {
|
||||
abspielen(daten.result, daten.elapsed)
|
||||
} else {
|
||||
// Dreh ist durch: Bahn ohne Animation auf die Endstellung setzen,
|
||||
// sonst steht die Marke woanders als die Gewinnerkarte
|
||||
ergebnis.value = daten.result
|
||||
fahren(0, KURVEN.fahrt, stoppX(daten.result.stopps.at(-1)))
|
||||
gewinner.value = daten.result.gewinner
|
||||
else if (nachricht.type === 'reset') {
|
||||
zurueckgesetzt()
|
||||
emit('reset')
|
||||
laden_()
|
||||
}
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function laden_() {
|
||||
@@ -278,10 +47,7 @@ async function laden_() {
|
||||
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
||||
const daten = await resp.json()
|
||||
items.value = daten.items
|
||||
if (!ergebnis.value) {
|
||||
dauer.value = 0
|
||||
x.value = ruheX()
|
||||
}
|
||||
ruhen()
|
||||
} catch (err) {
|
||||
fehler.value = err.message
|
||||
} finally {
|
||||
@@ -289,44 +55,11 @@ async function laden_() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await laden_()
|
||||
await laufendenDrehHolen()
|
||||
verbinden()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(enthuellung)
|
||||
abbestellen?.()
|
||||
})
|
||||
|
||||
defineExpose({ neu: laden_ })
|
||||
|
||||
// Läuft eine Ziehung, zeigt die Bahn deren Streifen — sonst die Vorschläge
|
||||
const kacheln = computed(() => {
|
||||
const erg = ergebnis.value
|
||||
if (erg) {
|
||||
const ziel = erg.stopps.at(-1).position
|
||||
return erg.streifen.map((i, k) => ({
|
||||
key: k,
|
||||
film: erg.karten[i],
|
||||
farbe: FARBEN[i % FARBEN.length],
|
||||
treffer: k === ziel,
|
||||
}))
|
||||
}
|
||||
return items.value.map((item, i) => ({
|
||||
key: i,
|
||||
film: item,
|
||||
farbe: FARBEN[i % FARBEN.length],
|
||||
treffer: false,
|
||||
}))
|
||||
})
|
||||
|
||||
async function drehen() {
|
||||
if (dreht.value || !items.value.length) return
|
||||
// Der Host hat geklickt — jetzt darf der Browser Ton. Der Server lost
|
||||
// aus und schickt das Ergebnis an alle, auch an uns.
|
||||
klang()
|
||||
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
||||
try {
|
||||
const resp = await fetch('/api/spin', { method: 'POST' })
|
||||
if (!resp.ok) fehler.value = 'Ziehen hat nicht geklappt.'
|
||||
@@ -335,18 +68,6 @@ async function drehen() {
|
||||
}
|
||||
}
|
||||
|
||||
function zurueckgesetzt() {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(enthuellung)
|
||||
anlauf.value = false
|
||||
bruch.value = false
|
||||
ergebnis.value = null
|
||||
gewinner.value = null
|
||||
dreht.value = false
|
||||
emit('reset')
|
||||
laden_()
|
||||
}
|
||||
|
||||
async function zuruecksetzen() {
|
||||
// Der Server meldet den Reset an alle zurück, auch an uns
|
||||
try {
|
||||
@@ -356,45 +77,20 @@ async function zuruecksetzen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
||||
// anderen — für Abende, an denen es keiner gruselig mag.
|
||||
const nurHorror = ref(true)
|
||||
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
|
||||
const wuerfelt = ref(false)
|
||||
|
||||
function nachGenre(horror) {
|
||||
return sammlung.value.filter(
|
||||
(e) => (e.genres || []).includes('Horror') === horror
|
||||
)
|
||||
}
|
||||
|
||||
const auswahl = computed(() => nachGenre(nurHorror.value))
|
||||
const anzahlHorror = computed(() => nachGenre(true).length)
|
||||
const anzahlOhne = computed(() => nachGenre(false).length)
|
||||
|
||||
async function wuerfeln() {
|
||||
const treffer = auswahl.value
|
||||
if (!treffer.length || wuerfelt.value) return
|
||||
wuerfelt.value = true
|
||||
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
||||
try {
|
||||
const resp = await fetch('/api/suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ movie_id: film.movie_id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
} catch {
|
||||
fehler.value = 'Eintragen hat nicht geklappt.'
|
||||
} finally {
|
||||
wuerfelt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await laden_()
|
||||
await laufendenDrehHolen()
|
||||
verbinden()
|
||||
})
|
||||
|
||||
onUnmounted(() => abbestellen?.())
|
||||
|
||||
defineExpose({ neu: laden_ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -402,7 +98,10 @@ async function oeffnen(item) {
|
||||
<div class="buehne">
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
|
||||
<div class="fenster">
|
||||
<div
|
||||
class="fenster"
|
||||
:style="{ '--kachel': `${masse.kachel}px`, '--luecke': `${masse.luecke}px` }"
|
||||
>
|
||||
<div class="marke" :class="{ anlauf, bruch }"></div>
|
||||
<div
|
||||
ref="bahn"
|
||||
@@ -468,61 +167,16 @@ async function oeffnen(item) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="liste">
|
||||
<div v-if="!host" class="sammlung-wuerfeln">
|
||||
<div class="schalter">
|
||||
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
||||
Horror ({{ anzahlHorror }})
|
||||
</button>
|
||||
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
||||
Kein Horror ({{ anzahlOhne }})
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="wuerfeln"
|
||||
:disabled="!auswahl.length || wuerfelt"
|
||||
@click="wuerfeln"
|
||||
>
|
||||
Zufällig aus Sammlung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
class="eintrag"
|
||||
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
|
||||
@click="oeffnen(item)"
|
||||
>
|
||||
<span
|
||||
class="cover"
|
||||
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
|
||||
>
|
||||
<img
|
||||
v-if="posterUrl(item.poster_path, 'w154')"
|
||||
:src="posterUrl(item.poster_path, 'w154')"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<span class="daten">
|
||||
<span class="titel">{{ item.title }}</span>
|
||||
<span class="meta">
|
||||
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
||||
</span>
|
||||
<span v-if="item.genres?.length" class="genres">
|
||||
{{ item.genres.join(' · ') }}
|
||||
</span>
|
||||
<span class="von">{{ item.name || 'Anonym' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<VorschlagsListe
|
||||
:items="items"
|
||||
:laden="laden"
|
||||
:host="host"
|
||||
:merkliste="merkliste"
|
||||
:mine="mine"
|
||||
:gesehen-ids="gesehenIds"
|
||||
@open="oeffnen"
|
||||
@fehler="fehler = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -546,7 +200,8 @@ async function oeffnen(item) {
|
||||
.fenster {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
height: 370px;
|
||||
/* Ein Poster ist 2:3, plus Luft für den Glow des Gewinners */
|
||||
height: calc(var(--kachel) * 1.5 + 40px);
|
||||
overflow: hidden;
|
||||
/* Links und rechts läuft der Streifen ins Dunkel aus */
|
||||
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
|
||||
@@ -557,7 +212,7 @@ async function oeffnen(item) {
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: var(--luecke);
|
||||
will-change: transform;
|
||||
/* Die Kurve setzt das Skript je Phase */
|
||||
transition-property: transform;
|
||||
@@ -565,8 +220,8 @@ async function oeffnen(item) {
|
||||
|
||||
.kachel {
|
||||
position: relative;
|
||||
flex: 0 0 220px;
|
||||
height: 330px;
|
||||
flex: 0 0 var(--kachel);
|
||||
height: calc(var(--kachel) * 1.5);
|
||||
overflow: hidden;
|
||||
background: var(--bg-soft);
|
||||
border: 2px solid;
|
||||
@@ -634,6 +289,28 @@ async function oeffnen(item) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marke::before,
|
||||
.marke::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: -9px;
|
||||
content: '';
|
||||
border-right: 9px solid transparent;
|
||||
border-left: 9px solid transparent;
|
||||
}
|
||||
|
||||
.marke::before {
|
||||
top: 0;
|
||||
border-top: 14px solid var(--accent);
|
||||
}
|
||||
|
||||
.marke::after {
|
||||
bottom: 0;
|
||||
border-bottom: 14px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Im Anlauf pulst die Marke dreimal — gleich geht es los */
|
||||
.marke.anlauf {
|
||||
animation: puls 0.33s ease-in-out 3;
|
||||
@@ -667,28 +344,6 @@ async function oeffnen(item) {
|
||||
}
|
||||
}
|
||||
|
||||
.marke::before,
|
||||
.marke::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: -9px;
|
||||
content: '';
|
||||
border-right: 9px solid transparent;
|
||||
border-left: 9px solid transparent;
|
||||
}
|
||||
|
||||
.marke::before {
|
||||
top: 0;
|
||||
border-top: 14px solid var(--accent);
|
||||
}
|
||||
|
||||
.marke::after {
|
||||
bottom: 0;
|
||||
border-bottom: 14px solid var(--accent);
|
||||
}
|
||||
|
||||
.leer-bahn {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -702,6 +357,21 @@ async function oeffnen(item) {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.dreh {
|
||||
padding: 0.7rem 2.2rem;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dreh:disabled {
|
||||
background: var(--line);
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: 0.7rem 1.4rem;
|
||||
color: var(--muted);
|
||||
@@ -721,73 +391,6 @@ async function oeffnen(item) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.leer {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dreh {
|
||||
padding: 0.7rem 2.2rem;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dreh:disabled {
|
||||
background: var(--line);
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sammlung-wuerfeln {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.schalter {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.schalter button {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.schalter button.an {
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.wuerfeln {
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.wuerfeln:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.wuerfeln:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gewinner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -837,111 +440,6 @@ async function oeffnen(item) {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.liste h3 {
|
||||
margin-bottom: 0.7rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.anzahl {
|
||||
padding: 0.1rem 0.45rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.liste ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.eintrag {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
color: var(--text);
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
||||
.eintrag.gesehen {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.eintrag.gesehen:hover {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.eintrag:hover {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
/* Der farbige Rand verbindet den Eintrag mit seinen Kacheln auf der Bahn */
|
||||
.cover {
|
||||
display: block;
|
||||
width: 46px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-soft);
|
||||
border-left: 3px solid;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cover img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.daten {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.titel {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.eintrag.eigen .titel {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta,
|
||||
.genres,
|
||||
.von {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.von {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 3rem 0;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -504,6 +504,12 @@ h3 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.aktionen {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.weg {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--muted);
|
||||
|
||||
58
frontend/src/composables/useEinstellungen.js
Normal file
58
frontend/src/composables/useEinstellungen.js
Normal 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 }
|
||||
}
|
||||
179
frontend/src/composables/useFilter.js
Normal file
179
frontend/src/composables/useFilter.js
Normal 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,
|
||||
}
|
||||
}
|
||||
150
frontend/src/composables/useKatalog.js
Normal file
150
frontend/src/composables/useKatalog.js
Normal 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,
|
||||
}
|
||||
}
|
||||
81
frontend/src/composables/useLangeListe.js
Normal file
81
frontend/src/composables/useLangeListe.js
Normal 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 }
|
||||
}
|
||||
124
frontend/src/composables/useMeins.js
Normal file
124
frontend/src/composables/useMeins.js
Normal 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,
|
||||
}
|
||||
}
|
||||
287
frontend/src/composables/useZiehung.js
Normal file
287
frontend/src/composables/useZiehung.js
Normal 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
50
frontend/src/klang.js
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user