diff --git a/backend/app/events.py b/backend/app/events.py index 572746f..21fa79c 100644 --- a/backend/app/events.py +++ b/backend/app/events.py @@ -19,16 +19,26 @@ log = logging.getLogger("events") PUFFER = 16 _abonnenten: set[asyncio.Queue] = set() +# Welche Sitzung hinter einer Verbindung steckt — daraus ergibt sich, +# wer gerade online ist: eine offene Seite mit gewähltem Namen +_sitzungen: dict[asyncio.Queue, str] = {} -def anmelden(): +def anmelden(session_id=None): queue = asyncio.Queue(maxsize=PUFFER) _abonnenten.add(queue) + if session_id: + _sitzungen[queue] = session_id return queue def abmelden(queue): _abonnenten.discard(queue) + _sitzungen.pop(queue, None) + + +def sitzungen_online(): + return set(_sitzungen.values()) def anzahl(): @@ -60,3 +70,5 @@ async def stream(queue, request): yield ": ping\n\n" finally: abmelden(queue) + # Die anderen Seiten sollen den grünen Punkt ausmachen + senden("users") diff --git a/backend/app/main.py b/backend/app/main.py index 95d4d95..4da6c89 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -564,8 +564,10 @@ letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []} @app.get("/api/events") -async def ereignisse(request: Request): - queue = events.anmelden() +async def ereignisse(request: Request, filmabend_sid: str | None = Cookie(None)): + queue = events.anmelden(filmabend_sid) + # Die anderen Seiten sollen den grünen Punkt anmachen + events.senden("users") return StreamingResponse( events.stream(queue, request), media_type="text/event-stream", @@ -833,8 +835,11 @@ def runde(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) staende = punkte.alle() leer = punkte.stand(0) + online = users.online_ids(events.sitzungen_online()) return { - "items": [{**u, **staende.get(u["id"], leer)} for u in users.alle()], + "items": [ + {**u, **staende.get(u["id"], leer), "online": u["id"] in online} for u in users.alle() + ], "mine": users.aktuell(sid), "host": users.ist_host(sid), } @@ -995,6 +1000,7 @@ def name_waehlen( sid = sitzung(response, filmabend_sid) if user_id is None: users.abwaehlen(sid) + events.senden("users") return {"mine": None} film = users.schutz_von(user_id) @@ -1009,6 +1015,7 @@ def name_waehlen( benutzer = users.waehlen(sid, user_id) if not benutzer: raise HTTPException(status_code=404, detail="Unbekannter Name") + events.senden("users") return {"mine": benutzer} diff --git a/backend/app/users.py b/backend/app/users.py index 166e2d1..4e4c80f 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -184,6 +184,11 @@ def host_setzen(session_id, an): return an +def online_ids(session_ids): + """Die Namen hinter offenen Verbindungen — wer gerade da ist.""" + return {besitzer(sid) for sid in session_ids} - {None} + + def besitzer(session_id): """Die user_id dieser Sitzung, oder None ohne gewählten Namen. diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py index 40229e9..4f437f1 100644 --- a/backend/tests/test_users.py +++ b/backend/tests/test_users.py @@ -46,3 +46,23 @@ def test_abwaehlen_macht_wieder_zum_zuschauer(client, film): # Lesen geht weiter, Schreiben nicht mehr assert client.get("/api/suggestions").json()["mine"] is None assert client.post("/api/suggestions", json={"movie_id": film(2)}).status_code == 403 + + +def test_online_ist_wer_eine_seite_offen_hat(client, anmelden): + from app import events + + anmelden(client, "Anna") + sid = client.cookies.get("filmabend_sid") + ich = client.get("/api/users").json()["mine"]["id"] + + def online(): + return {u["id"]: u["online"] for u in client.get("/api/users").json()["items"]} + + assert online()[ich] is False + queue = events.anmelden(sid) + assert online()[ich] is True + # Namenswechsel im selben Browser: der alte Name ist offline + anmelden(client, "Ben") + assert online()[ich] is False + events.abmelden(queue) + assert True not in online().values() diff --git a/frontend/src/App.vue b/frontend/src/App.vue index eabf933..f1d30da 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -141,7 +141,7 @@ const tabs = computed(() => [ count: quellen.value.find((q) => q.id === quelle.value)?.count ?? null, }, { id: 'suche', label: 'Suche', count: null }, - ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), + { id: 'merkliste', label: 'Sammlungen', count: null }, { id: 'wheel', label: 'Rad', count: null }, { id: 'stream', label: 'Stream', count: null }, ...(angemeldet.value || host.value ? [{ id: 'clips', label: 'Clips', count: null }] : []), @@ -571,6 +571,14 @@ onMounted(load) gap: 0.35rem; align-items: stretch; min-width: 0; + /* Zehn Reiter passen nicht auf jeden Schirm — dann rollt die Leiste, + statt unter den Namen zu laufen */ + overflow-x: auto; + scrollbar-width: none; +} + +.tabs::-webkit-scrollbar { + display: none; } .rechts { diff --git a/frontend/src/components/NamensWahl.vue b/frontend/src/components/NamensWahl.vue index daa2dc0..4bc2834 100644 --- a/frontend/src/components/NamensWahl.vue +++ b/frontend/src/components/NamensWahl.vue @@ -153,7 +153,7 @@ onUnmounted(() => { @@ -423,11 +423,15 @@ onUnmounted(() => { } /* Der Punkt zeigt: hier fragt die Seite nach dem Anmeldefilm */ -.schloss { - margin-left: 0.3rem; - color: var(--muted); - font-size: 0.6rem; +/* Grüner Punkt: eine Seite mit diesem Namen ist gerade offen */ +.online { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + margin-left: 0.35rem; vertical-align: middle; + background: #3ddc84; + border-radius: 50%; } @media (max-width: 640px), (pointer: coarse) {