Initial commit: watch-together phases 0-4 + presence hardening, internet-tested
This commit is contained in:
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user