1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5777
-3572
No files matched your search
@@ -159,6 +159,37 @@ class ServerHub {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
||||
/// hash never change, so clients may cache them for good.
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
||||
Response status(int code) => Response(code, body: Body.fromString('status $code'));
|
||||
app.get('/a/:sid/:token/:hash', (final req) {
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final hash = req.pathParameters.raw[#hash] ?? '';
|
||||
final session = hub.sessions.get(sid);
|
||||
if (session == null) return status(410);
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.unauthorized:
|
||||
return status(401);
|
||||
case MediaGate.forbidden:
|
||||
return status(403);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
final bytes = hub.engine.avatarFor(hash);
|
||||
final type = bytes == null ? null : avatarImageType(bytes);
|
||||
if (bytes == null || type == null) return status(404);
|
||||
return Response.ok(
|
||||
body: Body.fromData(bytes, mimeType: MimeType(type.$1, type.$2)),
|
||||
headers: Headers.build((h) {
|
||||
h['cache-control'] = ['private, max-age=31536000, immutable'];
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Media names are shown to everyone and must never carry a URL (api_key etc.).
|
||||
/// A URL-looking name is reduced to its last path segment, no query/fragment.
|
||||
String sanitizeMediaName(String name) {
|
||||
@@ -358,6 +389,19 @@ void mountSyncRoute(
|
||||
mediaId: hub.engine.mediaId);
|
||||
}
|
||||
hub.changed();
|
||||
case 'profile':
|
||||
if (token == null || viewer == null) return;
|
||||
final intent = ProfileIntent.fromJson(decoded);
|
||||
final avatar = intent.avatar;
|
||||
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
wsErr(ws, 'rejected: avatar too large');
|
||||
return;
|
||||
}
|
||||
final name = intent.name == null ? null : cleanMemberName(intent.name!);
|
||||
// Chat and kick name the sender by its token's name.
|
||||
if (name != null) viewer.name = name;
|
||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||
hub.changed();
|
||||
case 'report':
|
||||
if (token == null) return;
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
@@ -470,7 +514,7 @@ void mountSyncRoute(
|
||||
await close();
|
||||
return;
|
||||
}
|
||||
memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
|
||||
}
|
||||
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||
final activeToken = token;
|
||||
|
||||
@@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline }
|
||||
|
||||
class ViewerToken {
|
||||
final String token;
|
||||
final String name;
|
||||
final String avatar;
|
||||
/// Cleaned ([cleanMemberName]); follows `profile` renames.
|
||||
String name;
|
||||
bool kicked = false;
|
||||
/// Sent the server's host key in hello: the hosting app's own client.
|
||||
bool isHost = false;
|
||||
int bytesServed = 0;
|
||||
DateTime lastSeen;
|
||||
Future<void> Function(String reason)? onKick;
|
||||
ViewerToken({required this.token, required this.name, this.avatar = ''})
|
||||
ViewerToken({required this.token, required this.name})
|
||||
: lastSeen = DateTime.now().toUtc();
|
||||
|
||||
bool isStale(Duration timeout, [DateTime? now]) =>
|
||||
@@ -53,18 +53,14 @@ class Session {
|
||||
|
||||
Session({required this.id, required this.secret});
|
||||
|
||||
/// The avatar is only checked here; the engine keeps the picture.
|
||||
String issueToken(String name, String avatar) {
|
||||
if (avatar.length > RelayConstants.maxAvatarBytes) {
|
||||
throw const AuthException('avatar too large');
|
||||
}
|
||||
final trimmed = name.trim();
|
||||
final cleanName = trimmed.isEmpty
|
||||
? '?'
|
||||
: trimmed.substring(
|
||||
0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
|
||||
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
|
||||
final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar);
|
||||
tokens[token] = ViewerToken(token: token, name: cleanMemberName(name));
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class SyncServer {
|
||||
});
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||
mountAvatarRoute(_app, hub);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
|
||||
Reference in new issue
Block a user