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:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:04:45 +03:00
1 parent 81cac1214d
commit 0669ec0fc2
12 files changed
+1670 -538

No files matched your search

+12 -7
View File
@@ -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(
// 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: [
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),
),
],
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: IndexedStack(
index: _tab,
children: [
HomePage(updates: widget.updates),
SettingsPage(updates: widget.updates),
const IntegrationsPage(),
AboutPage(updates: widget.updates),
child: KeyedSubtree(key: ValueKey(_view), child: body),
),
],
),
),
],
);
}
}
@@ -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')),
],
],
),
),
],
);
});
}
}
+23 -8
View File
@@ -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,23 +243,34 @@ class ProfileChip extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: () => showProfileDialog(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
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: [
const ProfileAvatar(radius: 20),
const SizedBox(width: 12),
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: [
Text(n.isEmpty ? 'Set your name' : n,
style: t.titleMedium?.copyWith(
color: n.isEmpty ? Colors.grey : null)),
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)),
],
),
),
),
);
}
}
+40
View File
@@ -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),
),
),
],
),
);
}
}
+195 -30
View File
@@ -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);
});
}