forked from Foxyz/sync_replay_project
Library: source switch, selectable cards with a details panel, one merged Continue row, Latest grouped per series without re-releases, Link page with previews and recent links, Files embedded. Player: gear popover in both designs, progress line while controls hide, live hwdec. Settings rebuilt as cards. State marks the host member (proto stays 9, unreleased); hosting shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps the outgoing view's state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
323 lines
11 KiB
Dart
323 lines
11 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 '../screens/home/profile.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, stale, host, self;
|
|
const AvatarInfo({
|
|
this.id = '',
|
|
required this.name,
|
|
required this.color,
|
|
this.image,
|
|
this.ready = false,
|
|
this.buffering = false,
|
|
this.relaying = false,
|
|
this.stale = false,
|
|
this.host = false,
|
|
this.self = false,
|
|
});
|
|
}
|
|
|
|
/// 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.sync.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}) {
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// [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.sync.memberId;
|
|
final relay = r == null ? null : stateMediaRelay(r);
|
|
final playing = r?.media != null;
|
|
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: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
|
|
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
|
stale: m.stale,
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
/// 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, dimmed = stale.
|
|
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.stale
|
|
? 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.stale ? 0.4 : 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 (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.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].name,
|
|
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 by display name (feed lines carry names, not ids): from the
|
|
/// latest state when still there, else their initial on a stable colour.
|
|
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
|
final c = coord ?? SessionState.instance.coordinator;
|
|
final r = c.serverState.value;
|
|
for (final m in r?.members ?? const <MemberView>[]) {
|
|
if (m.name == name) return memberAvatarInfo(m, r, c);
|
|
}
|
|
return AvatarInfo(name: name, color: colorForName(name));
|
|
}
|