Home v2: top bar with Join/Host tabs, content-first layout
Top bar merges window chrome, profile, tabs and page icons. Join: one compact row plus recent-server tiles with live /health status and a needs-update badge. Host: reusable MediaPicker (Jellyfin placeholder, Link, File) filling the width, server settings in a narrow column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81cac1214d
commit
0669ec0fc2
12 files changed
+1689
-557
No files matched your search
@@ -23,7 +23,7 @@ Download the latest `watch-together-<version>-linux-x64.tar.gz` from the
|
||||
Releases page of this repo, extract it, run `watch_together`.
|
||||
|
||||
Update from the app: when a newer version exists, an update indicator
|
||||
appears on Home and opens the update card. "Update and restart" downloads,
|
||||
appears in Home's top bar and opens the update card. "Update and restart" downloads,
|
||||
verifies, applies in place and restarts; the install folder must be writable
|
||||
by you. Settings has Check now and an auto-check toggle. From a terminal:
|
||||
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A
|
||||
@@ -33,9 +33,11 @@ telling you to update.
|
||||
|
||||
## Host from the app
|
||||
|
||||
Home, Host card. Pick a port, an optional password (blank = anyone can
|
||||
join), and optionally a video: a link or a file path. Your name and avatar
|
||||
come from your profile. The app starts a
|
||||
Home, Host tab. Pick what to watch (optional): Link (paste an http(s) URL;
|
||||
Ctrl+V outside a field pastes it in) or File (path or Browse). Jellyfin is
|
||||
coming. In the column beside it set a port and an optional password (blank =
|
||||
anyone can join), then Host. Your name and avatar come from your profile
|
||||
(the chip at the top left). The app starts a
|
||||
server inside itself and joins it; you are the host and nobody can kick you.
|
||||
Leaving or closing the app stops the server. Friends need to reach your machine: open
|
||||
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
||||
@@ -70,9 +72,12 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||
|
||||
## Join
|
||||
|
||||
Home, Join card. Enter the host's address, port, and the password if there
|
||||
is one. Your name and avatar come from your profile (Home chip or Settings).
|
||||
Servers you got into appear as up to 6 Recent chips on Home: one click rejoins.
|
||||
Home, Join tab. Enter the host's address, port, and the password if there
|
||||
is one, then Enter or Join. Your name and avatar come from your profile (top
|
||||
bar chip or Settings). Servers you got into appear as up to 6 Recent server
|
||||
tiles with live status (online and how many are watching, or offline, plus
|
||||
"needs update" when the server runs another protocol): click one to rejoin,
|
||||
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// The submit button for one [ConnectMode], swapped for a progress bar and
|
||||
/// Cancel while that attempt runs. Disabled while the other mode is busy.
|
||||
class ConnectButton extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final ConnectMode mode;
|
||||
final String label;
|
||||
final VoidCallback onSubmit;
|
||||
final bool expand;
|
||||
const ConnectButton({
|
||||
super.key,
|
||||
required this.flow,
|
||||
required this.mode,
|
||||
required this.label,
|
||||
required this.onSubmit,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final busyHere = flow.busy == mode;
|
||||
if (busyHere) {
|
||||
return SizedBox(
|
||||
width: expand ? null : 220,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final button = FilledButton(
|
||||
onPressed: flow.busy != null ? null : onSubmit,
|
||||
child: Text(label),
|
||||
);
|
||||
return expand ? SizedBox(width: double.infinity, child: button) : button;
|
||||
}
|
||||
}
|
||||
|
||||
/// Last error or notice for [mode], or nothing.
|
||||
class ConnectFeedback extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final ConnectMode mode;
|
||||
const ConnectFeedback({super.key, required this.flow, required this.mode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final error = flow.errors[mode];
|
||||
final notice = flow.notices[mode];
|
||||
if (error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ErrorBox(title: error.title, message: error.message),
|
||||
);
|
||||
}
|
||||
if (notice != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(notice,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// Fetches a URL body; injectable so tests fake the server's `/health`.
|
||||
typedef HealthGetter = Future<String> Function(Uri uri);
|
||||
|
||||
const healthPollEvery = Duration(seconds: 10);
|
||||
const _timeout = Duration(seconds: 4);
|
||||
|
||||
/// What a recent-server tile shows: unknown until the first probe answers.
|
||||
class ServerHealth {
|
||||
final bool online;
|
||||
final String name;
|
||||
final int? proto;
|
||||
final int viewers;
|
||||
const ServerHealth.offline()
|
||||
: online = false,
|
||||
name = '',
|
||||
proto = null,
|
||||
viewers = 0;
|
||||
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
|
||||
: online = true;
|
||||
|
||||
/// True when the server speaks another protocol than this build.
|
||||
bool get needsUpdate =>
|
||||
online && proto != null && proto != RelayConstants.protoVersion;
|
||||
|
||||
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
|
||||
static ServerHealth parse(String body) {
|
||||
try {
|
||||
final j = jsonDecode(body);
|
||||
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
|
||||
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
|
||||
return ServerHealth.online(
|
||||
name: name is String ? name.trim() : '',
|
||||
proto: proto is num ? proto.toInt() : null,
|
||||
viewers: viewers is num ? viewers.toInt() : 0,
|
||||
);
|
||||
} catch (_) {
|
||||
return const ServerHealth.offline();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Uri healthUri(String host, String port) {
|
||||
final h = host.trim();
|
||||
// Bare IPv6 literals need brackets in a URL.
|
||||
final authority = h.contains(':') && !h.startsWith('[') ? '[$h]' : h;
|
||||
return Uri.parse('http://$authority:${port.trim()}/health');
|
||||
}
|
||||
|
||||
Future<ServerHealth> probeHealth(String host, String port, HealthGetter get) async {
|
||||
try {
|
||||
return ServerHealth.parse(await get(healthUri(host, port)).timeout(_timeout));
|
||||
} catch (_) {
|
||||
return const ServerHealth.offline();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain dart:io GET; non-200 throws.
|
||||
Future<String> defaultHealthGet(Uri uri) async {
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final res = await req.close().timeout(_timeout);
|
||||
if (res.statusCode != 200) {
|
||||
await res.drain<void>();
|
||||
throw HttpException('status ${res.statusCode}', uri: uri);
|
||||
}
|
||||
return await res.transform(utf8.decoder).join().timeout(_timeout);
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
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;
|
||||
late final void Function() _stopCrashWatch;
|
||||
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));
|
||||
});
|
||||
// Back from a session whose hosted server crashed: say so on the Host card.
|
||||
_stopCrashWatch = effect(() {
|
||||
final crash = SessionState.instance.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SessionState.instance.serverCrash.value = null;
|
||||
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopKickWatch();
|
||||
_stopCrashWatch();
|
||||
_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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.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 'connect_flow.dart';
|
||||
import 'health_probe.dart';
|
||||
import 'host_tab.dart';
|
||||
import 'integrations_page.dart';
|
||||
import 'join_tab.dart';
|
||||
import 'profile.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'top_bar.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// Everything outside a session: Home (profile, Join/Host, recents),
|
||||
/// Settings, Integrations, About behind a slim rail.
|
||||
/// Everything outside a session: one top bar (profile, Join/Host tabs,
|
||||
/// Settings/Integrations/About, window buttons) over a full-width body.
|
||||
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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
return const Scaffold(body: HomeShell(windowControls: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// [HomeScreen] minus the window chrome (which needs window_manager), so
|
||||
/// tests can pump it.
|
||||
/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the
|
||||
/// drag area and window buttons need window_manager) and a fake [healthGet].
|
||||
class HomeShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
const HomeShell({super.key, this.updates, this.autoUpdateCheck = true});
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
const HomeShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = false,
|
||||
this.healthGet = defaultHealthGet,
|
||||
});
|
||||
|
||||
@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'),
|
||||
];
|
||||
HomeView _view = HomeView.join;
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopCrashWatch;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Profile.instance.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_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));
|
||||
});
|
||||
// Back from a session whose hosted server crashed: say so on the Host tab.
|
||||
_stopCrashWatch = effect(() {
|
||||
final crash = SessionState.instance.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SessionState.instance.serverCrash.value = null;
|
||||
if (!mounted) return;
|
||||
_flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
||||
setState(() => _view = HomeView.host);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopKickWatch();
|
||||
_stopCrashWatch();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
@@ -63,40 +100,69 @@ class _HomeShellState extends State<HomeShell> {
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void _select(HomeView v) => setState(() => _view = v);
|
||||
|
||||
@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),
|
||||
),
|
||||
// Only the visible view is mounted: the Join tab polls /health only
|
||||
// while shown. Form state lives here, so nothing is lost on switch.
|
||||
final body = switch (_view) {
|
||||
HomeView.join =>
|
||||
JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet),
|
||||
HomeView.host => HostTab(
|
||||
flow: _flow,
|
||||
fields: _host,
|
||||
onOpenIntegrations: () => _select(HomeView.integrations),
|
||||
),
|
||||
HomeView.settings => SettingsPage(updates: widget.updates),
|
||||
HomeView.integrations => const IntegrationsPage(),
|
||||
HomeView.about => AboutPage(updates: widget.updates),
|
||||
};
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.digit1, control: true): () =>
|
||||
_select(HomeView.join),
|
||||
const SingleActivator(LogicalKeyboardKey.digit2, control: true): () =>
|
||||
_select(HomeView.host),
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () =>
|
||||
_select(HomeView.settings),
|
||||
},
|
||||
child: FocusScope(
|
||||
autofocus: true,
|
||||
child: Column(
|
||||
children: [
|
||||
HomeTopBar(
|
||||
view: _view,
|
||||
onSelect: _select,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
Divider(height: 1, thickness: 1, color: scheme.outlineVariant.withValues(alpha: 0.4)),
|
||||
Expanded(
|
||||
child: KeyedSubtree(key: ValueKey(_view), child: body),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
index: _tab,
|
||||
children: [
|
||||
HomePage(updates: widget.updates),
|
||||
SettingsPage(updates: widget.updates),
|
||||
const IntegrationsPage(),
|
||||
AboutPage(updates: widget.updates),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'connect_status.dart';
|
||||
import 'media_picker.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Host form state, owned by the Home shell so it survives tab switches.
|
||||
class HostFields {
|
||||
HostFields(ConnectForm f)
|
||||
: port = TextEditingController(text: f.hostPort),
|
||||
secret = TextEditingController(text: f.hostSecret),
|
||||
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
final MediaPickerController media;
|
||||
|
||||
void dispose() {
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
media.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Below this width the server column moves under the media picker.
|
||||
const hostStackBelow = 860.0;
|
||||
const _columnWidth = 340.0;
|
||||
|
||||
/// Media picker as the main area; port/password/Host in a narrow column.
|
||||
class HostTab extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const HostTab(
|
||||
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
|
||||
|
||||
void _host() => flow.submit(ConnectMode.host, (f) {
|
||||
final media = fields.media.selection;
|
||||
f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text
|
||||
..mediaPath = media.mediaPath
|
||||
..linkUrl = media.linkUrl;
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) {
|
||||
final busy = flow.busy == ConnectMode.host;
|
||||
final picker = Column(
|
||||
key: const ValueKey('host-picker'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('What to watch', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
MediaPicker(
|
||||
controller: fields.media,
|
||||
enabled: !busy,
|
||||
autofocus: true,
|
||||
onSubmit: _host,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
),
|
||||
],
|
||||
);
|
||||
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final stacked = box.maxWidth < hostStackBelow;
|
||||
return SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: stacked
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
picker,
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||
child: column,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: picker),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(width: _columnWidth, child: column),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerColumn extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback onHost;
|
||||
const _ServerColumn({required this.flow, required this.fields, required this.onHost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('host-column'),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cast, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Text('Your server', style: t.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('Runs on this computer. Friends need this port reachable.',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: kGap),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
child: TextField(
|
||||
key: const ValueKey('host-port'),
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: fields.secret,
|
||||
builder: (context, _) => TextField(
|
||||
key: const ValueKey('host-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password').copyWith(
|
||||
helperText:
|
||||
fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
||||
),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: fields.media,
|
||||
builder: (context, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(_mediaSummary(fields.media.selection),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
),
|
||||
),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
const SizedBox(height: kGap),
|
||||
ConnectButton(
|
||||
flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost, expand: true),
|
||||
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _mediaSummary(MediaSelection s) {
|
||||
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
|
||||
if (raw.isEmpty) return 'No media selected (optional).';
|
||||
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
|
||||
return 'Playing: $name';
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports hosted before: one click refills port and password.
|
||||
class _HostedRecents extends StatelessWidget {
|
||||
final HostFields fields;
|
||||
final bool enabled;
|
||||
const _HostedRecents({required this.fields, required this.enabled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: kGap),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in list)
|
||||
InputChip(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
||||
label: Text('Port ${r.port}'),
|
||||
tooltip: 'Use port ${r.port} again',
|
||||
isEnabled: enabled,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () {
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
},
|
||||
deleteButtonTooltipMessage: 'Forget',
|
||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'connect_status.dart';
|
||||
import 'health_probe.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
/// Join form text, owned by the Home shell so it survives tab switches.
|
||||
class JoinFields {
|
||||
JoinFields(ConnectForm f)
|
||||
: host = TextEditingController(text: f.host),
|
||||
port = TextEditingController(text: f.port),
|
||||
secret = TextEditingController(text: f.secret);
|
||||
final TextEditingController host;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
host.dispose();
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest recent-server tile; the grid adds columns as the window widens.
|
||||
const _minTileWidth = 300.0;
|
||||
|
||||
/// One compact address/port/password row, then recent servers with live
|
||||
/// status, as a grid that fills the width.
|
||||
class JoinTab extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final JoinFields fields;
|
||||
final HealthGetter healthGet;
|
||||
const JoinTab(
|
||||
{super.key, required this.flow, required this.fields, required this.healthGet});
|
||||
|
||||
void _join() => flow.submit(ConnectMode.join, (f) {
|
||||
f
|
||||
..host = fields.host.text
|
||||
..port = fields.port.text.trim()
|
||||
..secret = fields.secret.text;
|
||||
});
|
||||
|
||||
void _joinRecent(RecentServer r) {
|
||||
fields.host.text = r.host;
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
_join();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) => SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Join a friend’s server', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.join,
|
||||
child: _joinRow(),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
|
||||
child: ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text('Recent servers', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (!r.hosted) r
|
||||
];
|
||||
return RecentServersGrid(
|
||||
servers: list,
|
||||
enabled: flow.busy == null,
|
||||
healthGet: healthGet,
|
||||
onJoin: _joinRecent,
|
||||
onForget: SessionState.instance.forgetServer,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _joinRow() {
|
||||
return Wrap(
|
||||
key: const ValueKey('join-row'),
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-address'),
|
||||
controller: fields.host,
|
||||
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
||||
onChanged: (_) => flow.clearError(ConnectMode.join),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(':', style: TextStyle(fontSize: 18, color: Colors.grey)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-port'),
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password', hint: 'If the server has one'),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent servers as tiles; polls each one's `/health` every
|
||||
/// [healthPollEvery] while mounted (i.e. while the Join tab is visible).
|
||||
class RecentServersGrid extends StatefulWidget {
|
||||
final List<RecentServer> servers;
|
||||
final bool enabled;
|
||||
final HealthGetter healthGet;
|
||||
final ValueChanged<RecentServer> onJoin;
|
||||
final ValueChanged<RecentServer> onForget;
|
||||
const RecentServersGrid({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.enabled,
|
||||
required this.healthGet,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecentServersGrid> createState() => _RecentServersGridState();
|
||||
}
|
||||
|
||||
class _RecentServersGridState extends State<RecentServersGrid> {
|
||||
final _health = <String, ServerHealth>{};
|
||||
final _inFlight = <String>{};
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pollAll();
|
||||
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecentServersGrid old) {
|
||||
super.didUpdateWidget(old);
|
||||
for (final r in widget.servers) {
|
||||
if (!_health.containsKey(r.key)) _poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pollAll() {
|
||||
for (final r in widget.servers) {
|
||||
_poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll(RecentServer r) async {
|
||||
if (!_inFlight.add(r.key)) return;
|
||||
try {
|
||||
final h = await probeHealth(r.host, r.port, widget.healthGet);
|
||||
if (mounted) setState(() => _health[r.key] = h);
|
||||
} finally {
|
||||
_inFlight.remove(r.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.servers.isEmpty) return const _EmptyRecents();
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = ((box.maxWidth + kGap) / (_minTileWidth + kGap)).floor().clamp(1, 64);
|
||||
final width = (box.maxWidth - kGap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: kGap,
|
||||
runSpacing: kGap,
|
||||
children: [
|
||||
for (final r in widget.servers)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _RecentTile(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
server: r,
|
||||
health: _health[r.key],
|
||||
enabled: widget.enabled,
|
||||
onJoin: () => widget.onJoin(r),
|
||||
onForget: () => widget.onForget(r),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentTile extends StatelessWidget {
|
||||
final RecentServer server;
|
||||
/// Null until the first probe answers.
|
||||
final ServerHealth? health;
|
||||
final bool enabled;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onForget;
|
||||
const _RecentTile({
|
||||
super.key,
|
||||
required this.server,
|
||||
required this.health,
|
||||
required this.enabled,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final t = theme.textTheme;
|
||||
final h = health;
|
||||
final address = '${server.host}:${server.port}';
|
||||
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
|
||||
final (status, statusColor) = switch (h) {
|
||||
null => ('Checking…', Colors.grey),
|
||||
ServerHealth(online: true) => (
|
||||
'● online · ${h.viewers} watching',
|
||||
Colors.greenAccent.shade400
|
||||
),
|
||||
_ => ('○ offline', Colors.grey),
|
||||
};
|
||||
return Material(
|
||||
color: scheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onJoin : null,
|
||||
child: Tooltip(
|
||||
message: 'Join $address',
|
||||
waitDuration: const Duration(milliseconds: 600),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 6, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.dns_outlined, size: 20, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
|
||||
),
|
||||
if (server.secret.isNotEmpty)
|
||||
const Tooltip(
|
||||
message: 'Password saved',
|
||||
child: Icon(Icons.lock_outline, size: 16, color: Colors.grey),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Forget',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 18,
|
||||
onPressed: onForget,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 30, right: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(status,
|
||||
key: const ValueKey('recent-status'),
|
||||
style: t.bodyMedium?.copyWith(color: statusColor)),
|
||||
),
|
||||
if (h != null && h.needsUpdate)
|
||||
Tooltip(
|
||||
message: 'Server protocol ${h.proto}, this app '
|
||||
'${RelayConstants.protoVersion} — update so both match',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('needs update',
|
||||
style: t.labelSmall
|
||||
?.copyWith(color: scheme.onErrorContainer)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyRecents extends StatelessWidget {
|
||||
const _EmptyRecents();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('recents-empty'),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.history, size: 28, color: Colors.grey),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('No recent servers yet', style: t.titleSmall),
|
||||
const SizedBox(height: 2),
|
||||
Text('Servers you join show up here with live status; click one to rejoin.',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../widgets/media_input.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||
|
||||
enum MediaKind { jellyfin, link, file }
|
||||
|
||||
/// What to play: at most one of the two is non-empty; both empty = none.
|
||||
typedef MediaSelection = ({String mediaPath, String linkUrl});
|
||||
|
||||
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
|
||||
/// later the session's swap panel) so it survives rebuilds.
|
||||
class MediaPickerController extends ChangeNotifier {
|
||||
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
|
||||
: link = TextEditingController(text: linkUrl),
|
||||
file = TextEditingController(text: mediaPath),
|
||||
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
|
||||
link.addListener(notifyListeners);
|
||||
file.addListener(notifyListeners);
|
||||
}
|
||||
|
||||
final TextEditingController link;
|
||||
final TextEditingController file;
|
||||
MediaKind _source;
|
||||
String? _notice;
|
||||
|
||||
MediaKind get source => _source;
|
||||
set source(MediaKind v) {
|
||||
if (v == _source) return;
|
||||
_source = v;
|
||||
_notice = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// One-line hint under the fields (e.g. the file dialog failed).
|
||||
String? get notice => _notice;
|
||||
set notice(String? v) {
|
||||
_notice = v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
MediaSelection get selection => switch (_source) {
|
||||
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
|
||||
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
|
||||
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
|
||||
};
|
||||
|
||||
/// Drops pasted text where it belongs: http(s) into Link, else File.
|
||||
void pasteText(String text) {
|
||||
final parsed = parseMediaInput(text);
|
||||
if (parsed.linkUrl.isNotEmpty) {
|
||||
link.text = parsed.linkUrl;
|
||||
source = MediaKind.link;
|
||||
} else if (parsed.mediaPath.isNotEmpty) {
|
||||
file.text = parsed.mediaPath;
|
||||
source = MediaKind.file;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
link.dispose();
|
||||
file.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Source segments (Jellyfin / Link / File) and the matching input.
|
||||
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
|
||||
class MediaPicker extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final ValueChanged<MediaSelection>? onChanged;
|
||||
/// Enter in a field.
|
||||
final VoidCallback? onSubmit;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
const MediaPicker({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.onChanged,
|
||||
this.onSubmit,
|
||||
this.onOpenIntegrations,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MediaPicker> createState() => _MediaPickerState();
|
||||
}
|
||||
|
||||
class _MediaPickerState extends State<MediaPicker> {
|
||||
MediaPickerController get _c => widget.controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c.addListener(_changed);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MediaPicker old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_changed);
|
||||
widget.controller.addListener(_changed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
widget.onChanged?.call(_c.selection);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<String?> _clipboard() async {
|
||||
try {
|
||||
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pasteAnywhere() async {
|
||||
final text = await _clipboard();
|
||||
if (text != null && text.isNotEmpty) _c.pasteText(text);
|
||||
}
|
||||
|
||||
Future<void> _pasteLink() async {
|
||||
final text = await _clipboard();
|
||||
if (text != null && text.isNotEmpty) _c.link.text = text;
|
||||
}
|
||||
|
||||
Future<void> _browse() 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) {
|
||||
_c.file.text = path;
|
||||
_c.notice = null;
|
||||
}
|
||||
} catch (e) {
|
||||
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed;
|
||||
if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// A focused text field handles its own paste.
|
||||
final inField = FocusManager.instance.primaryFocus?.context
|
||||
?.findAncestorStateOfType<EditableTextState>() !=
|
||||
null;
|
||||
if (inField) return KeyEventResult.ignored;
|
||||
_pasteAnywhere();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = _c.source;
|
||||
return Focus(
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: _onKey,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !widget.enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: MediaKind.jellyfin,
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('Jellyfin')),
|
||||
ButtonSegment(
|
||||
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
|
||||
ButtonSegment(
|
||||
value: MediaKind.file,
|
||||
icon: Icon(Icons.insert_drive_file_outlined),
|
||||
label: Text('File')),
|
||||
],
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kGap),
|
||||
switch (source) {
|
||||
MediaKind.jellyfin =>
|
||||
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
|
||||
MediaKind.link => _FieldRow(
|
||||
key: const ValueKey('media-link'),
|
||||
field: TextField(
|
||||
controller: _c.link,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _pasteLink,
|
||||
icon: const Icon(Icons.content_paste, size: 18),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
help: 'A direct http(s) link to a video file; everyone streams it from there.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
MediaKind.file => _FieldRow(
|
||||
key: const ValueKey('media-file'),
|
||||
field: TextField(
|
||||
controller: _c.file,
|
||||
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse'),
|
||||
),
|
||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldRow extends StatelessWidget {
|
||||
final Widget field;
|
||||
final Widget button;
|
||||
final String help;
|
||||
final String? notice;
|
||||
const _FieldRow(
|
||||
{super.key, required this.field, required this.button, required this.help, this.notice});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: field),
|
||||
const SizedBox(width: 12),
|
||||
button,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(notice ?? help, style: small),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Jellyfin library grid will go: ghost posters that fill the
|
||||
/// width, dimmed, with the "coming soon" note on top.
|
||||
class _JellyfinPlaceholder extends StatelessWidget {
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const _JellyfinPlaceholder({this.onOpenIntegrations});
|
||||
|
||||
static const _posterWidth = 140.0;
|
||||
static const _rows = 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
|
||||
return Stack(
|
||||
key: const ValueKey('media-jellyfin'),
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.35,
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (var r = 0; r < _rows; r++)
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (var c = 0; c < cols; c++)
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Panel(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text('Link your Jellyfin in Integrations — coming soon',
|
||||
textAlign: TextAlign.center, style: t.titleSmall),
|
||||
if (onOpenIntegrations != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,11 @@ Future<void> showProfileDialog(BuildContext context) {
|
||||
|
||||
/// Home's top-left: avatar + name, click to edit.
|
||||
class ProfileChip extends StatelessWidget {
|
||||
const ProfileChip({super.key});
|
||||
/// One line and a small avatar, for the Home top bar.
|
||||
final bool dense;
|
||||
/// Just the avatar, for very narrow windows.
|
||||
final bool avatarOnly;
|
||||
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -239,31 +243,42 @@ class ProfileChip extends StatelessWidget {
|
||||
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,
|
||||
child: avatarOnly
|
||||
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
|
||||
: Padding(
|
||||
padding: dense
|
||||
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
|
||||
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
||||
child: Row(
|
||||
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)),
|
||||
ProfileAvatar(radius: dense ? 14 : 20),
|
||||
SizedBox(width: dense ? 8 : 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final n = p.name.value;
|
||||
final t = Theme.of(context).textTheme;
|
||||
final name = Text(n.isEmpty ? 'Set your name' : n,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: (dense ? t.titleSmall : t.titleMedium)
|
||||
?.copyWith(color: n.isEmpty ? Colors.grey : null));
|
||||
if (dense) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 200), child: name);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
name,
|
||||
Text('Edit profile',
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/window_buttons.dart';
|
||||
import 'profile.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
/// What the Home body shows: the two tabs, or one of the side pages.
|
||||
enum HomeView { join, host, settings, integrations, about }
|
||||
|
||||
const homeTopBarHeight = 48.0;
|
||||
|
||||
/// Below this width the bar drops the profile name and tab icons.
|
||||
const _compactBelow = 760.0;
|
||||
|
||||
/// Home's single top bar: profile, Join/Host tabs, update pill, page
|
||||
/// buttons and (in the real window) the drag area and window buttons.
|
||||
class HomeTopBar extends StatelessWidget {
|
||||
final HomeView view;
|
||||
final ValueChanged<HomeView> onSelect;
|
||||
final UpdateController? updates;
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
const HomeTopBar({
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.onSelect,
|
||||
this.updates,
|
||||
this.windowControls = false,
|
||||
});
|
||||
|
||||
static const _pages = [
|
||||
(HomeView.settings, Icons.settings_outlined, Icons.settings, 'Settings'),
|
||||
(HomeView.integrations, Icons.extension_outlined, Icons.extension, 'Integrations'),
|
||||
(HomeView.about, Icons.info_outline, Icons.info, 'About'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) => _bar(context, compact: box.maxWidth < _compactBelow));
|
||||
}
|
||||
|
||||
Widget _bar(BuildContext context, {required bool compact}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final row = Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
ProfileChip(dense: true, avatarOnly: compact),
|
||||
SizedBox(width: compact ? 8 : 20),
|
||||
_Tab(
|
||||
key: const ValueKey('tab-join'),
|
||||
label: 'Join',
|
||||
icon: compact ? null : Icons.login,
|
||||
selected: view == HomeView.join,
|
||||
onTap: () => onSelect(HomeView.join),
|
||||
),
|
||||
_Tab(
|
||||
key: const ValueKey('tab-host'),
|
||||
label: 'Host',
|
||||
icon: compact ? null : Icons.cast,
|
||||
selected: view == HomeView.host,
|
||||
onTap: () => onSelect(HomeView.host),
|
||||
),
|
||||
const Spacer(),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
for (final (v, icon, selectedIcon, label) in _pages)
|
||||
IconButton(
|
||||
tooltip: label,
|
||||
isSelected: view == v,
|
||||
icon: Icon(icon, size: 20),
|
||||
selectedIcon: Icon(selectedIcon, size: 20, color: scheme.primary),
|
||||
onPressed: () => onSelect(v),
|
||||
),
|
||||
if (windowControls) ...[
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
height: 20,
|
||||
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
|
||||
),
|
||||
const WindowButtons(),
|
||||
] else
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
final bar = SizedBox(height: homeTopBarHeight, child: row);
|
||||
return ColoredBox(
|
||||
color: scheme.surfaceContainerLowest,
|
||||
// Empty bar space drags the window; the controls on top take their
|
||||
// own clicks without waiting out the drag area's double-tap.
|
||||
child: windowControls
|
||||
? Stack(children: [
|
||||
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
|
||||
bar,
|
||||
])
|
||||
: bar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
const _Tab(
|
||||
{super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
required this.selected,
|
||||
required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: homeTopBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: selected ? scheme.primary : Colors.transparent, width: 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(label,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: color, fontWeight: selected ? FontWeight.w600 : null)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,3 +121,43 @@ class PageScroll extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widest a single text field gets, however wide the window.
|
||||
const kFieldMaxWidth = 720.0;
|
||||
|
||||
/// Inline failure under a connect form: bold title, selectable message.
|
||||
class ErrorBox extends StatelessWidget {
|
||||
final String title;
|
||||
final String message;
|
||||
const ErrorBox({super.key, required this.title, required this.message});
|
||||
|
||||
@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: '$title\n',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
TextSpan(text: message),
|
||||
]),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/screens/home/health_probe.dart';
|
||||
import 'package:watch_together/src/screens/home/home_screen.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/screens/home/ui.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
@@ -61,66 +67,225 @@ void main() {
|
||||
expect(s.hwdec.value, 'vaapi');
|
||||
});
|
||||
|
||||
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
||||
test('health parse: online, junk and protocol mismatch', () {
|
||||
final ok = ServerHealth.parse(jsonEncode(
|
||||
{'ok': true, 'name': 'Den', 'proto': RelayConstants.protoVersion, 'viewers': 2}));
|
||||
expect(ok.online, isTrue);
|
||||
expect(ok.name, 'Den');
|
||||
expect(ok.viewers, 2);
|
||||
expect(ok.needsUpdate, isFalse);
|
||||
expect(ServerHealth.parse('{"ok":true,"proto":-1}').needsUpdate, isTrue);
|
||||
expect(ServerHealth.parse('nope').online, isFalse);
|
||||
expect(ServerHealth.parse('{"ok":false}').online, isFalse);
|
||||
expect(healthUri('::1', '8080').toString(), 'http://[::1]:8080/health');
|
||||
});
|
||||
|
||||
test('pasting sorts links and paths into the right source', () {
|
||||
final c = MediaPickerController();
|
||||
expect(c.source, MediaKind.file);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
expect(c.source, MediaKind.link);
|
||||
expect(c.selection, (mediaPath: '', linkUrl: 'https://example.com/a.mkv'));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.selection, (mediaPath: '/home/me/b.mkv', linkUrl: ''));
|
||||
c.source = MediaKind.jellyfin;
|
||||
expect(c.selection, (mediaPath: '', linkUrl: ''));
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
/// Fake `/health`: host -> JSON body; unknown hosts are unreachable.
|
||||
HealthGetter fakeHealth(Map<String, Map<String, Object>> up) => (uri) async {
|
||||
final body = up[uri.host];
|
||||
if (body == null) throw Exception('connection refused');
|
||||
return jsonEncode(body);
|
||||
};
|
||||
|
||||
Future<void> pumpShell(WidgetTester tester, Size size, {HealthGetter? health}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: HomeShell(updates: UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false),
|
||||
body: HomeShell(
|
||||
updates: UpdateController(currentVersion: '0.0.0-dev'),
|
||||
autoUpdateCheck: false,
|
||||
healthGet: health ?? fakeHealth(const {}),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('home shows both cards side by side on a wide window', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 720));
|
||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||
expect(join.top, host.top);
|
||||
expect(join.height, host.height);
|
||||
expect(join.right, lessThan(host.left));
|
||||
setUp(() => SessionState.instance.recents.value = const []);
|
||||
|
||||
testWidgets('tabs and page buttons switch the body', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('host-picker')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('join-row')), findsNothing);
|
||||
expect(find.text('No password — anyone can join'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||
expect(find.text('Toggle ready'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('About'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-join')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('cards stack on a narrow window', (tester) async {
|
||||
await pumpShell(tester, const Size(560, 900));
|
||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||
expect(join.bottom, lessThan(host.top));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('join without a name errors inline in the join card', (tester) async {
|
||||
testWidgets('Enter in the join row submits; errors show under it', (tester) async {
|
||||
SessionState.instance.lastForm.name = '';
|
||||
await pumpShell(tester, const Size(1280, 720));
|
||||
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
|
||||
// The button submits too, and typing in the address clears the error.
|
||||
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example2');
|
||||
await tester.pump();
|
||||
expect(find.textContaining('display name', findRichText: true), findsNothing);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('recent tiles show live status from /health', (tester) async {
|
||||
SessionState.instance.recents.value = [
|
||||
join('alpha.example', '8080'),
|
||||
join('beta.example', '9000', 'pw'),
|
||||
join('gamma.example', '7000'),
|
||||
const RecentServer(hosted: true, host: '', port: '8181'),
|
||||
];
|
||||
await pumpShell(tester, const Size(1280, 900),
|
||||
health: fakeHealth({
|
||||
'alpha.example': {
|
||||
'ok': true,
|
||||
'name': 'Movie night',
|
||||
'proto': RelayConstants.protoVersion,
|
||||
'viewers': 3,
|
||||
},
|
||||
'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0},
|
||||
}));
|
||||
await tester.pump();
|
||||
Finder inTile(String key, Finder f) =>
|
||||
find.descendant(of: find.byKey(ValueKey('recent-$key')), matching: f);
|
||||
expect(inTile('alpha.example:8080', find.text('Movie night')), findsOneWidget);
|
||||
expect(inTile('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget);
|
||||
expect(inTile('alpha.example:8080', find.text('needs update')), findsNothing);
|
||||
expect(inTile('beta.example:9000', find.text('○ offline')), findsOneWidget);
|
||||
expect(inTile('beta.example:9000', find.text('beta.example')), findsOneWidget);
|
||||
expect(inTile('gamma.example:7000', find.text('needs update')), findsOneWidget);
|
||||
// Hosted entries live on the Host tab, not in the join grid.
|
||||
expect(find.byKey(const ValueKey('recent-host:8181')), findsNothing);
|
||||
// Tiles share a row on a wide window.
|
||||
final a = tester.getRect(find.byKey(const ValueKey('recent-alpha.example:8080')));
|
||||
final b = tester.getRect(find.byKey(const ValueKey('recent-beta.example:9000')));
|
||||
expect(a.top, b.top);
|
||||
// x forgets.
|
||||
await tester.tap(inTile('beta.example:9000', find.byTooltip('Forget')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('recent-beta.example:9000')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('no recents shows the empty state', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 720));
|
||||
expect(find.byKey(const ValueKey('recents-empty')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('wide windows: grid fills the width, fields stay compact', (tester) async {
|
||||
SessionState.instance.recents.value = [
|
||||
for (var i = 0; i < 6; i++) join('10.0.0.$i', '8080'),
|
||||
];
|
||||
await pumpShell(tester, const Size(3440, 1440));
|
||||
final first = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.0:8080')));
|
||||
final last = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.5:8080')));
|
||||
expect(first.top, last.top);
|
||||
expect(tester.getSize(find.byKey(const ValueKey('join-address'))).width, 280);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(find.byKey(const ValueKey('host-column'))).width, 340);
|
||||
final file = find.descendant(
|
||||
of: find.byKey(const ValueKey('media-file')), matching: find.byType(TextField));
|
||||
expect(tester.getSize(file).width, lessThanOrEqualTo(kFieldMaxWidth));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('media picker segments switch the input', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
|
||||
expect(find.text('Browse'), findsOneWidget);
|
||||
await tester.tap(find.text('Link'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
|
||||
expect(find.text('Paste'), findsOneWidget);
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link your Jellyfin in Integrations — coming soon'), findsOneWidget);
|
||||
await tester.tap(find.text('Open Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link account'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('host column sits right when wide, below when narrow', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
var picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
|
||||
var column = tester.getRect(find.byKey(const ValueKey('host-column')));
|
||||
expect(picker.right, lessThan(column.left));
|
||||
expect(picker.top, column.top);
|
||||
|
||||
await pumpShell(tester, const Size(640, 1000));
|
||||
picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
|
||||
column = tester.getRect(find.byKey(const ValueKey('host-column')));
|
||||
expect(picker.bottom, lessThan(column.top));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('host without a name errors in the server column', (tester) async {
|
||||
SessionState.instance.lastForm.name = '';
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Host'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('join-card')),
|
||||
of: find.byKey(const ValueKey('host-column')),
|
||||
matching: find.textContaining('display name', findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('rail switches to settings, integrations and about', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
testWidgets('narrow window: bar, join row and host tab fit', (tester) async {
|
||||
await pumpShell(tester, const Size(480, 800));
|
||||
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byKey(const ValueKey('tab-host')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||
expect(find.text('Toggle ready'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.extension_outlined));
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user