forked from Foxyz/sync_replay_project
- 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>
713 lines
25 KiB
Dart
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}');
|
|
}
|