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

@@ -0,0 +1,97 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'sync_client.dart';
/// Member pictures by content hash (`state.members[].avatarHash`), fetched
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and
/// kept in memory. A hash names fixed bytes, so entries never go stale;
/// listeners are notified when one arrives.
class AvatarCache extends ChangeNotifier {
final SyncClient sync;
/// Fetches one picture; null on any failure. Injectable for tests.
final Future<Uint8List?> Function(Uri url) _fetch;
final Map<String, Uint8List> _bytes = {};
final Set<String> _inFlight = {};
final Map<String, DateTime> _failedAt = {};
StreamSubscription? _sub;
bool _disposed = false;
static const _retryAfter = Duration(seconds: 10);
/// Above this many pictures, ones nobody in the latest state uses go.
static const _keep = 32;
AvatarCache(this.sync, {Future<Uint8List?> Function(Uri url)? fetch})
: _fetch = fetch ?? _httpGet {
_sub = sync.states.listen((s) {
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash};
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h));
live.forEach(_want);
});
}
/// The picture for [hash], or null (none, or not here yet: it is fetched
/// and listeners hear when it lands).
Uint8List? bytesFor(String hash) {
if (hash.isEmpty) return null;
final hit = _bytes[hash];
if (hit == null) _want(hash);
return hit;
}
void _want(String hash) {
if (_bytes.containsKey(hash) || _inFlight.contains(hash)) return;
final failed = _failedAt[hash];
if (failed != null && DateTime.now().difference(failed) < _retryAfter) return;
final ws = sync.wsUri;
final token = sync.token;
if (ws == null || token.isEmpty) return;
final url = Uri(
scheme: ws.scheme == 'wss' ? 'https' : 'http',
host: ws.host,
port: ws.port,
pathSegments: ['a', 'default', token, hash]);
_inFlight.add(hash);
unawaited(_fetch(url).then((bytes) {
_inFlight.remove(hash);
if (_disposed) return;
if (bytes == null || bytes.isEmpty) {
_failedAt[hash] = DateTime.now();
return;
}
_failedAt.remove(hash);
_bytes[hash] = bytes;
notifyListeners();
}));
}
static Future<Uint8List?> _httpGet(Uri url) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final resp = await (await client.getUrl(url)).close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200) {
await resp.drain<void>();
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
}
return builder.takeBytes();
} catch (_) {
return null;
} finally {
client.close(force: true);
}
}
@override
void dispose() {
_disposed = true;
unawaited(_sub?.cancel());
super.dispose();
}
}
@@ -33,6 +33,8 @@ class SyncClient {
Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
Uri? wsUri;
String token = '';
String memberId = '';
String? mediaUrl;
@@ -53,6 +55,7 @@ class SyncClient {
}) async {
await disconnect();
clockSync.reset();
this.wsUri = wsUri;
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
@@ -240,6 +243,13 @@ class SyncClient {
_send(ChatMessage(text: clean).toJson());
}
/// Mid-session name/picture change; null leaves it, an empty [avatar]
/// (base64 PNG) removes the picture.
void sendProfile({String? name, String? avatar}) {
if (name == null && avatar == null) return;
_send(ProfileIntent(name: name, avatar: avatar).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
}