forked from Foxyz/sync_replay_project
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
14 KiB
Dart
388 lines
14 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:signals/signals_flutter.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
|
|
import '../session/identity.dart';
|
|
import '../session/delivery.dart';
|
|
import '../session/party.dart';
|
|
import '../session/session_coordinator.dart';
|
|
import '../state/session_state.dart';
|
|
import 'motion.dart';
|
|
|
|
/// What an avatar shows: who, their picture or initial on [color], and
|
|
/// the status the ring and badges carry.
|
|
class AvatarInfo {
|
|
final String id;
|
|
final String name;
|
|
final Color color;
|
|
final ImageProvider? image;
|
|
final bool ready, buffering, relaying, host, self;
|
|
/// Where they stand: joining (a little faded), away (dimmed with a moon),
|
|
/// gone (dimmed, grey ring: reconnecting).
|
|
final Presence presence;
|
|
/// Since when they are gone; null otherwise.
|
|
final DateTime? goneSince;
|
|
/// Why their player cannot open the media (`members[].error`): an error
|
|
/// mark; empty when it can.
|
|
final String error;
|
|
/// How it gets the media and its buffer ([MemberDelivery.detail]), for
|
|
/// the hover; empty when unknown.
|
|
final String delivery;
|
|
const AvatarInfo({
|
|
this.id = '',
|
|
required this.name,
|
|
required this.color,
|
|
this.image,
|
|
this.ready = false,
|
|
this.buffering = false,
|
|
this.relaying = false,
|
|
this.presence = Presence.watching,
|
|
this.goneSince,
|
|
this.host = false,
|
|
this.self = false,
|
|
this.error = '',
|
|
this.delivery = '',
|
|
});
|
|
|
|
/// Sitting out.
|
|
bool get away => presence == Presence.away;
|
|
|
|
/// Disconnected, in their grace period.
|
|
bool get gone => presence == Presence.gone;
|
|
}
|
|
|
|
/// Stable colour for someone else (colours are not on the wire).
|
|
Color colorForName(String name) {
|
|
const palette = [
|
|
Colors.teal,
|
|
Colors.orange,
|
|
Colors.pink,
|
|
Colors.blue,
|
|
Colors.green,
|
|
Colors.indigo,
|
|
Colors.amber,
|
|
Colors.cyan,
|
|
Colors.red,
|
|
Colors.deepPurple,
|
|
];
|
|
var h = 0;
|
|
for (final c in name.codeUnits) {
|
|
h = (h * 31 + c) & 0x7fffffff;
|
|
}
|
|
return palette[h % palette.length];
|
|
}
|
|
|
|
final _images = <Object, MemoryImage>{};
|
|
|
|
MemoryImage _memo(Object key, Uint8List bytes) {
|
|
final hit = _images[key];
|
|
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
|
if (_images.length > 64) _images.clear();
|
|
return _images[key] = MemoryImage(bytes);
|
|
}
|
|
|
|
/// The one lookup for a member's picture: your own from the profile (no
|
|
/// round trip), anyone else's from the session's avatar cache by hash.
|
|
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
|
|
/// (see [PartyWatch]).
|
|
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
|
if (m.id.isNotEmpty && m.id == coord.connection.memberId) {
|
|
final own = Profile.instance.picture.value;
|
|
if (own != null) return _memo('self', own);
|
|
}
|
|
final bytes = coord.avatars.bytesFor(m.avatarHash);
|
|
return bytes == null ? null : _memo(m.avatarHash, bytes);
|
|
}
|
|
|
|
/// Your own avatar from the profile (also offline).
|
|
AvatarInfo selfAvatarInfo(
|
|
{bool ready = false, bool host = false, Presence presence = Presence.watching, String error = ''}) {
|
|
final p = Profile.instance;
|
|
final png = p.picture.value;
|
|
final name = p.name.value;
|
|
return AvatarInfo(
|
|
name: name.isEmpty ? '?' : name,
|
|
color: p.color.value,
|
|
image: png == null ? null : _memo('self', png),
|
|
ready: ready,
|
|
host: host,
|
|
self: true,
|
|
presence: presence,
|
|
error: error,
|
|
);
|
|
}
|
|
|
|
/// [m] as the party strip, toasts and the chat show it.
|
|
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
|
|
final self = m.id.isNotEmpty && m.id == coord.connection.memberId;
|
|
final relay = r == null ? null : stateMediaRelay(r);
|
|
final playing = r?.media != null;
|
|
final delivery = r == null ? null : deliveryOf(r, selfId: coord.connection.memberId, names: coord.memberNames);
|
|
return AvatarInfo(
|
|
id: m.id,
|
|
name: m.name,
|
|
color: self ? Profile.instance.color.value : colorForName(m.name),
|
|
image: memberAvatarImage(m, coord),
|
|
ready: m.ready,
|
|
buffering: countsForReady(m) && m.ready && playing && m.buffered < r!.bufferNeed,
|
|
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
|
presence: m.presence,
|
|
goneSince: m.goneSince,
|
|
// The state marks the host (`members[].host`); your own app also
|
|
// knows before the first state.
|
|
host: m.host || (self && coord.hostFile != null),
|
|
self: self,
|
|
error: m.error,
|
|
delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail,
|
|
);
|
|
}
|
|
|
|
/// Everyone in the server, from the latest state.
|
|
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
|
|
final c = coord ?? SessionState.instance.coordinator;
|
|
final r = c.serverState.value;
|
|
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
|
|
}
|
|
|
|
/// Rebuilds [builder] when the party state, your profile or a member's
|
|
/// picture changes.
|
|
class PartyWatch extends StatelessWidget {
|
|
final WidgetBuilder builder;
|
|
const PartyWatch({super.key, required this.builder});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ListenableBuilder(
|
|
listenable: SessionState.instance.coordinator.avatars,
|
|
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
|
|
);
|
|
}
|
|
|
|
/// Circle avatar with a status ring outside it and a gap in the background
|
|
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
|
/// tower = passing the video on, spinner = buffering, a little faded =
|
|
/// joining, dimmed with a grey ring = gone (reconnecting), dimmed with a
|
|
/// moon = sitting out, red "!" = cannot play the media.
|
|
class StatusAvatar extends StatelessWidget {
|
|
final AvatarInfo info;
|
|
final double size;
|
|
|
|
/// Host crown and relay tower; off where the context already says it.
|
|
final bool badges;
|
|
|
|
/// Ring colour when not ready (the profile shows none at all).
|
|
final bool ring;
|
|
const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
|
final ringColor = !ring
|
|
? bg
|
|
: info.gone
|
|
? Colors.grey.shade700
|
|
: info.buffering
|
|
? Colors.amber
|
|
: info.ready
|
|
? Colors.greenAccent.shade400
|
|
: Colors.grey;
|
|
final badge = size * 0.42;
|
|
// Ring and gap scale with the avatar (2 px at strip size).
|
|
final stroke = (size * 0.055).clamp(2.0, 4.0);
|
|
final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase();
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Fills the box: a non-positioned Stack child only gets loose
|
|
// constraints, and CircleAvatar then falls back to 40 px.
|
|
Positioned.fill(
|
|
child: Container(
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor),
|
|
padding: EdgeInsets.all(stroke),
|
|
child: Container(
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
|
padding: EdgeInsets.all(stroke),
|
|
child: Opacity(
|
|
opacity: info.gone || info.away
|
|
? 0.4
|
|
: info.presence == Presence.joining
|
|
? 0.7
|
|
: 1,
|
|
child: CircleAvatar(
|
|
radius: size / 2,
|
|
backgroundColor: info.color,
|
|
backgroundImage: info.image,
|
|
child: info.image != null
|
|
? null
|
|
: Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (info.buffering)
|
|
Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)),
|
|
if (badges && info.host)
|
|
Positioned(
|
|
left: -4,
|
|
top: -6,
|
|
child: Transform.rotate(
|
|
angle: -0.4,
|
|
child: Icon(
|
|
Icons.workspace_premium,
|
|
size: badge,
|
|
color: Colors.amber,
|
|
shadows: const [Shadow(blurRadius: 3, color: Colors.black)],
|
|
),
|
|
),
|
|
),
|
|
if (info.away)
|
|
Positioned(
|
|
key: const ValueKey('away-mark'),
|
|
left: -3,
|
|
bottom: -3,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
|
child: Icon(Icons.bedtime, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.blueGrey.shade200),
|
|
),
|
|
),
|
|
if (info.error.isNotEmpty)
|
|
Positioned(
|
|
key: const ValueKey('error-mark'),
|
|
right: -3,
|
|
top: -3,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(1),
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
|
child: Icon(Icons.error, size: (badge * 0.8).clamp(10.0, 24.0), color: Colors.redAccent.shade200),
|
|
),
|
|
),
|
|
if (badges && info.relaying)
|
|
Positioned(
|
|
right: -3,
|
|
bottom: -3,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
|
child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Overlapping avatars, host first then ready (the stack itself reads as
|
|
/// progress); they spread apart on hover. "+N" past [shown].
|
|
class AvatarStack extends StatefulWidget {
|
|
final List<AvatarInfo> members;
|
|
final VoidCallback? onTap;
|
|
final int shown;
|
|
final double size;
|
|
const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42});
|
|
|
|
@override
|
|
State<AvatarStack> createState() => _AvatarStackState();
|
|
}
|
|
|
|
class _AvatarStackState extends State<AvatarStack> {
|
|
bool _hover = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = widget.size;
|
|
int rank(AvatarInfo m) => m.host ? 0 : (m.gone ? 4 : m.away ? 3 : m.ready ? 1 : 2);
|
|
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
|
final visible = sorted.take(widget.shown).toList();
|
|
final extra = sorted.length - visible.length;
|
|
final step = _hover ? size + 6 : size * 0.62;
|
|
final count = visible.length + (extra > 0 ? 1 : 0);
|
|
final width = count == 0 ? 0.0 : step * (count - 1) + size;
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hover = true),
|
|
onExit: (_) => setState(() => _hover = false),
|
|
cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
key: const ValueKey('avatar-stack'),
|
|
duration: easeTime,
|
|
curve: ease,
|
|
width: width,
|
|
height: size,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
for (var i = visible.length - 1; i >= 0; i--)
|
|
AnimatedPositioned(
|
|
key: ValueKey('stack-${visible[i].id}-${visible[i].name}'),
|
|
duration: easeTime,
|
|
curve: ease,
|
|
left: i * step,
|
|
top: 0,
|
|
child: Tooltip(
|
|
message: [
|
|
visible[i].error.isEmpty
|
|
? visible[i].name
|
|
: "${visible[i].name} can't play this: ${visible[i].error}",
|
|
if (visible[i].delivery.isNotEmpty) visible[i].delivery,
|
|
].join('\n'),
|
|
child: StatusAvatar(info: visible[i], size: size),
|
|
),
|
|
),
|
|
if (extra > 0)
|
|
AnimatedPositioned(
|
|
duration: easeTime,
|
|
curve: ease,
|
|
left: visible.length * step,
|
|
top: 0,
|
|
child: CircleAvatar(
|
|
radius: size / 2,
|
|
child: Text('+$extra', style: const TextStyle(fontSize: 12)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Small coloured label ("host", "passing the video on").
|
|
class Pill extends StatelessWidget {
|
|
final String text;
|
|
final Color color;
|
|
const Pill(this.text, this.color, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
|
|
);
|
|
}
|
|
|
|
/// Someone in a feed line or toast: by member [id] when it has one, else
|
|
/// by display [name]; from the latest state when still there, else their
|
|
/// initial on a stable colour.
|
|
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
|
|
final c = coord ?? SessionState.instance.coordinator;
|
|
final r = c.serverState.value;
|
|
final members = r?.members ?? const <MemberView>[];
|
|
// By id when the line has one (names change and repeat), else by name.
|
|
for (final m in members) {
|
|
if (id.isNotEmpty ? m.id == id : m.name == name) return memberAvatarInfo(m, r, c);
|
|
}
|
|
return AvatarInfo(name: name, color: colorForName(name));
|
|
}
|