forked from Foxyz/sync_replay_project
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,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user