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:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:23:46 +03:00
1 parent f790470935
commit d29c12e4f0
62 files changed
+5777 -3572

No files matched your search

+6 -8
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 8;
static const int protoVersion = 9;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -32,13 +32,11 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5;
// Base64-char count of the avatar string, enforced at hello. 256 KiB
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
// for private-server use. PNG keeps transparency; JPEG did not.
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host
// uplink scales with this cap. Follow-up (not now): send avatar bytes
// once, reference by hash afterwards.
// Base64-char count of the avatar string, enforced at hello and
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
// (raw bitmap <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate
// over-size for private-server use. PNG keeps transparency; JPEG did
// not. Only the hash rides in `state`; the bytes are fetched once.
static const int maxAvatarBytes = 256 * 1024;
static const int maxNameLength = 32;
static const int maxChatLength = 500;
+34 -4
View File
@@ -18,6 +18,14 @@ int finiteInt(Object? value, String field, {int fallback = 0}) {
return n.toInt();
}
/// A member name as shown to everyone: trimmed, cut to
/// [RelayConstants.maxNameLength], `?` when empty.
String cleanMemberName(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
return trimmed.substring(0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
}
class HelloUser {
final String name;
final String avatar;
@@ -450,10 +458,32 @@ class ChatMessage {
}
}
/// Changes this member's name and/or picture mid-session. Null fields stay
/// as they are; an empty [avatar] removes the picture.
class ProfileIntent {
final String? name;
/// Base64 PNG, like `hello.user.avatar`.
final String? avatar;
const ProfileIntent({this.name, this.avatar});
Map<String, Object?> toJson() => {
't': 'profile',
if (name != null) 'name': name,
if (avatar != null) 'avatar': avatar,
};
factory ProfileIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'profile') throw const ProtocolException('not a profile intent');
return ProfileIntent(name: json['name'] as String?, avatar: json['avatar'] as String?);
}
}
class MemberView {
final String id;
final String name;
final String avatar;
/// Content hash of the member's picture, fetched at `/a/...`; empty when
/// none.
final String avatarHash;
final bool ready;
final bool inSet;
final double buffered;
@@ -463,7 +493,7 @@ class MemberView {
const MemberView({
required this.id,
required this.name,
this.avatar = '',
this.avatarHash = '',
required this.ready,
required this.inSet,
required this.buffered,
@@ -474,7 +504,7 @@ class MemberView {
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'avatar': avatar,
if (avatarHash.isNotEmpty) 'avatarHash': avatarHash,
'ready': ready,
'inSet': inSet,
'buffered': buffered,
@@ -485,7 +515,7 @@ class MemberView {
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatar: (json['avatar'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
+79 -4
View File
@@ -1,3 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'protocol.dart';
@@ -14,11 +19,34 @@ Correction correctionFor({required double ownPosition, required double serverPos
return Correction.none;
}
/// Content hash of a picture: the first 16 hex chars of its sha256.
String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substring(0, 16);
/// `(type, subtype)` of a picture by its magic bytes; null for anything
/// that is not PNG, JPEG, WebP or GIF (those are never stored or served).
(String, String)? avatarImageType(List<int> b) {
bool at(int i, List<int> sig) {
if (b.length < i + sig.length) return false;
for (var k = 0; k < sig.length; k++) {
if (b[i + k] != sig[k]) return false;
}
return true;
}
if (at(0, const [0x89, 0x50, 0x4E, 0x47])) return ('image', 'png');
if (at(0, const [0xFF, 0xD8, 0xFF])) return ('image', 'jpeg');
if (at(0, const [0x52, 0x49, 0x46, 0x46]) && at(8, const [0x57, 0x45, 0x42, 0x50])) return ('image', 'webp');
if (at(0, const [0x47, 0x49, 0x46, 0x38])) return ('image', 'gif');
return null;
}
class EngineMember {
final String id;
final String token;
String name;
String avatar;
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
Uint8List? avatarBytes;
String avatarHash = '';
bool ready = false;
bool inSet = false;
double buffered = 0;
@@ -29,14 +57,35 @@ class EngineMember {
required this.id,
required this.token,
required this.name,
this.avatar = '',
String avatar = '',
required DateTime now,
}) : lastSeen = now;
}) : lastSeen = now {
setAvatar(avatar);
}
/// Sets the picture from base64 (empty or undecodable: none). Returns
/// whether it changed.
bool setAvatar(String base64) {
Uint8List? bytes;
if (base64.isNotEmpty) {
try {
bytes = base64Decode(base64);
} on FormatException {
bytes = null;
}
}
if (bytes != null && avatarImageType(bytes) == null) bytes = null;
final hash = bytes == null || bytes.isEmpty ? '' : avatarHashOf(bytes);
if (hash == avatarHash) return false;
avatarHash = hash;
avatarBytes = hash.isEmpty ? null : bytes;
return true;
}
MemberView view({required bool stale}) => MemberView(
id: id,
name: name,
avatar: avatar,
avatarHash: avatarHash,
ready: ready,
inSet: inSet,
buffered: buffered,
@@ -129,6 +178,32 @@ class SyncEngine {
return id;
}
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
/// empty [avatar] removes the picture. [name] is already cleaned.
void applyProfile(String token, {String? name, String? avatar}) {
final member = members[token];
if (member == null) return;
_seen(member);
final oldName = member.name;
final renamed = name != null && name != oldName;
if (renamed) member.name = name;
final pictured = avatar != null && member.setAvatar(avatar);
if (renamed) {
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
} else if (pictured) {
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
}
}
/// Picture bytes of a current member by [EngineMember.avatarHash].
Uint8List? avatarFor(String hash) {
if (hash.isEmpty) return null;
for (final member in members.values) {
if (member.avatarHash == hash) return member.avatarBytes;
}
return null;
}
/// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) {
final member = members[token];
+54
View File
@@ -537,4 +537,58 @@ void main() {
expect(s.seekBy, isNull);
});
});
group('profile + avatars', () {
// Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped.
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]);
final other = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => 255 - i)]);
test('join keeps bytes, state carries only the hash', () {
engine.applyJoin('a', 'alice', png);
final hash = engine.members['a']!.avatarHash;
expect(hash, hasLength(16));
expect(engine.avatarFor(hash), base64Decode(png));
final view = engine.broadcast().members.single;
expect(view.avatarHash, hash);
expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(png)));
join('b', 'bob');
expect(engine.members['b']!.avatarHash, isEmpty);
engine.applyLeave('a');
expect(engine.avatarFor(hash), isNull, reason: 'dropped with the member');
});
test('a picture that is not an image is dropped', () {
final e = SyncEngine();
e.applyJoin('x', 'x', base64Encode(List<int>.generate(64, (i) => i)));
expect(e.members['x']!.avatarHash, isEmpty);
expect(avatarImageType([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), ('image', 'webp'));
});
test('rename feeds rename (actor new, target old); picture only feeds avatar', () {
engine.applyJoin('a', 'alice', png);
engine.feed.drain();
engine.applyProfile('a', name: 'bob');
var feed = engine.feed.drain().single;
expect(feed.action, 'rename');
expect(feed.actor, 'bob');
expect(feed.target, 'alice');
engine.applyProfile('a', avatar: other);
feed = engine.feed.drain().single;
expect(feed.action, 'avatar');
expect(feed.actor, 'bob');
expect(engine.members['a']!.avatarHash, avatarHashOf(base64Decode(other)));
engine.applyProfile('a', name: 'carol', avatar: '');
feed = engine.feed.drain().single;
expect(feed.action, 'rename');
expect(engine.members['a']!.avatarHash, isEmpty);
expect(engine.members['a']!.avatarBytes, isNull);
engine.applyProfile('a', name: 'carol', avatar: '');
expect(engine.feed.drain(), isEmpty, reason: 'nothing changed');
engine.applyProfile('a', avatar: 'not base64!');
expect(engine.members['a']!.avatarHash, isEmpty);
});
});
}