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:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:41:02 +03:00
1 parent beae87b0cc
commit aeff087c7d
30 files changed
+3278 -601

No files matched your search

@@ -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)),
],
),
),
],
);
}
}
+123
View File
@@ -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));
},
);
}
}