tl/scenes/consumers.py

61 lines
2.4 KiB
Python
Raw Normal View History

import json
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
import uuid
from channels.generic.websocket import AsyncWebsocketConsumer
class SceneConsumer(AsyncWebsocketConsumer):
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
"""One connection per open project. Two things ride it.
Scene deltas: relayed from what the server broadcasts on a save (see
scenes.views.scene). Read-only — edits still go through the PUT endpoint,
which is the broadcast source.
Presence: who's viewing, which ad-hoc party they're in, and where that party
is looking. Peers gossip it between themselves and work the party out
locally (tl.party); all this does is hand out a connection id, stamp the
sender's server-side identity onto every message — so nobody can post as
somebody else — and relay. Nothing about a party is stored anywhere.
"""
RELAYED = ("state", "nav")
async def connect(self):
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
self.pk = self.scope["url_route"]["kwargs"]["pk"]
self.group = f"project_{self.pk}"
self.cid = uuid.uuid4().hex[:12]
user = self.scope.get("user")
self.username = user.get_username() if user and user.is_authenticated else None
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
await self.send(text_data=json.dumps(
{"kind": "welcome", "cid": self.cid, "user": self.username}))
# the room answers a join with their own state, so rosters fill in both ways
await self._relay({"kind": "join"})
async def disconnect(self, code):
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
if hasattr(self, "cid"):
await self._relay({"kind": "leave"})
await self.channel_layer.group_discard(self.group, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
try:
msg = json.loads(text_data or "{}")
except ValueError:
return
if isinstance(msg, dict) and msg.get("kind") in self.RELAYED:
await self._relay(msg)
async def _relay(self, msg):
await self.channel_layer.group_send(
self.group,
{"type": "peer.msg", "msg": {**msg, "cid": self.cid, "user": self.username}})
# broadcast handler: {type: "peer.msg", msg: {...}} — presence gossip
async def peer_msg(self, event):
await self.send(text_data=json.dumps(event["msg"]))
# broadcast handler: {type: "scene.delta", delta: {...}}
async def scene_delta(self, event):
feat: who's viewing, and ad-hoc parties that watch together The project socket already carried scene deltas; it now also carries presence. The top bar grows a Google-Docs-style cluster of faces, and the menu behind it groups the room into parties, then everyone flying solo, with a Join on each. A party has no host — you only join one. Joining someone solo adopts *their cid* as the party id, so two people clicking Join in the same instant converge instead of minting two parties of one, and the party outlives whoever was joined first. Everyone in it drives everyone else: a jump, a scrub or a play from any member moves all the others. That's why being joined needs consent ("Let others join me", on by default, remembered) and why Leave is one click. Ordering was the thing to get right. A save is a PUT and a jump rides the socket, so "create an annotation, then jump into it" can arrive at a peer in the wrong order. Rather than truncate the stack and strand them at the root, a nav naming a group we haven't been told about is parked and replayed the moment the delta lands. Authoring parks it for the same reason: a draft detaches you from the party so hunting for marks is nobody else's business, and closing the form replays the park, putting you exactly where the party got to. Deleting a timeline someone is standing in now pops them out too. Playback ticks stay off the wire — every member runs the same clip off its own clock, so streaming positions would only fight them. Only deliberate moves and transport changes go out, and a play we started because a peer did isn't echoed back at them. The socket now reconnects with backoff and, on the way back, re-states the party id it was carrying and pulls the scene it missed. That catch-up is deliberately additive: "absent from the server" can also mean "saved a moment ago", and a wrong deletion costs someone their work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 16:21:55 -04:00
await self.send(text_data=json.dumps(event["delta"]))