Files
sync_replay_project/apps/desktop/lib/src/shell/party_strip.dart
T
foxyzandClaude Opus 5.5 4d00b7455e Restructure step 3: your server, Invite, version gate, AppGraph
- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:52:52 +03:00

713 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart';
import '../session/party.dart';
import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart';
import '../session/navigation.dart' show Place;
import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'delivery_line.dart';
import 'motion.dart';
/// The shell's tabs, in strip order.
enum ShellTab {
library('Library'),
schedule('Schedule'),
settings('Settings');
final String label;
const ShellTab(this.label);
}
const partyStripHeight = 60.0;
/// Every avatar in the strip, yours and the party's.
const stripAvatarSize = 42.0;
/// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room.
const stripCompactBelow = 1100.0;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party. Alone on your own server it is a plain player's: Join a
/// friend and Invite (a "Sharing" chip while your server is open to
/// friends); with others there the avatars (members card), server and
/// readiness, chat, and Invite (yours) or Leave (a friend's). Empty space
/// drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
final bool chatOpen;
final int unread;
final VoidCallback onChat;
final VoidCallback onJoin;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Opens your server to friends (if it is not yet) and copies the
/// invite.
final VoidCallback onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
/// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls;
const PartyStrip({
super.key,
required this.tab,
required this.onTab,
required this.chatOpen,
required this.unread,
required this.onChat,
required this.onJoin,
required this.onLeave,
required this.onProfile,
required this.onInvite,
this.onShowPlayer,
this.windowControls = true,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
final row = SignalBuilder(
builder: (context) {
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final online = nav.online.value;
final home = nav.home.value;
final friend = nav.place.value == Place.friend;
// Others there (or a friend's server): the party; else a plain
// player (D9).
final social = friend || (online && !coord.alone.value);
final shared = graph.yourServer.shared.value;
final canInvite = graph.yourServer.port.value != null && !graph.yourServer.busy.value;
final self = social ? coord.selfMember.value : null;
return Row(
children: [
const SizedBox(width: 16),
Tooltip(
message: 'Your profile',
child: InkWell(
key: const ValueKey('strip-profile'),
canRequestFocus: false,
customBorder: const CircleBorder(),
onTap: onProfile,
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: social && home,
error: online ? coord.playerError.value : '',
),
size: stripAvatarSize,
ring: social,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: HScroll(
builder: (c) => SingleChildScrollView(
controller: c,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in ShellTab.values)
StripTab(
key: ValueKey('tab-${t.name}'),
label: t.label,
selected: t == tab,
compact: compact,
// The version a rest of the mouse away (also at
// the bottom of Settings and in About).
tooltip: t == ShellTab.settings ? '$appName ${displayVersion()}' : null,
onTap: () => onTab(t),
),
],
),
),
),
),
),
const SizedBox(width: 8),
if (online && onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (social) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
members: partyAvatars(coord),
shown: compact ? 3 : 5,
size: stripAvatarSize,
onTap: () => showMembersCard(context),
),
),
const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6),
// Tab toggles the chat while the strip is shown.
ChatKey(
onToggle: onChat,
child: Badge(
isLabelVisible: unread > 0,
label: Text('$unread'),
child: IconButton(
key: const ValueKey('strip-chat'),
tooltip: 'Chat & activity (Tab)',
isSelected: chatOpen,
onPressed: onChat,
icon: const Icon(Icons.chat_bubble_outline, size: 20),
selectedIcon: Icon(
Icons.chat_bubble,
size: 20,
color: scheme.primary,
),
),
),
),
const SizedBox(width: 4),
if (friend)
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
)
else
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
] else ...[
if (shared != null) ...[
_SharingChip(compact: compact, port: shared.port),
const SizedBox(width: 8),
],
if (compact)
FilledButton.tonal(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join'))
else
FilledButton.tonalIcon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
],
const SizedBox(width: 8),
if (windowControls) ...[
SizedBox(
height: 20,
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
),
const WindowButtons(),
] else
const SizedBox(width: 8),
],
);
},
);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest,
border: Border(
bottom: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.4),
),
),
),
child: SizedBox(
height: partyStripHeight,
child: Stack(
children: [
// Empty strip space drags the window; the controls on top take
// their own clicks.
if (windowControls)
const Positioned.fill(
child: WindowDragArea(child: SizedBox.expand()),
),
Positioned.fill(child: row),
],
),
),
);
}
}
/// Server name and "ready n/m".
class _ServerLabel extends StatelessWidget {
final ColorScheme scheme;
const _ServerLabel({required this.scheme});
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) {
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
// counts who the presence policy counts), from the one status.
final graph = AppScope.of(context);
final line = graph.coordinator.status.value.party;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
serverName(graph),
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
line,
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
height: 1.2,
),
),
],
);
},
);
}
/// "Your server" at home, else the joined server's name (its `--name`),
/// or its address while unknown.
String serverName(AppGraph graph) {
final nav = graph.navigation;
if (nav.home.value) return 'Your server';
final label = nav.serverLabel.value;
return label.isNotEmpty ? label : nav.address.value;
}
/// Invite: opens your server to friends (if it is not yet) and copies
/// the invite.
class _InviteButton extends StatelessWidget {
final bool compact;
final VoidCallback? onInvite;
const _InviteButton({required this.compact, required this.onInvite});
@override
Widget build(BuildContext context) {
const tip = 'Copy an invite friends can join with (opens your server to them)';
if (compact) {
return Tooltip(
message: tip,
child: FilledButton(key: const ValueKey('strip-invite'), onPressed: onInvite, child: const Text('Invite')),
);
}
return Tooltip(
message: tip,
child: FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite'),
),
);
}
}
/// Your server is open to friends and nobody came yet: the port; a click
/// opens the members card (the invite, Stop sharing).
class _SharingChip extends StatelessWidget {
final bool compact;
final int port;
const _SharingChip({required this.compact, required this.port});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Friends can join with your invite. Click for the invite or to stop sharing.',
child: InkWell(
key: const ValueKey('sharing-chip'),
borderRadius: BorderRadius.circular(20),
onTap: () => showMembersCard(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
if (!compact) ...[
const SizedBox(width: 6),
Text('port $port', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
),
),
),
);
}
}
/// One strip tab: label and an underline that grows in when selected.
class StripTab extends StatelessWidget {
final String label;
final bool selected;
final bool compact;
final VoidCallback onTap;
/// Shown after resting on the tab.
final String? tooltip;
const StripTab({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.compact = false,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final tab = Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox(
height: partyStripHeight,
child: Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
AnimatedContainer(
duration: easeTime,
curve: ease,
height: 3,
width: selected ? 22 : 0,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
],
),
],
),
),
),
),
);
final tip = tooltip;
return tip == null ? tab : Tooltip(message: tip, waitDuration: const Duration(milliseconds: 600), child: tab);
}
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// per member force ready, nudge and sit out, and the invite (yours) or
/// the address (a friend's).
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
class MembersCard extends StatelessWidget {
const MembersCard({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('members-card'),
elevation: 12,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 340,
child: PartyWatch(
builder: (context) {
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final r = coord.serverState.value;
final home = nav.home.value;
final address = home ? null : nav.address.value;
final delivery = coord.status.value.delivery;
final server = nav.serverVersion.value;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
home ? 'Your server' : ['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (server.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: VersionText(
key: const ValueKey('server-version'),
label: 'Server',
version: server,
),
),
],
),
),
if (delivery != null) SourceLine(delivery: delivery),
for (final m in r?.members ?? const <MemberView>[])
_MemberTile(
member: m,
info: memberAvatarInfo(m, r, coord),
delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery),
),
if (r == null)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'connecting…',
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
const Divider(),
if (home)
const YourInvite()
else if (address != null && address.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
),
),
],
);
},
),
),
);
}
}
class _MemberTile extends StatelessWidget {
final MemberView member;
final AvatarInfo info;
/// How it gets the media and its buffer; null with nothing up or alone.
final MemberDelivery? delivery;
const _MemberTile({
required this.member,
required this.info,
this.delivery,
});
@override
Widget build(BuildContext context) {
final coord = AppScope.of(context).coordinator;
final m = member;
final since = m.goneSince;
final status = m.presence == Presence.gone
? 'reconnecting…${since == null ? '' : ' (${sinceText(since, DateTime.now().toUtc())})'}'
: m.presence == Presence.away
? 'sitting out'
: m.presence == Presence.joining
? 'joining…'
: m.error.isNotEmpty
? "can't play this: ${m.error}"
: info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready
? 'ready'
: 'not ready';
return ListTile(
key: ValueKey('member-${m.id}'),
dense: true,
contentPadding: const EdgeInsets.only(left: 20, right: 8),
leading: StatusAvatar(info: info, size: 36, badges: false),
title: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(info.self ? '${m.name} (you)' : m.name),
if (m.app.isNotEmpty) VersionText(key: ValueKey('version-${m.id}'), version: m.app),
if (info.host) const Pill('host', Colors.amber),
if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent),
],
),
subtitle: DeliveryStatus(status: status, delivery: delivery),
trailing: AppMenuButton<String>(
tooltip: 'More',
onSelected: (v) {
switch (v) {
case 'force':
coord.forceTarget(m.id, !m.ready);
case 'nudge':
coord.nudgeMember(m.id);
case 'out':
coord.sitOut(m.id);
case 'back':
coord.joinBack();
}
},
items: () => [
if (m.presence != Presence.away) AppMenuItem(value: 'force', label: m.ready ? 'Force unready' : 'Force ready'),
if (!info.self) const AppMenuItem(value: 'nudge', label: 'Nudge'),
if (info.self && m.presence == Presence.away)
const AppMenuItem(value: 'back', label: 'Join back')
else if (m.presence != Presence.away)
const AppMenuItem(value: 'out', label: 'Sit out'),
],
),
);
}
}
/// A Starsync version, quiet ("1.3.0"), amber with a hint when it is not
/// the one this app runs; [label] before it ("Server 1.3.0").
class VersionText extends StatelessWidget {
final String version;
final String? label;
const VersionText({super.key, required this.version, this.label});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final differs = versionDiffers(version);
final text = Text(
[?label, version].join(' '),
style: TextStyle(
fontSize: 11,
color: differs ? Colors.amber.shade300 : scheme.onSurfaceVariant.withValues(alpha: 0.8),
fontWeight: differs ? FontWeight.w600 : FontWeight.w400,
),
);
if (!differs) return text;
return Tooltip(message: 'You run ${shortVersion()}', child: text);
}
}
/// [version] is not the one this app runs (a dev run matches anything:
/// it has no version of its own).
bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Your server in the members card: shared, the invite's address
/// (Copy invite), a hint for friends outside the network and Stop
/// sharing; not shared, Invite (opens it and copies the invite).
class YourInvite extends StatelessWidget {
const YourInvite({super.key});
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
final graph = AppScope.of(context);
final yours = graph.yourServer;
return SignalBuilder(builder: (context) {
final invite = yours.shared.value;
final busy = yours.busy.value || yours.port.value == null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invite == null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
style: TextStyle(color: muted, fontSize: 12)),
),
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Invite'),
),
] else ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(invite.address,
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
),
Wrap(
spacing: 4,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite'),
),
TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
child: Text(
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
'forwarded on your router, or a server they can all reach (like a VPS). '
'Stop sharing sends everyone else out; you and Up next stay.',
style: TextStyle(color: muted, fontSize: 12),
),
),
],
],
),
);
});
}
}
/// Invite: opens your server to friends if it is not yet and copies the
/// invite ("Invite copied"); a port that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
try {
invite = await graph.yourServer.invite();
} catch (e) {
final port = graph.forms.form.hostPort;
graph.notifier.notify(ActionFailed(
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
return;
}
if (!context.mounted) return;
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
}