forked from Foxyz/sync_replay_project
Desktop: video freeze recovery, session fixes, Home redesign
- Player + VideoController per session; Reload video (Ctrl+R) and a frozen-picture watchdog that reloads automatically. - Shortcuts no longer fire while typing; empty swap disabled; hwdec setting/env actually applied. - Idle unready measured from real inactivity; cancellable reconnects and setup; ready restored from server state; kicked/failed host stops its server; graceful leave on window close; no URLs with keys in display names. - Welcome + connect screens replaced by one Home screen with Join and Host cards, recent servers, Settings, Integrations and About. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beae87b0cc
commit
aeff087c7d
30 files changed
+3278
-601
No files matched your search
@@ -1,359 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.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';
|
||||
import '../widgets/window_buttons.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: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||
child: DragToMoveArea(
|
||||
child: 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')),
|
||||
actions: const [WindowButtons()],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,80 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Opens [url] in the default browser (no url_launcher dependency).
|
||||
Future<bool> openInBrowser(String url) async {
|
||||
try {
|
||||
final ProcessResult r;
|
||||
if (Platform.isWindows) {
|
||||
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||
} else if (Platform.isMacOS) {
|
||||
r = await Process.run('open', [url]);
|
||||
} else {
|
||||
r = await Process.run('xdg-open', [url]);
|
||||
}
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const AboutPage({super.key, this.updates});
|
||||
|
||||
Future<void> _open(BuildContext context) async {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (await openInBrowser(repoUrl)) return;
|
||||
await Clipboard.setData(const ClipboardData(text: repoUrl));
|
||||
messenger?.showSnackBar(
|
||||
const SnackBar(content: Text('Could not open a browser — link copied')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
children: [
|
||||
const PageTitle('About'),
|
||||
Panel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(appName, style: t.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText('Version $appVersion',
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 4),
|
||||
UpdateStatusLine(controller: updates),
|
||||
const SizedBox(height: kGap),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||
IconButton(
|
||||
tooltip: 'Copy link',
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => _open(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: const Text('Source'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../session/session_coordinator.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import '../../widgets/media_input.dart';
|
||||
|
||||
/// What a card shows under its fields after a failed attempt.
|
||||
class CardError {
|
||||
final String title;
|
||||
final String message;
|
||||
const CardError(this.title, this.message);
|
||||
}
|
||||
|
||||
/// Host/Join submit state for the two Home cards. One attempt at a time;
|
||||
/// [cancel] abandons it via [SessionState.cancelPending].
|
||||
class ConnectFlow extends ChangeNotifier {
|
||||
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
|
||||
final SessionState _session;
|
||||
|
||||
ConnectMode? busy;
|
||||
final errors = <ConnectMode, CardError?>{};
|
||||
final notices = <ConnectMode, String?>{};
|
||||
int _attempt = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Set when an attempt ends in a kick, so Home shows the removal dialog.
|
||||
void Function(String reason)? onKicked;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void clearError(ConnectMode mode) {
|
||||
if (errors[mode] == null && notices[mode] == null) return;
|
||||
errors[mode] = null;
|
||||
notices[mode] = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// Validates and builds the params; throws [FormatException] with a
|
||||
/// user-facing message.
|
||||
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async {
|
||||
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
|
||||
final port = int.tryParse(rawPort.trim());
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
throw const FormatException('port must be 1–65535');
|
||||
}
|
||||
if (f.name.trim().isEmpty) {
|
||||
throw const FormatException('set your display name first (click your profile at the top)');
|
||||
}
|
||||
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
|
||||
throw const FormatException('enter the server address');
|
||||
}
|
||||
String avatar = '';
|
||||
try {
|
||||
avatar = await encodeAvatar(f.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');
|
||||
}
|
||||
if (mode == ConnectMode.host &&
|
||||
f.mediaPath.isNotEmpty &&
|
||||
!await File(f.mediaPath).exists()) {
|
||||
throw const FormatException('media file not found — check the path or pick again with Browse');
|
||||
}
|
||||
return ConnectParams(
|
||||
host: f.host.trim(),
|
||||
port: port,
|
||||
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
|
||||
name: f.name.trim(),
|
||||
avatar: avatar,
|
||||
mediaPath: mode == ConnectMode.host ? f.mediaPath : '',
|
||||
linkUrl: mode == ConnectMode.host ? f.linkUrl : '',
|
||||
);
|
||||
}
|
||||
|
||||
/// [edit] fills the card's fields into a copy of the last form, so the
|
||||
/// other card's saved values survive.
|
||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
||||
if (busy != null) return;
|
||||
final myAttempt = ++_attempt;
|
||||
final form = _session.lastForm.copy();
|
||||
edit(form);
|
||||
form.name = form.name.trim();
|
||||
form.host = form.host.trim();
|
||||
if (mode == ConnectMode.host) {
|
||||
final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath);
|
||||
form
|
||||
..mediaPath = parsed.mediaPath
|
||||
..linkUrl = parsed.linkUrl;
|
||||
}
|
||||
busy = mode;
|
||||
errors[mode] = null;
|
||||
notices[mode] = null;
|
||||
_notify();
|
||||
|
||||
final ConnectParams params;
|
||||
try {
|
||||
params = await buildParams(mode, form);
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
errors[mode] = CardError('Check the form',
|
||||
e is FormatException ? e.message : 'check the form and try again');
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (mode == ConnectMode.host) {
|
||||
await _session.host(params, form);
|
||||
} else {
|
||||
await _session.join(params, form);
|
||||
}
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
final kick = _session.coordinator.kickReason.peek();
|
||||
if (kick != null && kick.isNotEmpty) {
|
||||
onKicked?.call(kick);
|
||||
} else {
|
||||
errors[mode] = CardError('Could not connect', '$e');
|
||||
}
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
if (myAttempt != _attempt) {
|
||||
// Cancelled after it got through: back out.
|
||||
try {
|
||||
await _session.leave();
|
||||
await _session.stopServer();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
busy = null;
|
||||
_notify();
|
||||
await _session.rememberServer(RecentServer(
|
||||
hosted: mode == ConnectMode.host,
|
||||
host: mode == ConnectMode.host ? '' : form.host,
|
||||
port: mode == ConnectMode.host ? form.hostPort.trim() : form.port.trim(),
|
||||
secret: mode == ConnectMode.host ? form.hostSecret : form.secret,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> cancel() async {
|
||||
final mode = busy;
|
||||
_attempt++;
|
||||
try {
|
||||
await _session.cancelPending();
|
||||
} catch (_) {}
|
||||
busy = null;
|
||||
if (mode != null) notices[mode] = 'Cancelled';
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'profile.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Below this width the Join/Host cards stack.
|
||||
const _stackBelow = 700.0;
|
||||
|
||||
const _videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
const HomePage({super.key, this.updates});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
final _flow = ConnectFlow();
|
||||
late final _f = SessionState.instance.lastForm;
|
||||
late final _joinHost = TextEditingController(text: _f.host);
|
||||
late final _joinPort = TextEditingController(text: _f.port);
|
||||
late final _joinSecret = TextEditingController(text: _f.secret);
|
||||
late final _hostPort = TextEditingController(text: _f.hostPort);
|
||||
late final _hostSecret = TextEditingController(text: _f.hostSecret);
|
||||
late final _media =
|
||||
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
||||
late final void Function() _stopKickWatch;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_flow.onKicked = _showKickedDialog;
|
||||
// Back from a session that ended in a kick: say so.
|
||||
_stopKickWatch = effect(() {
|
||||
final kick = SessionState.instance.coordinator.kickReason.value;
|
||||
if (kick == null || kick.isEmpty) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopKickWatch();
|
||||
_flow.dispose();
|
||||
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _join() => _flow.submit(ConnectMode.join, (f) {
|
||||
f
|
||||
..host = _joinHost.text
|
||||
..port = _joinPort.text.trim()
|
||||
..secret = _joinSecret.text;
|
||||
});
|
||||
|
||||
void _host() => _flow.submit(ConnectMode.host, (f) {
|
||||
f
|
||||
..hostPort = _hostPort.text.trim()
|
||||
..hostSecret = _hostSecret.text
|
||||
..mediaPath = _media.text.trim()
|
||||
..linkUrl = '';
|
||||
});
|
||||
|
||||
void _useRecent(RecentServer r) {
|
||||
if (r.hosted) {
|
||||
_hostPort.text = r.port;
|
||||
_hostSecret.text = r.secret;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
_joinHost.text = r.host;
|
||||
_joinPort.text = r.port;
|
||||
_joinSecret.text = r.secret;
|
||||
_join();
|
||||
}
|
||||
|
||||
Future<void> _browseMedia() async {
|
||||
try {
|
||||
final files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick media file',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: _videoExtensions,
|
||||
);
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path != null && mounted) setState(() => _media.text = path);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_flow.notices[ConnectMode.host] =
|
||||
'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showKickedDialog(String reason) async {
|
||||
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
|
||||
_lastKickShown = reason;
|
||||
_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 server.' : reason),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _flow,
|
||||
builder: (context, _) => PageScroll(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const ProfileChip(),
|
||||
const Spacer(),
|
||||
UpdateIndicator(controller: widget.updates),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(builder: (context, box) {
|
||||
final stacked = box.maxWidth < _stackBelow;
|
||||
final join = _joinCard(context, fill: !stacked);
|
||||
final host = _hostCard(context, fill: !stacked);
|
||||
if (stacked) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [join, const SizedBox(height: kGap), host],
|
||||
);
|
||||
}
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: join),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(child: host),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
_recents(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _joinCard(BuildContext context, {required bool fill}) {
|
||||
return _ConnectCard(
|
||||
key: const ValueKey('join-card'),
|
||||
icon: Icons.login,
|
||||
title: 'Join',
|
||||
subtitle: 'Connect to a friend’s server.',
|
||||
mode: ConnectMode.join,
|
||||
flow: _flow,
|
||||
action: 'Join',
|
||||
onSubmit: _join,
|
||||
fill: fill,
|
||||
fields: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _joinHost,
|
||||
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
||||
onChanged: (_) => _flow.clearError(ConnectMode.join),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _joinPort,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _joinSecret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password (optional)'),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hostCard(BuildContext context, {required bool fill}) {
|
||||
return _ConnectCard(
|
||||
key: const ValueKey('host-card'),
|
||||
icon: Icons.cast,
|
||||
title: 'Host',
|
||||
subtitle: 'Run the server here. Your friends need this port open.',
|
||||
mode: ConnectMode.host,
|
||||
flow: _flow,
|
||||
action: 'Host',
|
||||
onSubmit: _host,
|
||||
fill: fill,
|
||||
fields: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: TextField(
|
||||
controller: _hostPort,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: _hostSecret,
|
||||
builder: (context, _) => TextField(
|
||||
controller: _hostSecret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password (optional)').copyWith(
|
||||
helperText: _hostSecret.text.isEmpty
|
||||
? 'No password — anyone can join'
|
||||
: null,
|
||||
),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _media,
|
||||
decoration: fieldDecoration(
|
||||
'Media',
|
||||
hint: 'File path or http(s) link — optional',
|
||||
suffix: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: TextButton(onPressed: _browseMedia, child: const Text('Browse')),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _recents(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = SessionState.instance.recents.value;
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
final busy = _flow.busy != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Recent',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in list)
|
||||
InputChip(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
avatar: Icon(r.hosted ? Icons.cast : Icons.dns_outlined, size: 18),
|
||||
label: Text(r.hosted ? 'Hosted on :${r.port}' : '${r.host}:${r.port}'),
|
||||
tooltip: r.hosted ? 'Fill the Host card' : 'Join ${r.host}:${r.port}',
|
||||
isEnabled: !busy,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () => _useRecent(r),
|
||||
deleteButtonTooltipMessage: 'Forget',
|
||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final ConnectMode mode;
|
||||
final ConnectFlow flow;
|
||||
final String action;
|
||||
final VoidCallback onSubmit;
|
||||
final List<Widget> fields;
|
||||
// Side by side: push the button to the bottom so both cards line up.
|
||||
final bool fill;
|
||||
const _ConnectCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.mode,
|
||||
required this.flow,
|
||||
required this.action,
|
||||
required this.onSubmit,
|
||||
required this.fields,
|
||||
required this.fill,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final busyHere = flow.busy == mode;
|
||||
final busyElsewhere = flow.busy != null && !busyHere;
|
||||
final error = flow.errors[mode];
|
||||
final notice = flow.notices[mode];
|
||||
return Panel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 22, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text(title, style: theme.textTheme.titleLarge),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: kGap),
|
||||
AbsorbPointer(
|
||||
absorbing: busyHere,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 12,
|
||||
children: fields,
|
||||
),
|
||||
),
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ErrorBox(error: error),
|
||||
] else if (notice != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(notice, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
if (fill) const Spacer(),
|
||||
const SizedBox(height: kGap),
|
||||
if (busyHere) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
||||
style: theme.textTheme.bodySmall),
|
||||
),
|
||||
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: busyElsewhere ? null : onSubmit,
|
||||
child: Text(action),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBox extends StatelessWidget {
|
||||
final CardError error;
|
||||
const _ErrorBox({required this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 18, color: scheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SelectableText.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(
|
||||
text: '${error.title}\n',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
TextSpan(text: error.message),
|
||||
]),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/window_chrome_bar.dart';
|
||||
import 'about_page.dart';
|
||||
import 'home_page.dart';
|
||||
import 'integrations_page.dart';
|
||||
import 'profile.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Everything outside a session: Home (profile, Join/Host, recents),
|
||||
/// Settings, Integrations, About behind a slim rail.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
WindowChromeBar(title: appName),
|
||||
Expanded(child: HomeShell()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [HomeScreen] minus the window chrome (which needs window_manager), so
|
||||
/// tests can pump it.
|
||||
class HomeShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
const HomeShell({super.key, this.updates, this.autoUpdateCheck = true});
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
int _tab = 0;
|
||||
|
||||
static const _destinations = [
|
||||
(Icons.home_outlined, Icons.home, 'Home'),
|
||||
(Icons.settings_outlined, Icons.settings, 'Settings'),
|
||||
(Icons.extension_outlined, Icons.extension, 'Integrations'),
|
||||
(Icons.info_outline, Icons.info, 'About'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Profile.instance.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
}
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: _tab,
|
||||
onDestinationSelected: (i) => setState(() => _tab = i),
|
||||
labelType: NavigationRailLabelType.none,
|
||||
minWidth: 64,
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
indicatorShape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||
destinations: [
|
||||
for (final (icon, selected, label) in _destinations)
|
||||
NavigationRailDestination(
|
||||
icon: Tooltip(message: label, child: Icon(icon)),
|
||||
selectedIcon: Tooltip(message: label, child: Icon(selected)),
|
||||
label: Text(label),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _tab,
|
||||
children: [
|
||||
HomePage(updates: widget.updates),
|
||||
SettingsPage(updates: widget.updates),
|
||||
const IntegrationsPage(),
|
||||
AboutPage(updates: widget.updates),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'ui.dart';
|
||||
|
||||
class IntegrationsPage extends StatelessWidget {
|
||||
const IntegrationsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
children: [
|
||||
const PageTitle('Integrations',
|
||||
subtitle: 'Connect media libraries to pick what to watch.'),
|
||||
Panel(
|
||||
enabled: false,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.video_library_outlined, size: 32),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Jellyfin', style: t.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Coming soon: link your Jellyfin account(s), browse and '
|
||||
'put up episodes, watched/continue-watching sync.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kGap),
|
||||
const OutlinedButton(onPressed: null, child: Text('Link account')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import '../../widgets/avatar_pipeline.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Display name + avatar, shared by the Home profile row and Settings.
|
||||
/// Backed by [SessionState.lastForm]; every change is persisted. The
|
||||
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
|
||||
/// before its path is stored, and the encoded bytes are the preview.
|
||||
class Profile {
|
||||
static final Profile instance = Profile._();
|
||||
Profile._();
|
||||
|
||||
final name = signal('');
|
||||
final preview = signal<Uint8List?>(null);
|
||||
final error = signal<String?>(null);
|
||||
final picking = signal(false);
|
||||
bool _loaded = false;
|
||||
|
||||
void ensureLoaded() {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final f = SessionState.instance.lastForm;
|
||||
name.value = f.name;
|
||||
_refreshPreview(f.avatarPath);
|
||||
}
|
||||
|
||||
Future<void> _refreshPreview(String path) async {
|
||||
try {
|
||||
final b64 = await encodeAvatar(path);
|
||||
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||
} catch (_) {
|
||||
preview.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
void setName(String value) {
|
||||
name.value = value.trim();
|
||||
SessionState.instance.lastForm.name = value.trim();
|
||||
_save();
|
||||
}
|
||||
|
||||
Future<void> pickAvatar() async {
|
||||
if (picking.peek()) return;
|
||||
picking.value = true;
|
||||
error.value = 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 (_) {
|
||||
error.value = 'file dialog unavailable — try again';
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
final String b64;
|
||||
try {
|
||||
b64 = await encodeAvatar(path);
|
||||
} on FormatException catch (e) {
|
||||
error.value = e.message;
|
||||
return;
|
||||
} catch (_) {
|
||||
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
|
||||
return;
|
||||
}
|
||||
SessionState.instance.lastForm.avatarPath = path;
|
||||
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||
await _save();
|
||||
} finally {
|
||||
picking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAvatar() async {
|
||||
SessionState.instance.lastForm.avatarPath = '';
|
||||
preview.value = null;
|
||||
error.value = null;
|
||||
await _save();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
await SessionState.instance.saveForm();
|
||||
} catch (_) {
|
||||
error.value = 'could not save profile — try again';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar circle with the initial as fallback.
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
final double radius;
|
||||
const ProfileAvatar({super.key, this.radius = 20});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return SignalBuilder(builder: (context) {
|
||||
final img = p.preview.value;
|
||||
final n = p.name.value;
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
backgroundImage: img != null ? MemoryImage(img) : null,
|
||||
child: img != null
|
||||
? null
|
||||
: p.picking.value
|
||||
? SizedBox(
|
||||
width: radius * 0.8,
|
||||
height: radius * 0.8,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
|
||||
style: TextStyle(fontSize: radius * 0.8)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar (tap to change) + name field. Used inline in Settings and in
|
||||
/// the Home profile dialog.
|
||||
class ProfileEditor extends StatefulWidget {
|
||||
final bool autofocus;
|
||||
final VoidCallback? onSubmitted;
|
||||
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
|
||||
|
||||
@override
|
||||
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||
}
|
||||
|
||||
class _ProfileEditorState extends State<ProfileEditor> {
|
||||
late final _name = TextEditingController(text: Profile.instance.name.peek());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: 'Change avatar',
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: p.pickAvatar,
|
||||
child: const ProfileAvatar(radius: 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _name,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Display name'),
|
||||
onChanged: p.setName,
|
||||
onSubmitted: (_) => widget.onSubmitted?.call(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SignalBuilder(builder: (context) {
|
||||
final err = p.error.value;
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: p.pickAvatar,
|
||||
child: const Text('Change avatar')),
|
||||
if (p.preview.value != null)
|
||||
TextButton(
|
||||
onPressed: p.clearAvatar,
|
||||
child: const Text('Remove avatar')),
|
||||
if (err != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(err,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showProfileDialog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||
title: const Text('Profile'),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: ProfileEditor(
|
||||
autofocus: true,
|
||||
onSubmitted: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context), child: const Text('Done')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Home's top-left: avatar + name, click to edit.
|
||||
class ProfileChip extends StatelessWidget {
|
||||
const ProfileChip({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = Profile.instance;
|
||||
return Tooltip(
|
||||
message: 'Edit profile',
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
onTap: () => showProfileDialog(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ProfileAvatar(radius: 20),
|
||||
const SizedBox(width: 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final n = p.name.value;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(n.isEmpty ? 'Set your name' : n,
|
||||
style: t.titleMedium?.copyWith(
|
||||
color: n.isEmpty ? Colors.grey : null)),
|
||||
Text('Edit profile',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
import 'profile.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// In-session keys, as handled by the session screen's global key handler.
|
||||
const sessionShortcuts = [
|
||||
('Space', 'Toggle ready'),
|
||||
('← / →', 'Seek back / forward 10 s'),
|
||||
('F', 'Toggle fullscreen'),
|
||||
('Tab', 'Open / close the people & chat panel'),
|
||||
('Ctrl+R', 'Reload the video'),
|
||||
('Esc', 'Close panel or fullscreen, else ask to leave'),
|
||||
];
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
final UpdateController? updates;
|
||||
const SettingsPage({super.key, this.updates});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
children: [
|
||||
const PageTitle('Settings'),
|
||||
const _Section(title: 'Profile', child: ProfileEditor()),
|
||||
const _Section(title: 'Player', child: _HwdecSetting()),
|
||||
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
|
||||
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
|
||||
const _Section(
|
||||
title: 'Video quality & shaders',
|
||||
enabled: false,
|
||||
trailing: _SoonBadge(),
|
||||
child: Text('Upscaling and shader presets for mpv.',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
final Widget? trailing;
|
||||
const _Section(
|
||||
{required this.title, required this.child, this.enabled = true, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kGap),
|
||||
child: Panel(
|
||||
enabled: enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [SectionLabel(title, trailing: trailing), child],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SoonBadge extends StatelessWidget {
|
||||
const _SoonBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('Coming soon', style: TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HwdecSetting extends StatefulWidget {
|
||||
const _HwdecSetting();
|
||||
|
||||
@override
|
||||
State<_HwdecSetting> createState() => _HwdecSettingState();
|
||||
}
|
||||
|
||||
class _HwdecSettingState extends State<_HwdecSetting> {
|
||||
final _s = AppSettings.instance;
|
||||
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_custom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final mode = _s.hwdecMode.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Hardware decoding'),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
|
||||
'shows glitches or a black picture.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SegmentedButton<HwdecMode>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: HwdecMode.auto, label: Text('Auto')),
|
||||
ButtonSegment(value: HwdecMode.off, label: Text('Off')),
|
||||
ButtonSegment(value: HwdecMode.custom, label: Text('Custom')),
|
||||
],
|
||||
selected: {mode},
|
||||
onSelectionChanged: (s) => _s.setHwdecMode(s.first),
|
||||
),
|
||||
if (mode == HwdecMode.custom)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: TextField(
|
||||
controller: _custom,
|
||||
decoration: fieldDecoration('mpv hwdec value',
|
||||
hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'),
|
||||
onChanged: _s.setHwdecCustom,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateSettings extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const _UpdateSettings({this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = controller ?? UpdateController.instance;
|
||||
final s = AppSettings.instance;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: UpdateStatusLine(controller: c)),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: c.status,
|
||||
builder: (context, st, _) {
|
||||
if (st.phase == UpdatePhase.idle || st.manifest == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => showUpdateDialog(context, controller: c),
|
||||
child: const Text('Show update'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: updateCheck,
|
||||
builder: (context, check, _) => OutlinedButton(
|
||||
onPressed: check == UpdateCheck.checking ? null : () => runUpdateCheck(c, true),
|
||||
child: const Text('Check now'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SignalBuilder(
|
||||
builder: (context) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Check automatically on start'),
|
||||
value: s.autoCheckUpdates.value,
|
||||
onChanged: s.setAutoCheckUpdates,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShortcutList extends StatelessWidget {
|
||||
const _ShortcutList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('While watching:', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
for (final (key, what) in sessionShortcuts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(key,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(what)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Product name (working title). Change it here only.
|
||||
const appName = 'Watch Together';
|
||||
|
||||
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
||||
|
||||
const kRadius = 12.0;
|
||||
const kGap = 16.0;
|
||||
const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28);
|
||||
|
||||
/// Rounded panel used by every Home/Settings section.
|
||||
class Panel extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool enabled;
|
||||
const Panel(
|
||||
{super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.enabled = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// Material (not a DecoratedBox) so ink from tiles/chips inside shows.
|
||||
final panel = Material(
|
||||
color: scheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(padding: padding, child: child),
|
||||
);
|
||||
if (enabled) return panel;
|
||||
return Opacity(opacity: 0.5, child: IgnorePointer(child: panel));
|
||||
}
|
||||
}
|
||||
|
||||
/// Page heading + optional subtitle, same spacing everywhere.
|
||||
class PageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
const PageTitle(this.title, {super.key, this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kGap),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: t.headlineSmall),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(subtitle!,
|
||||
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small grey heading inside a panel.
|
||||
class SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
const SectionLabel(this.text, {super.key, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field style shared by the connect cards.
|
||||
InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
|
||||
InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
suffixIcon: suffix,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
);
|
||||
|
||||
/// Single-item scrollable page body, width-capped so it doesn't sprawl
|
||||
/// on wide windows.
|
||||
class PageScroll extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final double maxWidth;
|
||||
const PageScroll({super.key, required this.children, this.maxWidth = 920});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/update_banner.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Compact "Update vX" pill for Home's top-right; hidden until there is
|
||||
/// something to install. Click opens the full [UpdateBanner].
|
||||
class UpdateIndicator extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const UpdateIndicator({super.key, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = controller ?? UpdateController.instance;
|
||||
return ValueListenableBuilder<UpdateStatus>(
|
||||
valueListenable: c.status,
|
||||
builder: (context, s, _) {
|
||||
final m = s.manifest;
|
||||
if (s.phase == UpdatePhase.idle || m == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final busy = s.phase == UpdatePhase.downloading ||
|
||||
s.phase == UpdatePhase.applying ||
|
||||
s.phase == UpdatePhase.restarting;
|
||||
final label = switch (s.phase) {
|
||||
UpdatePhase.downloading =>
|
||||
'Updating ${((s.progress ?? 0) * 100).round()}%',
|
||||
UpdatePhase.applying => 'Installing…',
|
||||
UpdatePhase.restarting => 'Restarting…',
|
||||
UpdatePhase.failed => 'Update failed',
|
||||
_ => m.isRequired ? 'Required update' : 'Update v${m.version}',
|
||||
};
|
||||
return Tooltip(
|
||||
message: 'Show update',
|
||||
child: FilledButton.tonalIcon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed
|
||||
? scheme.errorContainer
|
||||
: null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
onPressed: () => showUpdateDialog(context, controller: c),
|
||||
icon: busy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.system_update_alt, size: 18),
|
||||
label: Text(label),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The existing update card in a dialog; closes itself when the card
|
||||
/// would render nothing (e.g. "Later").
|
||||
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
||||
final c = controller ?? UpdateController.instance;
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: ValueListenableBuilder<UpdateStatus>(
|
||||
valueListenable: c.status,
|
||||
builder: (context, s, _) {
|
||||
if (s.phase == UpdatePhase.idle || s.manifest == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: UpdateBanner(controller: c),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum UpdateCheck { notRun, checking, done }
|
||||
|
||||
/// Progress of this run's update check, for Settings/About wording.
|
||||
final updateCheck = ValueNotifier(UpdateCheck.notRun);
|
||||
|
||||
/// [force] re-checks even if this process already checked ("Check now").
|
||||
Future<void> runUpdateCheck([UpdateController? controller, bool force = false]) async {
|
||||
final c = controller ?? UpdateController.instance;
|
||||
updateCheck.value = UpdateCheck.checking;
|
||||
try {
|
||||
await (force ? c.checkNow() : c.checkOnce());
|
||||
} finally {
|
||||
updateCheck.value = UpdateCheck.done;
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line update state: "Up to date", "Update v1.2 available", ...
|
||||
class UpdateStatusLine extends StatelessWidget {
|
||||
final UpdateController? controller;
|
||||
const UpdateStatusLine({super.key, this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = controller ?? UpdateController.instance;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([c.status, c.lastCheck, updateCheck]),
|
||||
builder: (context, _) {
|
||||
final s = c.status.value;
|
||||
final m = s.manifest;
|
||||
final String text;
|
||||
if (c.skipDevBuilds && c.currentVersion.endsWith('-dev')) {
|
||||
text = 'Dev build — update checks are off';
|
||||
} else if (s.phase != UpdatePhase.idle && m != null) {
|
||||
text = s.phase == UpdatePhase.failed
|
||||
? 'Update to v${m.version} failed'
|
||||
: 'Update v${m.version} available';
|
||||
} else {
|
||||
text = switch (updateCheck.value) {
|
||||
UpdateCheck.notRun => 'Not checked yet',
|
||||
UpdateCheck.checking => 'Checking…',
|
||||
UpdateCheck.done => c.lastCheck.value ?? 'Not checked yet',
|
||||
};
|
||||
}
|
||||
return Text(text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import '../state/session_state.dart';
|
||||
import '../update/updater.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/member_avatar.dart';
|
||||
import '../widgets/shown_frame_probe.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import '../widgets/window_drag_layer.dart';
|
||||
|
||||
@@ -20,8 +22,12 @@ class SessionScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SessionScreenState extends State<SessionScreen> {
|
||||
VideoController? _video;
|
||||
final _videoKey = GlobalKey<VideoState>();
|
||||
// One key per player generation: a reloaded player needs a fresh
|
||||
// `Video` (it never swaps controllers in place).
|
||||
var _videoGen = -1;
|
||||
var _videoKey = GlobalKey<VideoState>();
|
||||
bool _refullscreen = false;
|
||||
final _frameProbe = ShownFrameProbe();
|
||||
final _sheetOpen = signal(false);
|
||||
final _confirmLeave = signal(false);
|
||||
final _isFullscreen = signal(false);
|
||||
@@ -31,6 +37,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
final _swapLink = TextEditingController();
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopSheetWatch;
|
||||
late final void Function() _stopPlayerWatch;
|
||||
late final FocusNode _noFocus;
|
||||
|
||||
@override
|
||||
@@ -38,11 +45,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
super.initState();
|
||||
final session = SessionState.instance;
|
||||
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
_video = VideoController(session.coordinator.playback.player);
|
||||
session.coordinator.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
_stopKickWatch = effect(() {
|
||||
if (session.coordinator.kickReason.value != null) {
|
||||
session.route.value = AppRoute.connect;
|
||||
session.route.value = AppRoute.home;
|
||||
}
|
||||
});
|
||||
_stopSheetWatch = effect(() {
|
||||
@@ -50,6 +57,35 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
});
|
||||
// The fullscreen route holds its own `Video` of the old player: leave
|
||||
// it before the player goes away, re-enter once the new one is up.
|
||||
_stopPlayerWatch = effect(() {
|
||||
final alive = session.coordinator.playback.alive.value;
|
||||
if (!alive && _isFullscreen.peek()) {
|
||||
_refullscreen = true;
|
||||
if (mounted) Navigator.of(context, rootNavigator: true).maybePop();
|
||||
} else if (alive && _refullscreen) {
|
||||
_refullscreen = false;
|
||||
// Entering before the new output's first frame leaves the
|
||||
// fullscreen copy blank for good (seen in a real run).
|
||||
() async {
|
||||
try {
|
||||
await session.coordinator.playback.video.waitUntilFirstFrameRendered
|
||||
.timeout(const Duration(seconds: 10));
|
||||
} catch (_) {}
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (mounted && !_isFullscreen.peek() && session.coordinator.playback.alive.peek()) {
|
||||
_videoKey.currentState?.enterFullscreen();
|
||||
}
|
||||
}();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _reloadVideo() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
coord.markInput();
|
||||
coord.reloadVideo();
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
@@ -65,8 +101,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopKickWatch();
|
||||
_stopSheetWatch();
|
||||
_stopPlayerWatch();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
_swapLink.dispose();
|
||||
@@ -74,25 +113,21 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
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;
|
||||
if (textInputFocused()) 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.keyR &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_reloadVideo();
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
_lastKeyAt = now;
|
||||
coord.toggleReady();
|
||||
@@ -174,6 +209,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
const MaterialDesktopVolumeButton(),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_MarkAllReadyControlButton(focusNode: _noFocus),
|
||||
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
const MaterialDesktopFullscreenButton(),
|
||||
@@ -183,16 +219,27 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
descendantsAreTraversable: false,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: _video == null
|
||||
? const Center(
|
||||
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
||||
)
|
||||
: MaterialDesktopVideoControlsTheme(
|
||||
// Player lifetime = session (and each "Reload video"): no live
|
||||
// player ⇒ no `Video`, so no texture outlives its player.
|
||||
body: SignalBuilder(
|
||||
builder: (context) {
|
||||
final playback = coord.playback;
|
||||
final gen = playback.generation.value;
|
||||
if (!playback.alive.value) {
|
||||
return const Center(
|
||||
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
||||
);
|
||||
}
|
||||
if (gen != _videoGen) {
|
||||
_videoGen = gen;
|
||||
_videoKey = GlobalKey<VideoState>();
|
||||
}
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: controlsTheme,
|
||||
fullscreen: controlsTheme,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: _video!,
|
||||
controller: playback.video,
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: _sessionControls,
|
||||
onEnterFullscreen: () async {
|
||||
@@ -204,7 +251,9 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
await defaultExitNativeFullscreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -212,6 +261,8 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
Widget _sessionControls(VideoState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Frozen-picture watchdog samples the route around this anchor.
|
||||
_frameProbe.anchor(),
|
||||
MaterialDesktopVideoControls(state),
|
||||
_healthOverlay(),
|
||||
_waitingOverlay(),
|
||||
@@ -453,7 +504,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: AlertDialog(
|
||||
title: const Text('Leave room?'),
|
||||
title: const Text('Leave server?'),
|
||||
content: const Text('You can rejoin right away.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
|
||||
@@ -519,6 +570,30 @@ class _MarkAllReadyControlButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReloadVideoControlButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final VoidCallback onPressed;
|
||||
const _ReloadVideoControlButton({required this.focusNode, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||
child: TextButton.icon(
|
||||
focusNode: focusNode,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Reload video', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastCard extends StatefulWidget {
|
||||
final String text;
|
||||
const _ToastCard({super.key, required this.text});
|
||||
@@ -592,7 +667,7 @@ class _SheetPanel extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(playing: playing, hasRoom: r != null),
|
||||
],
|
||||
@@ -663,11 +738,19 @@ class _SheetPanel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
onPressed: () {
|
||||
coord.swapLink(swapLink.text.trim());
|
||||
swapLink.clear();
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: swapLink,
|
||||
builder: (context, value, _) {
|
||||
final link = value.text.trim();
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
onPressed: link.isEmpty
|
||||
? null
|
||||
: () {
|
||||
coord.swapLink(link);
|
||||
swapLink.clear();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -678,7 +761,7 @@ class _SheetPanel extends StatelessWidget {
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Leave room'),
|
||||
label: const Text('Leave server'),
|
||||
onPressed: onLeave,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../update/updater.dart';
|
||||
import '../widgets/window_chrome_bar.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
WindowChromeBar(title: 'Watch Together'),
|
||||
Expanded(child: _WelcomeBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeBody extends StatelessWidget {
|
||||
const _WelcomeBody();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Watch Together', style: TextStyle(fontSize: 24)),
|
||||
Text('v$appVersion', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
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