Initial commit: watch-together phases 0-4 + presence hardening, internet-tested

This commit is contained in:
foxyz committed 2026-09-12 00:13:43 +03:00
commit 427d851a5b
101 files changed
+11326

No files matched your search

@@ -0,0 +1,102 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import '../sync/room_diff.dart';
class PlaybackController {
late final Player player;
bool _ready = false;
Future<void> init() async {
player = Player(
configuration: PlayerConfiguration(
bufferSize: 64 * 1024 * 1024,
vo: Platform.environment['SYNC_RELAY_VO'],
));
_ready = true;
await _pinBufferProps();
}
Future<void> _pin(String property, String value) async {
try {
final dynamic platform = player.platform;
await platform?.setProperty(property, value);
} catch (e, st) {
debugPrint('pin $property=$value failed: $e\n$st');
}
}
Future<void> _pinBufferProps() async {
await _pin('demuxer-readahead-secs', '10');
await _pin('cache-secs', '10');
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
if (hwdec != null && hwdec.isNotEmpty) {
await _pin('hwdec', hwdec);
}
await _pin('stream-lavf-o',
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
}
Future<void> openUrl(String url) async {
if (!_ready) return;
try {
await player.open(Media(url), play: false);
} catch (e, st) {
debugPrint('open $url failed: $e\n$st');
rethrow;
}
}
double get positionSecs =>
player.state.position.inMilliseconds / 1000.0;
double get bufferedSecsAhead {
final ahead =
(player.state.buffer - player.state.position).inMilliseconds / 1000.0;
if (ahead.isNaN || ahead < 0) return 0;
return ahead;
}
double get durationSecs =>
player.state.duration.inMilliseconds / 1000.0;
Future<void> apply(List<PlayerAction> actions) async {
for (final action in actions) {
try {
switch (action) {
case SeekAction(:final position):
if ((positionSecs - position).abs() < 0.5) break;
await player.seek(Duration(milliseconds: (position * 1000).round()));
case TransportAction(:final playing):
if (playing) {
await player.play();
} else {
await player.pause();
}
case RateAction(:final rate):
await player.setRate(rate);
}
} catch (e, st) {
debugPrint('player action $action failed: $e\n$st');
}
}
}
Future<void> dispose() async {
_ready = false;
try {
await player.dispose();
} catch (_) {}
}
Future<void> stop() async {
if (!_ready) return;
try {
await player.stop();
} catch (e, st) {
debugPrint('player stop failed: $e\n$st');
}
}
}
@@ -0,0 +1,351 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../widgets/avatar_pipeline.dart';
import '../widgets/media_input.dart';
import '../widgets/user_section.dart';
class ConnectScreen extends StatefulWidget {
const ConnectScreen({super.key});
@override
State<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends State<ConnectScreen> {
late final TextEditingController _host;
late final TextEditingController _port;
late final TextEditingController _secret;
late final TextEditingController _name;
late final TextEditingController _media;
late final void Function() _stopKickWatch;
String? _error;
String? _notice;
bool _busy = false;
bool _dialogOpen = false;
int _attempt = 0;
String? _lastKickShown;
@override
void initState() {
super.initState();
final f = SessionState.instance.lastForm;
_host = TextEditingController(text: f.host);
_port = TextEditingController(text: f.port);
_secret = TextEditingController(text: f.secret);
_name = TextEditingController(text: f.name);
_media = TextEditingController(text: f.linkUrl.isNotEmpty ? f.linkUrl : f.mediaPath);
_stopKickWatch = effect(() {
final kick = SessionState.instance.coordinator.kickReason.value;
if (kick == null || kick.isEmpty) {
_lastKickShown = null;
return;
}
if (kick == _lastKickShown) return;
_lastKickShown = kick;
final reason = kick;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _showKickedDialog(reason);
});
});
}
@override
void dispose() {
_stopKickWatch();
_host.dispose();
_port.dispose();
_secret.dispose();
_name.dispose();
_media.dispose();
super.dispose();
}
String _actionable(Object e) {
if (e is FormatException) return e.message;
return 'check the form and try again';
}
Future<ConnectParams> _buildParams(ConnectMode mode) async {
final port = int.tryParse(_port.text.trim());
if (port == null || port <= 0 || port > 65535) {
throw const FormatException('port must be 1–65535');
}
if (_secret.text.isEmpty) {
throw const FormatException('room password required');
}
if (_name.text.trim().isEmpty) {
throw const FormatException('display name required');
}
String avatar = '';
try {
avatar = await encodeAvatar(SessionState.instance.lastForm.avatarPath);
} on FormatException catch (e) {
throw FormatException(e.message);
} catch (_) {
throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo');
}
final parsed = parseMediaInput(_media.text);
if (mode == ConnectMode.host && parsed.mediaPath.isNotEmpty && !await File(parsed.mediaPath).exists()) {
throw const FormatException('media file not found — check the path or pick again with Browse');
}
return ConnectParams(
host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(),
port: port,
secret: _secret.text,
name: _name.text.trim(),
avatar: avatar,
mediaPath: mode == ConnectMode.host ? parsed.mediaPath : '',
linkUrl: mode == ConnectMode.host ? parsed.linkUrl : '',
);
}
Future<void> _submit(ConnectMode mode) async {
final myAttempt = ++_attempt;
final session = SessionState.instance;
setState(() {
_error = null;
_notice = null;
_busy = true;
});
late final ConnectParams params;
try {
params = await _buildParams(mode);
} catch (e) {
if (!mounted || myAttempt != _attempt) return;
setState(() {
_error = _actionable(e);
_busy = false;
});
return;
}
final parsed = parseMediaInput(_media.text);
final form = SessionState.instance.lastForm
..host = _host.text.trim()
..port = _port.text.trim()
..secret = _secret.text
..name = _name.text.trim()
..mediaPath = parsed.mediaPath
..linkUrl = parsed.linkUrl;
try {
if (mode == ConnectMode.host) {
await session.host(params, form);
} else {
await session.join(params, form);
}
} catch (e) {
if (!mounted || myAttempt != _attempt) return;
final kick = session.coordinator.kickReason.peek();
setState(() => _busy = false);
if (kick != null && kick.isNotEmpty) {
await _showKickedDialog(kick);
} else {
await _showConnectionDialog('$e');
}
return;
}
if (myAttempt != _attempt) {
try {
await session.leave();
await session.stopServer();
} catch (_) {}
return;
}
if (mounted) setState(() => _busy = false);
}
Future<void> _cancel() async {
_attempt++;
try {
await SessionState.instance.coordinator.cancelConnect();
await SessionState.instance.stopServer();
} catch (_) {}
if (!mounted) return;
setState(() {
_busy = false;
_notice = 'connection cancelled';
});
}
Future<void> _showKickedDialog(String reason) async {
if (!mounted || _dialogOpen) return;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('You were removed'),
content: Text(reason.isEmpty ? 'The host removed you from the room.' : reason),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
Future<void> _showConnectionDialog(String message) async {
if (!mounted || _dialogOpen) return;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Could not connect'),
content: Text(message),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
Future<void> _browse(TextEditingController target, {required String dialogTitle, List<String>? allowedExtensions}) async {
try {
final files = await FilePicker.pickFiles(
dialogTitle: dialogTitle,
type: allowedExtensions == null ? FileType.any : FileType.custom,
allowedExtensions: allowedExtensions,
);
if (files.isEmpty) return;
final path = files.first.path;
if (path != null && mounted) setState(() => target.text = path);
} catch (e) {
if (!mounted) return;
setState(() => _notice = 'file dialog unavailable (${e.runtimeType}) — paste the path manually');
}
}
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
return Scaffold(
appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
),
body: Stack(
alignment: AlignmentGeometry.center,
children: [
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.all(20),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: ConnectProfileCard(name: _name),
),
),
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(24),
children: [
if (_error != null)
Card(
child: Padding(padding: const EdgeInsets.all(12), child: Text(_error!)),
),
if (_notice != null) Text(_notice!),
SignalBuilder(
builder: (context) {
final mode = session.connectMode.value;
return SegmentedButton<ConnectMode>(
segments: const [
ButtonSegment(value: ConnectMode.host, label: Text('Host')),
ButtonSegment(value: ConnectMode.join, label: Text('Join')),
],
selected: {mode},
onSelectionChanged: (s) => session.connectMode.value = s.first,
);
},
),
const SizedBox(height: 16),
SignalBuilder(
builder: (context) {
if (session.connectMode.value == ConnectMode.host) {
return const SizedBox.shrink();
}
return TextField(
controller: _host,
decoration: const InputDecoration(labelText: 'Host address'),
);
},
),
TextField(
controller: _port,
decoration: const InputDecoration(labelText: 'Port'),
keyboardType: TextInputType.number,
),
TextField(
controller: _secret,
decoration: const InputDecoration(labelText: 'Room password'),
obscureText: true,
),
const SizedBox(height: 8),
SignalBuilder(
builder: (context) {
if (session.connectMode.value == ConnectMode.host) {
return Row(
children: [
Expanded(
child: TextField(
controller: _media,
decoration: const InputDecoration(labelText: 'Media (file path or http(s) URL)'),
),
),
TextButton(
onPressed: () => _browse(
_media,
dialogTitle: 'Pick media file',
allowedExtensions: const ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'],
),
child: const Text('Browse'),
),
],
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
SignalBuilder(
builder: (context) {
final mode = session.connectMode.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ElevatedButton(
onPressed: _busy ? null : () => _submit(mode),
child: Text(
_busy
? 'Connecting…'
: mode == ConnectMode.host
? 'Start hosting'
: 'Join',
),
),
if (_busy) TextButton(onPressed: _cancel, child: const Text('Cancel')),
],
);
},
),
const SizedBox(height: 12),
const Text('Host opens ports and serves. Clients open nothing.', textAlign: TextAlign.center),
],
),
),
],
),
);
}
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/session_state.dart';
const _legacyControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
bottomButtonBar: [
MaterialDesktopVolumeButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class LegacySessionScreen extends StatefulWidget {
const LegacySessionScreen({super.key});
@override
State<LegacySessionScreen> createState() => _LegacySessionScreenState();
}
class _LegacySessionScreenState extends State<LegacySessionScreen> {
VideoController? _video;
final _focus = FocusNode();
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
}
@override
void dispose() {
_focus.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final coord = session.coordinator;
return Focus(
focusNode: _focus,
autofocus: true,
onKeyEvent: _onKey,
child: Scaffold(
appBar: AppBar(
title: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final r = coord.room.value;
return Text('Room · ${r?.transport ?? '…'} · ${h.message}');
},
),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => session.leave(),
),
],
),
body: Column(
children: [
SignalBuilder(builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final text = [
h.message,
if (starve != null) starve,
].join(' · ');
return Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(text),
);
}),
Expanded(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _legacyControls,
fullscreen: _legacyControls,
child: Video(controller: _video!),
),
),
SignalBuilder(builder: (context) {
final r = coord.room.value;
if (r == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
children: [
for (final m in r.members)
Chip(
avatar: CircleAvatar(
child: Text(m.name.isEmpty
? '?'
: m.name[0].toUpperCase()),
),
label: Text(m.name),
backgroundColor: m.ready
? Colors.green.withValues(alpha: 0.3)
: null,
),
],
),
);
}),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SignalBuilder(builder: (context) {
final ready =
coord.selfMember.value?.ready ?? false;
return ElevatedButton(
onPressed: () => coord.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready'),
);
}),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () => coord.forcePlayAll(),
child: const Text('Force play all'),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../state/session_state.dart';
const _minimalControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
bottomButtonBar: [
MaterialDesktopVolumeButton(),
_ReadyButton(),
_MarkAllButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class MinimalSessionScreen extends StatefulWidget {
const MinimalSessionScreen({super.key});
@override
State<MinimalSessionScreen> createState() => _MinimalSessionScreenState();
}
class _MinimalSessionScreenState extends State<MinimalSessionScreen> {
VideoController? _video;
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_key);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_key);
super.dispose();
}
bool _key(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) {
return false;
}
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _minimalControls,
fullscreen: _minimalControls,
child: Video(controller: _video!),
),
),
const Positioned(
top: 8,
right: 8,
child: Text('MINIMAL',
style: TextStyle(color: Colors.red, fontSize: 12)),
),
],
),
);
}
}
class _ReadyButton extends StatelessWidget {
const _ReadyButton();
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState
.instance.coordinator.selfMember.value?.ready ??
false;
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready',
style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllButton extends StatelessWidget {
const _MarkAllButton();
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready',
style: TextStyle(fontSize: 12)),
);
}
}
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../session/health.dart';
import '../state/session_state.dart';
import '../widgets/member_avatar.dart';
const _nosheetControls = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: false,
controlsHoverDuration: Duration(hours: 24),
bottomButtonBar: [
MaterialDesktopVolumeButton(),
_ReadyButton(),
_MarkAllButton(),
MaterialDesktopPositionIndicator(),
Spacer(),
MaterialDesktopFullscreenButton(),
],
);
class NosheetSessionScreen extends StatefulWidget {
const NosheetSessionScreen({super.key});
@override
State<NosheetSessionScreen> createState() => _NosheetSessionScreenState();
}
class _NosheetSessionScreenState extends State<NosheetSessionScreen> {
VideoController? _video;
@override
void initState() {
super.initState();
_video = VideoController(
SessionState.instance.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_key);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_key);
super.dispose();
}
bool _key(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) {
return false;
}
if (event.logicalKey == LogicalKeyboardKey.space) {
SessionState.instance.coordinator.toggleReady();
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _video == null
? const Center(child: Text('player starting…'))
: MaterialDesktopVideoControlsTheme(
normal: _nosheetControls,
fullscreen: _nosheetControls,
child: Video(controller: _video!),
),
),
const Positioned(
top: 8,
right: 8,
child: Text('NOSHEET',
style: TextStyle(color: Colors.red, fontSize: 12)),
),
Positioned(
top: 12,
left: 12,
child: SignalBuilder(builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final unwell = h.severity == HealthSeverity.warn ||
h.severity == HealthSeverity.error ||
retrying ||
starve != null;
if (!unwell) return const SizedBox.shrink();
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
[h.message, if (starve != null) starve].join(' · '),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
);
}),
),
SignalBuilder(builder: (context) {
final r = coord.room.value;
if (r == null || r.transport != 'held') {
return const SizedBox.shrink();
}
final waiting = [
for (final m in r.members)
if (!m.ready) m
];
if (waiting.isEmpty) return const SizedBox.shrink();
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: Wrap(
spacing: -20,
children: [
for (final m in waiting)
Opacity(
opacity: m.stale ? 0.5 : 1.0,
child: MemberAvatar(
avatarBase64: m.avatar,
name: m.name,
size: 64,
),
),
],
),
),
),
);
}),
Positioned(
right: 12,
top: 56,
child: SignalBuilder(builder: (context) {
final items = coord.toasts.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final t in items.reversed.take(5))
Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Text(t.text),
),
),
],
);
}),
),
],
),
);
}
}
class _ReadyButton extends StatelessWidget {
const _ReadyButton();
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState
.instance.coordinator.selfMember.value?.ready ??
false;
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready',
style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllButton extends StatelessWidget {
const _MarkAllButton();
@override
Widget build(BuildContext context) {
return TextButton(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () =>
SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready',
style: TextStyle(fontSize: 12)),
);
}
}
@@ -0,0 +1,580 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import '../session/health.dart';
import '../state/session_state.dart';
import '../widgets/dialogs.dart';
import '../widgets/member_avatar.dart';
class SessionScreen extends StatefulWidget {
const SessionScreen({super.key});
@override
State<SessionScreen> createState() => _SessionScreenState();
}
class _SessionScreenState extends State<SessionScreen> {
VideoController? _video;
bool _sheetOpen = false;
bool _confirmLeave = false;
DateTime? _lastKeyAt;
final _chat = TextEditingController();
final _swapLink = TextEditingController();
late final void Function() _stopKickWatch;
late final FocusNode _noFocus;
@override
void initState() {
super.initState();
final session = SessionState.instance;
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
_video = VideoController(session.coordinator.playback.player);
HardwareKeyboard.instance.addHandler(_globalKey);
_stopKickWatch = effect(() {
if (session.coordinator.kickReason.value != null) {
session.route.value = AppRoute.connect;
}
});
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey);
_stopKickWatch();
_chat.dispose();
_swapLink.dispose();
_noFocus.dispose();
super.dispose();
}
bool get _editing {
var n = FocusManager.instance.primaryFocus;
while (n != null) {
final w = n.context?.widget;
if (w is EditableText) return true;
n = n.parent;
}
return false;
}
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
if (_editing) return false;
final now = DateTime.now().toUtc();
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
return false;
}
final coord = SessionState.instance.coordinator;
if (event.logicalKey == LogicalKeyboardKey.space) {
_lastKeyAt = now;
coord.toggleReady();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.tab) {
if (!mounted) return false;
_lastKeyAt = now;
setState(() => _sheetOpen = !_sheetOpen);
coord.markInput();
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
_lastKeyAt = now;
coord.nudge(10);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
_lastKeyAt = now;
coord.nudge(-10);
return true;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
if (!mounted) return false;
if (_sheetOpen) {
setState(() => _sheetOpen = false);
return true;
}
_lastKeyAt = now;
setState(() => _confirmLeave = true);
return true;
}
return false;
}
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final coord = session.coordinator;
final controlsTheme = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false,
keyboardShortcuts: {},
visibleOnMount: true,
hideMouseOnControlsRemoval: false,
controlsHoverDuration: const Duration(hours: 24),
bottomButtonBar: [
const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus),
_MarkAllReadyControlButton(focusNode: _noFocus),
const MaterialDesktopPositionIndicator(),
const Spacer(),
const MaterialDesktopFullscreenButton(),
],
);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _video == null
? const Center(
child: Text('player starting…', style: TextStyle(color: Colors.white)),
)
: MaterialDesktopVideoControlsTheme(
normal: controlsTheme,
fullscreen: controlsTheme,
child: Video(controller: _video!),
),
),
Positioned(
top: 0,
right: 0,
child: Container(
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.group, color: Colors.white),
onPressed: () {
setState(() => _sheetOpen = !_sheetOpen);
coord.markInput();
},
),
],
),
),
),
Positioned(
top: 12,
left: 12,
child: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final kicked = coord.kickReason.value;
final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null;
if (!unwell) return const SizedBox.shrink();
final color = switch (h.severity) {
HealthSeverity.ok => Colors.green,
HealthSeverity.info => Colors.grey,
HealthSeverity.warn => Colors.orange,
HealthSeverity.error => Colors.red,
};
final IconData icon;
if (retrying) {
icon = Icons.sync;
} else if (h.severity == HealthSeverity.error) {
icon = Icons.error_outline;
} else if (h.severity == HealthSeverity.warn) {
icon = Icons.warning_amber_outlined;
} else {
icon = Icons.hourglass_empty;
}
final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · ');
final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
return Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Flexible(
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
),
if (retrying)
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () => coord.cancelReconnect(),
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
),
if (canRetry)
TextButton(
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
onPressed: () => coord.retry(),
child: const Text('Retry', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
),
SignalBuilder(
builder: (context) {
final r = coord.room.value;
final waiting = [
for (final m in r?.members ?? [])
if (!m.ready) m,
];
final show = r != null && r.transport == 'held' && waiting.isNotEmpty;
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: Visibility(
visible: show,
maintainState: true,
maintainAnimation: true,
maintainSize: true,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadiusGeometry.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: .min,
spacing: 10,
crossAxisAlignment: .center,
children: [
Text("Waiting for", style: TextTheme.of(context).labelLarge),
Wrap(
alignment: WrapAlignment.center,
spacing: -20,
runSpacing: 8,
children: [
for (final m in waiting)
Opacity(
opacity: m.stale ? 0.5 : 1.0,
child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96),
),
],
),
],
),
),
),
),
),
),
);
},
),
Positioned(
right: 12,
top: 56,
child: SignalBuilder(
builder: (context) {
final items = coord.toasts.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)],
);
},
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: !_sheetOpen,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _sheetOpen ? 1 : 0,
child: GestureDetector(
onTap: () => setState(() => _sheetOpen = false),
child: Container(color: Colors.black54),
),
),
),
),
Positioned(
top: 0,
bottom: 0,
right: 0,
child: IgnorePointer(
ignoring: !_sheetOpen,
child: AnimatedSlide(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
offset: _sheetOpen ? Offset.zero : const Offset(1, 0),
child: SizedBox(
width: 380,
height: double.infinity,
child: _SheetPanel(
chat: _chat,
swapLink: _swapLink,
onClose: () => setState(() => _sheetOpen = false),
onLeave: () => setState(() {
_sheetOpen = false;
_confirmLeave = true;
}),
),
),
),
),
),
if (_confirmLeave)
Positioned.fill(
child: GestureDetector(
onTap: () => setState(() => _confirmLeave = false),
child: Container(
color: Colors.black54,
child: Center(
child: GestureDetector(
onTap: () {},
child: AlertDialog(
title: const Text('Leave room?'),
content: const Text('You can rejoin right away.'),
actions: [
TextButton(onPressed: () => setState(() => _confirmLeave = false), child: const Text('Cancel')),
TextButton(
onPressed: () {
setState(() => _confirmLeave = false);
session.leave();
},
child: const Text('Leave'),
),
],
),
),
),
),
),
),
],
),
);
}
}
class _ReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _ReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (context) {
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
return TextButton(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => SessionState.instance.coordinator.toggleReady(),
child: Text(ready ? 'Ready ✓' : 'Not ready', style: const TextStyle(fontSize: 12)),
);
},
);
}
}
class _MarkAllReadyControlButton extends StatelessWidget {
final FocusNode focusNode;
const _MarkAllReadyControlButton({required this.focusNode});
@override
Widget build(BuildContext context) {
return TextButton(
focusNode: focusNode,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 8),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => SessionState.instance.coordinator.forcePlayAll(),
child: const Text('Mark everyone ready', style: TextStyle(fontSize: 12)),
);
}
}
class _ToastCard extends StatefulWidget {
final String text;
const _ToastCard({super.key, required this.text});
@override
State<_ToastCard> createState() => _ToastCardState();
}
class _ToastCardState extends State<_ToastCard> {
bool _shown = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _shown = true);
});
}
@override
Widget build(BuildContext context) {
return AnimatedSlide(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
offset: _shown ? Offset.zero : const Offset(1, 0),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: _shown ? 1 : 0,
child: Card(
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(widget.text)),
),
),
);
}
}
class _SheetPanel extends StatelessWidget {
final TextEditingController chat;
final TextEditingController swapLink;
final VoidCallback onClose;
final VoidCallback onLeave;
const _SheetPanel({required this.chat, required this.swapLink, required this.onClose, required this.onLeave});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final coord = session.coordinator;
return Material(
elevation: 8,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: SignalBuilder(
builder: (context) {
final r = coord.room.value;
final log = coord.feedLog.value;
final theme = Theme.of(context);
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
return ListView(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text(
'transport ${r?.transport ?? '…'} · event ${r?.eventId ?? 0} · media ${r?.mediaId ?? 0}',
style: theme.textTheme.bodySmall,
),
],
),
),
IconButton(icon: const Icon(Icons.close), onPressed: onClose),
],
),
const Divider(height: 20),
Text('Users (${r?.members.length ?? 0})', style: sectionStyle),
const SizedBox(height: 6),
if (r != null)
for (final m in r.members)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 36),
title: Text(m.id == coord.sync.memberId ? '${m.name} (you)' : m.name),
subtitle: Text('ready=${m.ready} inSet=${m.inSet} buf=${m.buffered.toStringAsFixed(1)} stale=${m.stale}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => coord.forceTarget(m.id, !m.ready),
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
TextButton(
onPressed: () async {
final ok = await showKickConfirmDialog(context, m.name);
if (ok == true) coord.kick(m.id);
},
child: const Text('Kick'),
),
],
),
),
),
const Divider(height: 20),
Text('Log', style: sectionStyle),
const SizedBox(height: 6),
if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall),
for (final f in log.reversed.take(30))
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('${f.actor} ${f.action} ${f.target ?? ''}', style: theme.textTheme.bodySmall),
),
const Divider(height: 20),
Text('Chat', style: sectionStyle),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: chat,
decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true),
onSubmitted: (v) {
coord.sendChat(v);
chat.clear();
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
coord.sendChat(chat.text);
chat.clear();
},
),
],
),
const SizedBox(height: 12),
Text('Share a link', style: sectionStyle),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: swapLink,
decoration: const InputDecoration(labelText: 'Swap to link URL', border: OutlineInputBorder(), isDense: true),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.swap_horiz),
onPressed: () {
coord.swapLink(swapLink.text.trim());
swapLink.clear();
},
),
],
),
const Divider(height: 20),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
icon: const Icon(Icons.logout),
label: const Text('Leave room'),
onPressed: onLeave,
),
),
],
);
},
),
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../state/session_state.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Watch Together', style: TextStyle(fontSize: 24)),
const SizedBox(height: 8),
const Text(
'Set your name + avatar on the next screen.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => session.goConnect(ConnectMode.host),
child: const Text('Host'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => session.goConnect(ConnectMode.join),
child: const Text('Join'),
),
],
),
const SizedBox(height: 12),
const Text(
'Host opens ports and serves. Clients open nothing.',
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
enum HealthSeverity { ok, info, warn, error }
class HealthStatus {
final HealthSeverity severity;
final String message;
const HealthStatus(this.severity, this.message);
}
@@ -0,0 +1,603 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../playback/playback_controller.dart';
import '../sfx/sfx.dart';
import '../sync/room_diff.dart';
import '../sync/sync_client.dart';
import 'health.dart';
class ConnectParams {
final String host;
final int port;
final String secret;
final String name;
final String avatar;
final String mediaPath;
final String linkUrl;
const ConnectParams({
required this.host,
required this.port,
required this.secret,
required this.name,
this.avatar = '',
this.mediaPath = '',
this.linkUrl = '',
});
}
class ToastItem {
final int id;
final String text;
final DateTime at;
const ToastItem({required this.id, required this.text, required this.at});
}
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
String? roomMediaUrl(RoomBroadcast room) {
final media = room.media;
if (media == null) return null;
final url = media['url'];
if (url is String && url.isNotEmpty) return url;
return null;
}
class SessionCoordinator {
final SfxService sfx;
final SyncClient sync = SyncClient();
final PlaybackController playback = PlaybackController();
final RoomDiff diff = RoomDiff();
final health =
signal(const HealthStatus(HealthSeverity.info, 'idle'));
final room = signal<RoomBroadcast?>(null);
final feedLog = signal<List<FeedMessage>>([]);
final toasts = signal<List<ToastItem>>([]);
final reconnecting = signal(false);
final kickReason = signal<String?>(null);
final starving = signal<String?>(null);
late final Computed<MemberView?> selfMember = computed(() {
final r = room.value;
if (r == null || sync.memberId.isEmpty) return null;
for (final m in r.members) {
if (m.id == sync.memberId) return m;
}
return null;
});
Timer? _ticker;
StreamSubscription? _roomSub;
StreamSubscription? _feedSub;
StreamSubscription? _kickSub;
StreamSubscription? _errSub;
StreamSubscription? _statusSub;
bool _manualClose = false;
bool _playerReady = false;
bool _warnedIdle = false;
bool _wasPlayAllowed = false;
bool _lastReady = false;
int _mediaId = -1;
String? _openUrl;
String _wsHost = '';
int _wsPort = 0;
double _lastPos = 0;
double? _pendingSeekTarget;
DateTime? _pendingSeekAt;
int _seekRetries = 0;
DateTime? _lastRoomAt;
DateTime _lastInput = DateTime.now().toUtc();
int _toastId = 0;
ConnectParams? _params;
bool _everConnected = false;
SessionCoordinator({this.sfx = const NoopSfxService()});
Future<void> initPlayer() async {
if (_playerReady) return;
try {
await playback.init();
_playerReady = true;
} catch (e, st) {
debugPrint('player init failed: $e\n$st');
health.value =
HealthStatus(HealthSeverity.error, 'player failed to start: $e');
rethrow;
}
}
void markInput() {
_lastInput = DateTime.now().toUtc();
_warnedIdle = false;
}
Future<void> connect(ConnectParams params) async {
await leave(silent: true);
_params = params;
_manualClose = false;
kickReason.value = null;
_wsHost = params.host;
_wsPort = params.port;
diff.reset();
_mediaId = -1;
_openUrl = null;
_lastReady = false;
_wasPlayAllowed = false;
_everConnected = false;
markInput();
_subscribe();
feedLog.value = [];
toasts.value = [];
await initPlayer();
health.value =
const HealthStatus(HealthSeverity.info, 'connecting…');
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
try {
final welcome = await sync.connect(
wsUri: wsUri,
secret: params.secret,
name: params.name,
avatar: params.avatar,
);
reconnecting.value = false;
_everConnected = true;
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
_lastRoomAt = DateTime.now().toUtc();
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty) {
await _openMedia(url);
} else {
health.value = const HealthStatus(
HealthSeverity.info, 'connected — waiting for host media…');
}
if (params.linkUrl.isNotEmpty) {
sync.sendSwap(HelloMedia(
name: params.linkUrl, size: 0, duration: 0, url: params.linkUrl));
}
_startTicker();
} catch (e) {
debugPrint('connect failed: $e');
health.value =
HealthStatus(HealthSeverity.error, _friendlyError(e));
_scheduleReconnect();
rethrow;
}
}
void _subscribe() {
_roomSub ??= sync.rooms.listen(_onRoom);
_feedSub ??= sync.feeds.listen(_onFeed);
_kickSub ??= sync.kicked.listen(_onKicked);
_errSub ??= sync.errors.listen((msg) {
health.value = HealthStatus(HealthSeverity.warn, msg);
});
_statusSub ??= sync.status.listen((s) {
if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_scheduleReconnect();
}
});
}
Future<void> _openMedia(String url) async {
if (url == _openUrl) return;
_openUrl = url;
diff.reset();
try {
await playback.openUrl(url);
health.value =
const HealthStatus(HealthSeverity.ok, 'connected');
} catch (e) {
debugPrint('open media failed: $e');
health.value =
HealthStatus(HealthSeverity.error, 'cannot open media: $e');
}
}
String _fileUrlFor(String name) {
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
}
Future<void> _onRoom(RoomBroadcast r) async {
final now = DateTime.now().toUtc();
_lastRoomAt = now;
room.value = r;
if (r.mediaId != _mediaId) {
_mediaId = r.mediaId;
var url = roomMediaUrl(r);
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
final name = (r.media?['name'] as String?) ?? '';
if (name.isNotEmpty) url = _fileUrlFor(name);
}
if (url != null && url.isNotEmpty) {
await _openMedia(url);
} else {
_openUrl = null;
}
_pendingSeekTarget = null;
_seekRetries = 0;
}
if (!_playerReady || _openUrl == null) {
_wasPlayAllowed = false;
_updateStarving(r, 0);
return;
}
final allowed = localPlayAllowed(r, sync.memberId);
var actions = diff.diff(r, playback.positionSecs, now);
if (r.transport == 'playing' && !allowed) {
actions = [
for (final a in actions) if (a is! TransportAction) a,
const TransportAction(false),
];
}
if (allowed && !_wasPlayAllowed && r.transport == 'playing') {
final edge = roomPositionAt(r, now);
actions = [
for (final a in actions) if (a is! TransportAction) a,
SeekAction(edge),
const TransportAction(true),
];
_pendingSeekTarget = edge;
_pendingSeekAt = now;
}
_wasPlayAllowed = allowed;
for (final a in actions) {
if (a is SeekAction) {
_pendingSeekTarget = a.position;
_pendingSeekAt = now;
}
}
await playback.apply(actions);
_updateStarving(r, playback.bufferedSecsAhead);
}
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
if (r.transport == 'playing') {
starving.value = null;
return;
}
for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
starving.value =
'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered';
return;
}
}
final self = selfMember.peek();
if (self != null && self.ready) {
final waiters = r.members
.where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready))
.map((m) => m.name)
.toList();
if (waiters.isNotEmpty) {
final readyCount = r.members.where((m) => m.ready).length;
starving.value =
'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)';
return;
}
}
if (self != null && !self.ready) {
starving.value = 'press Space when ready';
return;
}
if (r.members.any((m) => m.inSet)) {
starving.value = 'held — waiting for readiness';
return;
}
starving.value = 'held — press Space when ready';
}
void _onFeed(FeedMessage f) {
feedLog.value = [...feedLog.peek(), f].length > 100
? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99)
: [...feedLog.peek(), f];
if (_toastActions.contains(f.action)) {
final text = f.action == 'chat'
? '${f.actor}: ${f.reason}'
: _feedText(f);
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc())
];
}
final kind = sfxForFeedAction(f.action);
if (kind != null) {
var readyIndex = 0;
if (f.action == 'ready') {
readyIndex = room.peek()?.members.where((m) => m.ready).length ?? 0;
}
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
}
}
String _feedText(FeedMessage f) {
switch (f.action) {
case 'join':
return '${f.actor} joined';
case 'leave':
return '${f.actor} left';
case 'kick':
return '${f.actor} kicked ${f.target ?? ''}';
case 'swap':
return '${f.actor} changed media';
default:
return '${f.actor} ${f.action}';
}
}
Future<void> _onKicked(String reason) async {
kickReason.value = reason;
_manualClose = true;
reconnecting.value = false;
_ticker?.cancel();
_ticker = null;
try {
await playback.apply(const [TransportAction(false)]);
} catch (_) {}
await sync.disconnect();
health.value = HealthStatus(
HealthSeverity.error,
reason.isEmpty ? 'kicked from room' : 'kicked: $reason');
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
Future<void> _tick() async {
final now = DateTime.now().toUtc();
toasts.value =
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
final r = room.peek();
if (sync.token.isNotEmpty && r != null && _playerReady) {
sync.sendReport(
position: playback.positionSecs,
buffered: playback.bufferedSecsAhead,
eventId: r.eventId,
);
_detectUserSeek(r);
_checkSeekLanded(r);
}
if (!_manualClose &&
kickReason.peek() == null &&
_lastRoomAt != null &&
now.difference(_lastRoomAt!).inSeconds >= 5) {
if (room.peek() != null) {
room.value = null;
starving.value = null;
_wasPlayAllowed = false;
_mediaId = -1;
_pendingSeekTarget = null;
_seekRetries = 0;
diff.reset();
}
if (!reconnecting.peek()) {
health.value = const HealthStatus(
HealthSeverity.warn, 'connection lost — retrying…');
_scheduleReconnect();
}
}
final idleSecs = now.difference(_lastInput).inSeconds;
if (idleSecs >= 8 * 60 && !_warnedIdle) {
_warnedIdle = true;
_toastId++;
toasts.value = [
...toasts.peek(),
ToastItem(
id: _toastId,
text: 'idle — auto-unready in 2 min unless you press Space',
at: now)
];
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
}
if (idleSecs >= 10 * 60) {
final self = selfMember.peek();
if (self != null && self.ready) {
_lastReady = false;
sync.sendReady(false, source: 'auto');
markInput();
}
}
}
void _detectUserSeek(RoomBroadcast r) {
if (_openUrl == null) {
_lastPos = playback.positionSecs;
return;
}
final now = DateTime.now().toUtc();
final pos = playback.positionSecs;
final jumped = (pos - _lastPos).abs();
_lastPos = pos;
if (jumped <= 2.0) return;
if ((pos - roomPositionAt(r, now)).abs() < 2.0) return;
final recentSync = _pendingSeekAt != null &&
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
if (recentSync) return;
markInput();
sync.sendSeek(pos);
}
Future<void> _checkSeekLanded(RoomBroadcast r) async {
final target = _pendingSeekTarget;
final at = _pendingSeekAt;
if (target == null || at == null) return;
if (DateTime.now().toUtc().difference(at).inMilliseconds < 1500) return;
if (seekLanded(ownPosition: playback.positionSecs, target: target)) {
_pendingSeekTarget = null;
_pendingSeekAt = null;
_seekRetries = 0;
return;
}
if (_seekRetries < 1) {
_seekRetries++;
_pendingSeekAt = DateTime.now().toUtc();
await playback.apply([SeekAction(target)]);
} else {
_pendingSeekTarget = null;
_pendingSeekAt = null;
_seekRetries = 0;
}
}
void toggleReady() {
final self = selfMember.peek();
final next = !(self?.ready ?? false);
markInput();
_lastReady = next;
sync.sendReady(next, source: 'manual');
}
void forceTarget(String memberId, bool ready) {
markInput();
sync.sendForce(memberId, ready);
}
void forcePlayAll() {
markInput();
sync.sendForce('all', true);
}
void seekTo(double position) {
markInput();
sync.sendSeek(position);
}
void nudge(double deltaSecs) {
final r = room.peek();
if (r == null || _openUrl == null) return;
final target =
roomPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
if (target.isNaN || target < 0) return;
markInput();
sync.sendSeek(target);
}
void sendChat(String text) {
markInput();
sync.sendChat(text);
}
void kick(String memberId, {String reason = ''}) {
markInput();
sync.sendKick(memberId, reason: reason);
}
Future<void> swapLink(String url) async {
markInput();
sync.sendSwap(
HelloMedia(name: url, size: 0, duration: 0, url: url));
}
void _scheduleReconnect({bool manual = false}) {
if (_manualClose || kickReason.peek() != null || _params == null) return;
if (!manual && !_everConnected) return;
if (reconnecting.peek()) return;
reconnecting.value = true;
Future.delayed(const Duration(milliseconds: 1500), () async {
if (_manualClose || kickReason.peek() != null) {
reconnecting.value = false;
return;
}
final params = _params;
if (params == null) {
reconnecting.value = false;
return;
}
try {
health.value = const HealthStatus(
HealthSeverity.info, 'reconnecting…');
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
final welcome = await sync.connect(
wsUri: wsUri,
secret: params.secret,
name: params.name,
avatar: params.avatar,
);
reconnecting.value = false;
_everConnected = true;
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
final url = welcome.mediaUrl;
if (url != null && url.isNotEmpty && url != _openUrl) {
await _openMedia(url);
}
if (_lastReady) sync.sendReady(true, source: 'manual');
_startTicker();
} catch (e) {
debugPrint('reconnect failed: $e');
reconnecting.value = false;
_scheduleReconnect();
}
});
}
void cancelReconnect() {
_manualClose = true;
reconnecting.value = false;
}
Future<void> cancelConnect() async {
_manualClose = true;
reconnecting.value = false;
try {
await sync.disconnect();
} catch (_) {}
health.value = const HealthStatus(HealthSeverity.info, 'cancelled');
}
void retry() {
if (kickReason.peek() != null || _params == null) return;
_manualClose = false;
reconnecting.value = false;
health.value =
const HealthStatus(HealthSeverity.info, 'retrying…');
_scheduleReconnect(manual: true);
}
String _friendlyError(Object e) {
final msg = '$e';
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
if (msg.contains('wrong secret')) return 'wrong room password (401)';
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
return 'host offline — check address and port';
}
return 'connect failed: $e';
}
Future<void> leave({bool silent = false}) async {
_manualClose = true;
_ticker?.cancel();
_ticker = null;
reconnecting.value = false;
try {
await playback.stop();
} catch (_) {}
try {
await sync.disconnect();
} catch (_) {}
if (!silent) {
room.value = null;
starving.value = null;
health.value = const HealthStatus(HealthSeverity.info, 'idle');
}
}
Future<void> dispose() async {
await leave(silent: true);
await _roomSub?.cancel();
await _feedSub?.cancel();
await _kickSub?.cancel();
await _errSub?.cancel();
await _statusSub?.cancel();
await sync.dispose();
await playback.dispose();
}
}
+72
View File
@@ -0,0 +1,72 @@
enum SfxKind {
ready,
unready,
forceReady,
forceUnready,
forcePlay,
forceHold,
kick,
join,
leave,
stale,
swap,
seek,
pause,
resume,
chat,
warn,
}
class SfxCue {
final SfxKind kind;
final int readyIndex;
const SfxCue(this.kind, {this.readyIndex = 0});
}
SfxKind? sfxForFeedAction(String action) {
switch (action) {
case 'ready':
return SfxKind.ready;
case 'unready':
return SfxKind.unready;
case 'force-ready':
return SfxKind.forceReady;
case 'force-unready':
return SfxKind.forceUnready;
case 'force-play':
return SfxKind.forcePlay;
case 'force-hold':
return SfxKind.forceHold;
case 'kick':
return SfxKind.kick;
case 'join':
return SfxKind.join;
case 'leave':
return SfxKind.leave;
case 'stale':
return SfxKind.stale;
case 'swap':
return SfxKind.swap;
case 'seek':
return SfxKind.seek;
case 'pause':
return SfxKind.pause;
case 'resume':
return SfxKind.resume;
case 'chat':
return SfxKind.chat;
default:
return null;
}
}
abstract class SfxService {
Future<void> play(SfxCue cue);
}
class NoopSfxService implements SfxService {
const NoopSfxService();
@override
Future<void> play(SfxCue cue) async {}
}
@@ -0,0 +1,21 @@
import 'package:hive_ce/hive.dart';
const connectFormBoxName = 'connect_form';
const connectKeyHost = 'host';
const connectKeyPort = 'port';
const connectKeySecret = 'secret';
const connectKeyName = 'name';
const connectKeyAvatarPath = 'avatarPath';
const connectKeyMediaPath = 'mediaPath';
const connectKeyLinkUrl = 'linkUrl';
Future<Box> openConnectFormBox() => Hive.openBox(connectFormBoxName);
Box? connectFormBoxIfOpen() =>
Hive.isBoxOpen(connectFormBoxName) ? Hive.box(connectFormBoxName) : null;
String readString(Box box, String key, String fallback) {
final v = box.get(key);
return v is String ? v : fallback;
}
@@ -0,0 +1,159 @@
import 'package:flutter/widgets.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import '../sfx/sfx.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../widgets/avatar_pipeline.dart';
import 'connect_prefs.dart';
enum AppRoute { welcome, connect, session }
enum ConnectMode { host, join }
class ConnectForm {
String host;
String port;
String secret;
String name;
String avatarPath;
String mediaPath;
String linkUrl;
ConnectForm({
this.host = '127.0.0.1',
this.port = '8080',
this.secret = '',
this.name = '',
this.avatarPath = '',
this.mediaPath = '',
this.linkUrl = '',
});
}
class SessionState {
static final SessionState instance = SessionState._();
SessionState._();
final route = signal(AppRoute.welcome);
final connectMode = signal(ConnectMode.host);
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
final room = signal<RoomBroadcast?>(null);
final SfxService sfx = const NoopSfxService();
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
final lastForm = ConnectForm();
RoomServer? _server;
Future<void> loadForm() async {
final box = await openConnectFormBox();
lastForm
..host = readString(box, connectKeyHost, '127.0.0.1')
..port = readString(box, connectKeyPort, '8080')
..secret = readString(box, connectKeySecret, '')
..name = readString(box, connectKeyName, '')
..avatarPath = readString(box, connectKeyAvatarPath, '')
..mediaPath = readString(box, connectKeyMediaPath, '')
..linkUrl = readString(box, connectKeyLinkUrl, '');
}
Future<void> saveForm() async {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
await box.putAll({
connectKeyHost: lastForm.host,
connectKeyPort: lastForm.port,
connectKeySecret: lastForm.secret,
connectKeyName: lastForm.name,
connectKeyAvatarPath: lastForm.avatarPath,
connectKeyMediaPath: lastForm.mediaPath,
connectKeyLinkUrl: lastForm.linkUrl,
});
}
Future<String> loadAvatar(String path) => encodeAvatar(path);
void goConnect(ConnectMode mode) {
connectMode.value = mode;
route.value = AppRoute.connect;
}
Future<void> host(ConnectParams params, ConnectForm form) async {
_rememberForm(form);
status.value = const HealthStatus(HealthSeverity.info, 'starting server…');
await stopServer();
final server = RoomServer(AppConfig(
port: params.port,
secret: params.secret,
name: 'room',
mediaPath: params.mediaPath,
));
try {
await server.start();
} catch (e, st) {
debugPrint('server start failed: $e\n$st');
status.value =
HealthStatus(HealthSeverity.error, 'cannot bind port ${params.port}: $e');
rethrow;
}
_server = server;
status.value =
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: params.secret,
name: params.name,
avatar: params.avatar,
linkUrl: params.linkUrl,
));
route.value = AppRoute.session;
try {
await saveForm();
} catch (e, st) {
debugPrint('save connect form failed: $e\n$st');
}
}
Future<void> join(ConnectParams params, ConnectForm form) async {
_rememberForm(form);
await coordinator.connect(params);
route.value = AppRoute.session;
try {
await saveForm();
} catch (e, st) {
debugPrint('save connect form failed: $e\n$st');
}
}
void _rememberForm(ConnectForm form) {
lastForm
..host = form.host
..port = form.port
..secret = form.secret
..name = form.name
..avatarPath = form.avatarPath
..mediaPath = form.mediaPath
..linkUrl = form.linkUrl;
}
Future<void> startHeadless(List<String> args) async {
int port = 8080;
for (var i = 0; i < args.length - 1; i++) {
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
}
_server = RoomServer(AppConfig(port: port, secret: 'change-me', name: 'room'));
await _server!.start();
}
Future<void> stopServer() async {
await _server?.stop();
_server = null;
}
Future<void> leave() async {
await coordinator.leave();
await stopServer();
status.value = const HealthStatus(HealthSeverity.info, 'idle');
route.value = AppRoute.connect;
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:sync_relay_core/core.dart';
sealed class PlayerAction {
const PlayerAction();
}
class SeekAction extends PlayerAction {
final double position;
const SeekAction(this.position);
}
class TransportAction extends PlayerAction {
final bool playing;
const TransportAction(this.playing);
}
class RateAction extends PlayerAction {
final double rate;
const RateAction(this.rate);
}
double roomPositionAt(RoomBroadcast room, DateTime now) {
if (room.transport != 'playing') return room.position;
return room.position + now.difference(room.serverTime).inMilliseconds / 1000.0;
}
bool seekLanded({required double ownPosition, required double target}) {
if ((ownPosition - target).abs() > RelayConstants.driftSeekThresholdSecs) {
return false;
}
return true;
}
bool localPlayAllowed(RoomBroadcast room, String memberId) {
if (memberId.isEmpty) return false;
for (final m in room.members) {
if (m.id == memberId) return m.inSet && m.ready;
}
return false;
}
class RoomDiff {
static const debounceWindow = Duration(seconds: 2);
int _lastEventId = -1;
String _lastTransport = '';
int _lastMediaId = -1;
bool _slowing = false;
DateTime? _deviatingSince;
int _deviatingEvent = -1;
bool get slowing => _slowing;
List<PlayerAction> diff(RoomBroadcast room, double ownPosition, DateTime now) {
final out = <PlayerAction>[];
final roomPos = roomPositionAt(room, now);
if (room.mediaId != _lastMediaId && _lastMediaId != -1) {
_lastEventId = room.eventId;
_lastTransport = room.transport;
_lastMediaId = room.mediaId;
_slowing = false;
_deviatingSince = null;
out.add(const SeekAction(0));
out.add(const TransportAction(false));
out.add(const RateAction(1.0));
return out;
}
if (_lastEventId == -1) {
_lastEventId = room.eventId;
_lastTransport = room.transport;
_lastMediaId = room.mediaId;
_deviatingSince = null;
out.add(SeekAction(roomPos));
out.add(TransportAction(room.transport == 'playing'));
return out;
}
if (room.eventId != _lastEventId) {
_lastEventId = room.eventId;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(SeekAction(roomPos));
if (room.transport != _lastTransport) {
_lastTransport = room.transport;
out.add(TransportAction(room.transport == 'playing'));
}
return out;
}
if (room.transport != _lastTransport) {
_lastTransport = room.transport;
_slowing = false;
_deviatingSince = null;
out.add(const RateAction(1.0));
out.add(TransportAction(room.transport == 'playing'));
out.add(SeekAction(roomPos));
return out;
}
final drift = ownPosition - roomPos;
if (_slowing) {
if (drift.abs() < 0.1) {
_slowing = false;
out.add(const RateAction(1.0));
}
return out;
}
switch (correctionFor(ownPosition: ownPosition, roomPosition: roomPos)) {
case Correction.seek:
if (_deviatingEvent != room.eventId || _deviatingSince == null) {
_deviatingEvent = room.eventId;
_deviatingSince = now;
break;
}
if (now.difference(_deviatingSince!).compareTo(debounceWindow) < 0) {
break;
}
_deviatingSince = null;
out.add(SeekAction(roomPos));
case Correction.slowDown:
_deviatingSince = null;
_slowing = true;
out.add(const RateAction(RelayConstants.slowDownRate));
case Correction.none:
_deviatingSince = null;
break;
}
return out;
}
void reset() {
_lastEventId = -1;
_lastTransport = '';
_lastMediaId = -1;
_slowing = false;
_deviatingSince = null;
_deviatingEvent = -1;
}
}
+228
View File
@@ -0,0 +1,228 @@
import 'dart:async';
import 'dart:convert';
import 'package:sync_relay_core/core.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
enum SyncStatus { idle, connecting, open, kicked, closed, error }
class SyncClient {
WebSocketChannel? _channel;
StreamSubscription? _sub;
final StreamController<RoomBroadcast> _rooms = StreamController.broadcast();
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
final StreamController<String> _kicked = StreamController.broadcast();
final StreamController<String> _errors = StreamController.broadcast();
final StreamController<SyncStatus> _status = StreamController.broadcast();
Stream<RoomBroadcast> get rooms => _rooms.stream;
Stream<FeedMessage> get feeds => _feeds.stream;
Stream<String> get kicked => _kicked.stream;
Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
String token = '';
String memberId = '';
String? mediaUrl;
RoomBroadcast? lastRoom;
Completer<WelcomeMessage>? _pendingWelcome;
Future<WelcomeMessage> connect({
required Uri wsUri,
required String secret,
required String name,
String avatar = '',
HelloMedia? media,
}) async {
await disconnect();
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
_channel = ch;
final welcomeCompleter = Completer<WelcomeMessage>();
_sub = ch.stream.listen(
(raw) => _onText('$raw', welcomeCompleter),
onError: (Object e, StackTrace st) {
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(e, st);
}
_status.add(SyncStatus.error);
},
onDone: () {
if (!welcomeCompleter.isCompleted) {
welcomeCompleter.completeError(StateError('closed before welcome'));
}
_status.add(SyncStatus.closed);
},
cancelOnError: false,
);
final hello = HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name, avatar: avatar),
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
);
ch.sink.add(jsonEncode(hello.toJson()));
_pendingWelcome = welcomeCompleter;
try {
final welcome = await welcomeCompleter.future.timeout(
const Duration(seconds: 10),
);
_pendingWelcome = null;
token = welcome.token;
memberId = welcome.memberId;
mediaUrl = welcome.mediaUrl;
if (welcome.state != null) {
try {
lastRoom = RoomBroadcast.fromJson(welcome.state!);
_rooms.add(lastRoom!);
} catch (_) {}
}
_status.add(SyncStatus.open);
return welcome;
} catch (e) {
_pendingWelcome = null;
await disconnect();
_status.add(SyncStatus.error);
rethrow;
}
}
void _onText(String raw, Completer<WelcomeMessage> welcomeCompleter) {
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(raw);
if (parsed is! Map) return;
decoded = Map<String, Object?>.from(parsed);
} catch (_) {
return;
}
final t = decoded['t'];
if (!welcomeCompleter.isCompleted) {
if (t == 'welcome') {
try {
welcomeCompleter.complete(WelcomeMessage.fromJson(decoded));
} catch (e) {
welcomeCompleter.completeError(e);
}
} else if (t == 'err') {
welcomeCompleter.completeError(
ProtocolException('${decoded['message'] ?? 'rejected'}'),
);
}
return;
}
switch (t) {
case 'room':
try {
final room = RoomBroadcast.fromJson(decoded);
lastRoom = room;
_rooms.add(room);
} catch (_) {}
case 'feed':
try {
_feeds.add(FeedMessage.fromJson(decoded));
} catch (_) {}
case 'kicked':
try {
final msg = KickedMessage.fromJson(decoded);
_kicked.add(msg.reason);
_status.add(SyncStatus.kicked);
} catch (_) {
_kicked.add('');
}
case 'err':
_errors.add('${decoded['message'] ?? 'error'}');
case 'chat':
try {
_feeds.add(
FeedMessage(
actor: '${decoded['from'] ?? '?'}',
action: 'chat',
reason: (decoded['text'] as String?) ?? '',
at: DateTime.now().toUtc(),
),
);
} catch (_) {}
}
}
void _send(Map<String, Object?> json) {
final ch = _channel;
if (ch == null) return;
try {
ch.sink.add(jsonEncode(json));
} catch (_) {}
}
void sendReady(bool ready, {String source = 'manual'}) {
_send(ReadyIntent(ready: ready, source: source).toJson());
}
void sendForce(String target, bool ready) {
_send(ForceIntent(target: target, ready: ready).toJson());
}
void sendSeek(double position) {
if (position.isNaN || position < 0) return;
_send(SeekIntent(position: position).toJson());
}
void sendSwap(HelloMedia media) {
_send(SwapIntent(media: media).toJson());
}
void sendReport({
required double position,
required double buffered,
required int eventId,
}) {
final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered;
_send(
ReportMessage(
position: position,
buffered: safeBuffered,
eventId: eventId,
).toJson(),
);
}
void sendChat(String text) {
final clean = text.length > RelayConstants.maxChatLength
? text.substring(0, RelayConstants.maxChatLength)
: text;
_send(ChatMessage(text: clean).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
}
Future<void> disconnect() async {
final pending = _pendingWelcome;
_pendingWelcome = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(StateError('cancelled'));
} try {
await _sub?.cancel();
} catch (_) {}
_sub = null;
try {
await _channel?.sink.close();
} catch (_) {}
_channel = null;
token = '';
memberId = '';
mediaUrl = null;
lastRoom = null;
}
Future<void> dispose() async {
await disconnect();
await _rooms.close();
await _feeds.close();
await _kicked.close();
await _errors.close();
await _status.close();
}
}
@@ -0,0 +1,44 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
Future<String> encodeAvatar(String path) async {
final trimmed = path.trim();
if (trimmed.isEmpty) return '';
final file = File(trimmed);
if (!await file.exists()) {
throw const FormatException(
'avatar file not found — check the path or pick again with Browse');
}
late final List<int> raw;
try {
raw = await file.readAsBytes();
} catch (_) {
throw const FormatException(
'could not read that avatar file — pick it again with Browse');
}
if (raw.isEmpty) {
throw const FormatException(
'that avatar file is empty — pick a PNG, JPG or WebP photo');
}
final decoded = img.decodeImage(Uint8List.fromList(raw));
if (decoded == null) {
throw const FormatException(
'could not read that image — pick a PNG, JPG or WebP photo');
}
final side =
decoded.width < decoded.height ? decoded.width : decoded.height;
final x = (decoded.width - side) ~/ 2;
final y = (decoded.height - side) ~/ 2;
final cropped =
img.copyCrop(decoded, x: x, y: y, width: side, height: side);
final resized = img.copyResize(cropped, width: 128, height: 128);
final jpg = img.encodeJpg(resized, quality: 85);
if (jpg.length > 64 * 1024) {
throw const FormatException(
'that photo is still too large after shrinking — pick a smaller one');
}
return base64Encode(jpg);
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
Future<bool?> showKickConfirmDialog(
BuildContext context, String memberName) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Remove $memberName?'),
content: const Text('They can come straight back in.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Remove'),
),
],
),
);
}
@@ -0,0 +1,8 @@
({String mediaPath, String linkUrl}) parseMediaInput(String input) {
final trimmed = input.trim();
final lower = trimmed.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) {
return (mediaPath: '', linkUrl: trimmed);
}
return (mediaPath: trimmed, linkUrl: '');
}
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:flutter/material.dart';
class MemberAvatar extends StatelessWidget {
final String avatarBase64;
final String name;
final double size;
static final Map<String, MemoryImage> _images = {};
const MemberAvatar({
super.key,
required this.avatarBase64,
required this.name,
this.size = 40,
});
static MemoryImage? _providerFor(String raw) {
if (raw.isEmpty) return null;
var hit = _images[raw];
if (hit != null) return hit;
try {
hit = MemoryImage(base64Decode(raw));
} catch (_) {
return null;
}
if (_images.length > 64) _images.clear();
_images[raw] = hit;
return hit;
}
@override
Widget build(BuildContext context) {
final image = _providerFor(avatarBase64);
final decoration = BoxDecoration(
shape: BoxShape.circle,
);
if (image != null) {
return Container(
decoration: decoration,
child: ClipOval(
child: Image(
image: ResizeImage(height: (size * MediaQuery.of(context).devicePixelRatio).round(), image),
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => _fallback(),
),
),
);
}
return Container(decoration: decoration, child: _fallback());
}
Widget _fallback() => CircleAvatar(
radius: size / 2,
backgroundColor: Colors.grey.shade800,
child: Text(
name.isEmpty ? '?' : name[0].toUpperCase(),
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
),
);
}
@@ -0,0 +1,163 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../state/session_state.dart';
import 'avatar_pipeline.dart';
/// Profile card for the connect screen.
///
/// Lives in a fixed corner card outside the scrolling form, so Host/Join
/// field changes below never move it. Name edits write back to
/// [SessionState.lastForm] on every change (persisted on submit); avatar
/// changes by tapping the circle, validated through [encodeAvatar] before
/// the path is stored, with the encoded bytes shown as the preview.
class ConnectProfileCard extends StatefulWidget {
final TextEditingController name;
const ConnectProfileCard({super.key, required this.name});
@override
State<ConnectProfileCard> createState() => _ConnectProfileCardState();
}
class _ConnectProfileCardState extends State<ConnectProfileCard> {
late String _avatarPath;
Uint8List? _preview;
String? _error;
bool _picking = false;
@override
void initState() {
super.initState();
_avatarPath = SessionState.instance.lastForm.avatarPath;
_refreshPreview();
}
Future<void> _refreshPreview() async {
String b64;
try {
b64 = await encodeAvatar(_avatarPath);
} catch (_) {
return;
}
if (!mounted) return;
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
}
Future<void> _persistProfile() async {
try {
await SessionState.instance.saveForm();
} catch (_) {
if (!mounted) return;
setState(() => _error = 'could not save profile — try again');
}
}
Future<void> _pickAvatar() async {
if (_picking) return;
setState(() {
_picking = true;
_error = null;
});
try {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
if (!mounted) return;
setState(() => _error = 'file dialog unavailable — try again');
return;
}
if (files.isEmpty) return;
final path = files.first.path;
if (path == null || !mounted) return;
late final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
if (!mounted) return;
setState(() => _error = e.message);
return;
} catch (_) {
if (!mounted) return;
setState(() => _error =
'could not read that image — pick a PNG, JPG or WebP photo');
return;
}
_avatarPath = path;
SessionState.instance.lastForm.avatarPath = path;
await _persistProfile();
if (!mounted) return;
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
} finally {
if (mounted) setState(() => _picking = false);
}
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: _pickAvatar,
child: Tooltip(
message: 'Change avatar',
child: CircleAvatar(
radius: 22,
backgroundColor: Colors.grey.shade800,
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
child: _preview != null
? null
: _picking
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: ListenableBuilder(
listenable: widget.name,
builder: (context, _) {
final t = widget.name.text.trim();
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
},
),
),
),
),
const SizedBox(width: 12),
SizedBox(
width: 180,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: widget.name,
decoration: const InputDecoration(
labelText: 'Display name',
isDense: true,
),
onChanged: (_) {
SessionState.instance.lastForm.name = widget.name.text.trim();
},
onSubmitted: (_) => _persistProfile(),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_error!, style: const TextStyle(fontSize: 11)),
),
],
),
),
],
);
}
}