1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash

The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:23:46 +03:00
1 parent f790470935
commit d29c12e4f0
62 files changed
+5777 -3572

No files matched your search

+2 -13
View File
@@ -1,28 +1,17 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'src/screens/home/home_screen.dart';
import 'src/screens/home/ui.dart';
import 'src/screens/session_screen.dart';
import 'src/state/session_state.dart';
import 'src/shell/app_shell.dart';
class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key});
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
return MaterialApp(
title: appName,
theme: ThemeData.dark(),
home: SignalBuilder(
builder: (context) {
return switch (session.route.value) {
AppRoute.home => const HomeScreen(),
AppRoute.session => const SessionScreen(),
};
},
),
home: const AppShell(),
);
}
}
@@ -6,11 +6,10 @@ import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
// The experimental mpv-style (uosc) controls frame: a full-width timeline
// at the bottom with a row of flat buttons above it, window buttons top
// at the bottom with a row of flat buttons above it, a corner widget top
// right. Mouse movement shows them; after [UoscControls.hideAfter] without
// any they fade out, the timeline shrinks to a thin line and the cursor
// hides. Everything lives here because media_kit's fullscreen route
// renders only the video and its controls.
// hides. Our own frame: no media_kit controls state to reach into.
class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden.
@@ -18,7 +17,7 @@ class UoscControls extends StatefulWidget {
/// Buttons left and right of the row above the timeline.
final List<Widget> left;
final List<Widget> right;
/// Top-right corner (window buttons), shown with the controls.
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
+1 -1
View File
@@ -207,7 +207,7 @@ class JellyfinAccounts extends ChangeNotifier {
/// Last failure text for an offline account.
String? errorOf(String serverId) => _errors[serverId];
/// The Host tab's last Jellyfin pick, restored on the next start.
/// The last Jellyfin pick put up, restored in the Library on the next start.
JellyfinMedia? get lastPick => _lastPick;
/// Stable per install: Jellyfin keys devices and tokens by it.
@@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4),
const Text(
'Browse your libraries in the Host tab and put up movies and episodes. '
'Browse your libraries in the Library tab and put up movies and episodes. '
'Sign in with Quick Connect or your password; several servers are fine.',
style: TextStyle(color: Colors.grey),
),
+1 -1
View File
@@ -91,7 +91,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
const [HomeRoute()];
/// Every navigation is saved per server (Hive), so the next browser
/// (Host tab, swap panel, next start) opens where this one was.
/// (the Library tab, next start) opens where this one was.
void _remember() {
final id = _account?.serverId;
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
@@ -66,7 +66,7 @@ class PlaybackController {
/// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek;
/// Latest shader message for the user (the session screen toasts it).
/// Latest shader message for the user (the session player toasts it).
final shaderNotice = signal<ShaderNotice?>(null);
/// How media_kit renders the current player's video (shaders need
@@ -25,9 +25,10 @@ Future<bool> openInBrowser(String url) async {
}
}
class AboutPage extends StatelessWidget {
/// Name, version, update state and the source link (Settings → About).
class AboutPanel extends StatelessWidget {
final UpdateController? updates;
const AboutPage({super.key, this.updates});
const AboutPanel({super.key, this.updates});
Future<void> _open(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context);
@@ -40,11 +41,7 @@ class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return PageScroll(
maxWidth: 680,
children: [
const PageTitle('About'),
Panel(
return Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -73,8 +70,6 @@ class AboutPage extends StatelessWidget {
),
],
),
),
],
);
);
}
}
@@ -1,12 +1,8 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../session/session_coordinator.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import '../../widgets/media_input.dart';
import 'profile.dart';
/// What a card shows under its fields after a failed attempt.
class CardError {
@@ -15,7 +11,7 @@ class CardError {
const CardError(this.title, this.message);
}
/// Host/Join submit state for the two Home cards. One attempt at a time;
/// Host/Join submit state for the strip's two connect cards. One attempt at a time;
/// [cancel] abandons it via [SessionState.cancelPending].
class ConnectFlow extends ChangeNotifier {
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
@@ -27,7 +23,8 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0;
bool _disposed = false;
/// Set when an attempt ends in a kick, so Home shows the removal dialog.
/// Set when an attempt ends in a kick, so the shell shows the removal
/// dialog.
void Function(String reason)? onKicked;
@override
@@ -54,61 +51,38 @@ class ConnectFlow extends ChangeNotifier {
}
/// Validates and builds the params; throws [FormatException] with a
/// user-facing message.
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async {
/// user-facing message. [avatar] is the base64 picture (the profile's).
/// Hosting puts nothing up: what to watch is picked in the library.
static ConnectParams buildParams(ConnectMode mode, ConnectForm f, {String avatar = ''}) {
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
final port = int.tryParse(rawPort.trim());
if (port == null || port <= 0 || port > 65535) {
throw const FormatException('port must be 1–65535');
}
if (f.name.trim().isEmpty) {
throw const FormatException('set your display name first (click your profile at the top)');
throw const FormatException('set your display name first (click your avatar at the top left)');
}
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
throw const FormatException('enter the server address');
}
String avatar = '';
try {
avatar = await encodeAvatar(f.avatarPath);
} on FormatException catch (e) {
throw FormatException(e.message);
} catch (_) {
throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo');
}
if (mode == ConnectMode.host &&
f.mediaPath.isNotEmpty &&
!await File(f.mediaPath).exists()) {
throw const FormatException('media file not found — check the path or pick again with Browse');
}
return ConnectParams(
host: f.host.trim(),
port: port,
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
name: f.name.trim(),
avatar: avatar,
mediaPath: mode == ConnectMode.host ? f.mediaPath : '',
linkUrl: mode == ConnectMode.host ? f.linkUrl : '',
);
}
/// [edit] fills the card's fields into a copy of the last form, so the
/// other card's saved values survive. [media] (host only) is put up
/// instead of the form's link or file (a Jellyfin ref; the pick itself is
/// remembered in the Jellyfin box, not the form).
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{HelloMedia? media}) async {
/// other card's saved values survive.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
if (busy != null) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
edit(form);
form.name = form.name.trim();
form.host = form.host.trim();
if (mode == ConnectMode.host) {
final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath);
form
..mediaPath = parsed.mediaPath
..linkUrl = parsed.linkUrl;
}
busy = mode;
errors[mode] = null;
notices[mode] = null;
@@ -116,8 +90,9 @@ class ConnectFlow extends ChangeNotifier {
ConnectParams params;
try {
params = await buildParams(mode, form);
if (media != null && mode == ConnectMode.host) params = params.withMedia(media);
final profile = Profile.instance;
await profile.ensureLoaded();
params = buildParams(mode, form, avatar: profile.avatarBase64());
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
@@ -1,175 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'about_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 'top_bar.dart';
import 'update_indicator.dart';
/// 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: HomeShell(windowControls: true));
}
}
/// [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;
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const HomeShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = false,
this.healthGet = defaultHealthGet,
this.jellyfin,
});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
HomeView _view = HomeView.join;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@override
void initState() {
super.initState();
Profile.instance.ensureLoaded();
// Reads linked servers and checks their tokens in the background.
_jellyfin.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 {
final s = AppSettings.instance;
await s.ensureLoaded();
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;
// 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 => IntegrationsPage(jellyfin: _jellyfin),
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),
),
],
),
),
);
}
}
@@ -1,392 +0,0 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../jellyfin/accounts.dart';
import '../../jellyfin/session_media.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, {JellyfinAccounts? jellyfin})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret),
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
// The last Jellyfin pick lives in the Jellyfin box (the connect form
// only knows links and files); restore it once that box is read.
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
this.jellyfin.ensureLoaded().then((_) {
final pick = this.jellyfin.lastPick;
if (_disposed || pick == null || media.jellyfin != null) return;
if (this.jellyfin.account(pick.serverId) == null) return;
if (noMedia) {
media.pickJellyfin(pick);
} else {
final source = media.source;
media.pickJellyfin(pick);
media.source = source;
}
});
}
final JellyfinAccounts jellyfin;
final TextEditingController port;
final TextEditingController secret;
final MediaPickerController media;
/// Jellyfin browser enlarged: the server column shrinks to a bar.
final browserExpanded = ValueNotifier(false);
bool _disposed = false;
void dispose() {
_disposed = true;
port.dispose();
secret.dispose();
media.dispose();
browserExpanded.dispose();
}
}
/// What hosting a picked Jellyfin item sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
/// not linked any more or its sign-in expired.
HelloMedia hostJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
if (accounts.account(media.serverId) == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
}
return mediaForPick(media);
}
/// 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() {
if (flow.busy != null) return;
final media = fields.media.selection;
HelloMedia? jellyfinMedia;
if (media is JellyfinMedia) {
try {
jellyfinMedia = hostJellyfinMedia(fields.jellyfin, media);
} on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return;
}
fields.jellyfin.rememberPick(media);
}
flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media is FileMedia ? media.path : ''
..linkUrl = media is LinkMedia ? media.url : '',
media: jellyfinMedia,
);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
builder: (context, _) {
final busy = flow.busy == ConnectMode.host;
final jellyfin = fields.media.source == MediaKind.jellyfin;
final expanded = jellyfin && fields.browserExpanded.value;
Widget picker({required bool fill}) {
final p = MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
jellyfin: fields.jellyfin,
expanded: expanded,
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
);
return Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
fill ? Expanded(child: p) : p,
],
);
}
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
return LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < hostStackBelow;
// Jellyfin browser: fills the height (lazy grids need a bounded
// viewport); the form sits beside it or, enlarged, in a bar below.
if (expanded) {
return Padding(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(height: 12),
_HostBar(flow: flow, fields: fields, onHost: _host),
],
),
);
}
if (jellyfin && !stacked) {
return Padding(
padding: kPagePadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(width: 24),
SizedBox(
width: _columnWidth,
child: SingleChildScrollView(child: column),
),
],
),
);
}
return SingleChildScrollView(
padding: kPagePadding,
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
picker(fill: false),
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(fill: false)),
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),
],
),
);
}
}
String mediaSummary(PickedMedia? s) =>
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
/// Enlarged-browser mode: what is picked, port, password and Host in one
/// row under the browser.
class _HostBar extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _HostBar({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('host-bar'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(mediaSummary(fields.media.selection),
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
),
const SizedBox(width: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 100,
child: TextField(
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 200,
child: TextField(
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'none = open'),
onSubmitted: (_) => onHost(),
),
),
],
),
),
const SizedBox(width: 12),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
],
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
],
),
);
}
}
/// 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),
),
],
),
);
});
}
}
@@ -1,23 +0,0 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/accounts_panel.dart';
import 'ui.dart';
class IntegrationsPage extends StatelessWidget {
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const IntegrationsPage({super.key, this.jellyfin});
@override
Widget build(BuildContext context) {
return PageScroll(
maxWidth: 820,
children: [
const PageTitle('Integrations',
subtitle: 'Connect media libraries to pick what to watch.'),
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
],
);
}
}
@@ -1,388 +0,0 @@
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)),
],
),
),
],
),
);
}
}
@@ -15,8 +15,8 @@ const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
/// later the session's swap panel) so it survives rebuilds.
/// State of a [MediaPicker], owned by whoever hosts it (the shell, for the
/// Library tab) so it survives rebuilds and tab switches.
class MediaPickerController extends ChangeNotifier {
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
+87 -241
View File
@@ -1,285 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/connect_prefs.dart';
import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart';
import 'ui.dart';
/// Display name + avatar, shared by the Home profile row and Settings.
/// Backed by [SessionState.lastForm]; every change is persisted. The
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
/// before its path is stored, and the encoded bytes are the preview.
/// Colours offered in the profile card; the first is the default.
const profileColors = <Color>[
Colors.deepPurple,
Colors.indigo,
Colors.teal,
Colors.pink,
Colors.orange,
Colors.blueGrey,
];
const _keyPicture = 'avatarPng';
const _keyColor = 'profileColor';
/// Sends a mid-session name/picture change (null = unchanged, empty
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
class Profile {
static final Profile instance = Profile._();
Profile._();
final name = signal('');
final preview = signal<Uint8List?>(null);
/// The cropped PNG; null shows the initial on [color].
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
final picking = signal(false);
ProfilePush push = pushProfileToSession;
bool _loaded = false;
Timer? _nameTimer;
void ensureLoaded() {
/// Reads the stored profile once (the app shell starts it). Old installs
/// stored a picture path: it is shrunk through [encodeAvatar] once and
/// kept as bytes. A caller during the first read does not wait for it
/// (no shared Future: tests run each in its own zone).
Future<void> ensureLoaded() async {
if (_loaded) return;
_loaded = true;
final f = SessionState.instance.lastForm;
name.value = f.name;
_refreshPreview(f.avatarPath);
await _load();
}
Future<void> _refreshPreview(String path) async {
Future<void> _load() async {
final form = SessionState.instance.lastForm;
name.value = form.name;
try {
final b64 = await encodeAvatar(path);
preview.value = b64.isEmpty ? null : base64Decode(b64);
} catch (_) {
preview.value = null;
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = box.get(_keyPicture);
final c = box.get(_keyColor);
if (c is int) color.value = Color(c);
if (png is Uint8List && png.isNotEmpty) {
picture.value = png;
} else if (form.avatarPath.isNotEmpty) {
final b64 = await encodeAvatar(form.avatarPath);
if (b64.isNotEmpty) picture.value = base64Decode(b64);
form.avatarPath = '';
await _save();
}
} catch (e) {
// No store (tests) or an unreadable old picture: start without.
debugPrint('profile load: $e');
}
}
/// The picture as sent in hello and profile updates; '' when none.
String avatarBase64() {
final png = picture.peek();
return png == null ? '' : avatarWireBase64(png);
}
void setName(String value) {
name.value = value.trim();
SessionState.instance.lastForm.name = value.trim();
_save();
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
_nameTimer = Timer(const Duration(milliseconds: 600), () => push(name: name.peek()));
}
Future<void> pickAvatar() async {
if (picking.peek()) return;
picking.value = true;
Future<void> setPicture(Uint8List? png) async {
picture.value = png;
error.value = null;
await _save();
try {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
error.value = 'file dialog unavailable — try again';
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
error.value = e.message;
return;
} catch (_) {
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
return;
}
SessionState.instance.lastForm.avatarPath = path;
preview.value = b64.isEmpty ? null : base64Decode(b64);
await _save();
} finally {
picking.value = false;
push(avatar: png == null ? '' : avatarWireBase64(png));
} on FormatException catch (e) {
error.value = e.message;
}
}
Future<void> clearAvatar() async {
SessionState.instance.lastForm.avatarPath = '';
preview.value = null;
error.value = null;
Future<void> setColor(Color c) async {
color.value = c;
await _save();
}
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
_keyPicture: png ?? Uint8List(0),
_keyColor: color.peek().toARGB32(),
});
} catch (_) {
error.value = 'could not save profile — try again';
}
}
}
/// Avatar circle with the initial as fallback.
class ProfileAvatar extends StatelessWidget {
final double radius;
const ProfileAvatar({super.key, this.radius = 20});
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return SignalBuilder(builder: (context) {
final img = p.preview.value;
final n = p.name.value;
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundImage: img != null ? MemoryImage(img) : null,
child: img != null
? null
: p.picking.value
? SizedBox(
width: radius * 0.8,
height: radius * 0.8,
child: const CircularProgressIndicator(strokeWidth: 2))
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
style: TextStyle(fontSize: radius * 0.8)),
);
});
}
}
/// Avatar (tap to change) + name field. Used inline in Settings and in
/// the Home profile dialog.
class ProfileEditor extends StatefulWidget {
final bool autofocus;
final VoidCallback? onSubmitted;
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
@override
State<ProfileEditor> createState() => _ProfileEditorState();
}
class _ProfileEditorState extends State<ProfileEditor> {
late final _name = TextEditingController(text: Profile.instance.name.peek());
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final p = Profile.instance;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Tooltip(
message: 'Change avatar',
child: InkWell(
customBorder: const CircleBorder(),
onTap: p.pickAvatar,
child: const ProfileAvatar(radius: 36),
),
),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _name,
autofocus: widget.autofocus,
decoration: fieldDecoration('Display name'),
onChanged: p.setName,
onSubmitted: (_) => widget.onSubmitted?.call(),
),
const SizedBox(height: 4),
SignalBuilder(builder: (context) {
final err = p.error.value;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
TextButton(
onPressed: p.pickAvatar,
child: const Text('Change avatar')),
if (p.preview.value != null)
TextButton(
onPressed: p.clearAvatar,
child: const Text('Remove avatar')),
if (err != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(err,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.error)),
),
],
);
}),
],
),
),
],
);
}
}
Future<void> showProfileDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
title: const Text('Profile'),
content: SizedBox(
width: 380,
child: ProfileEditor(
autofocus: true,
onSubmitted: () => Navigator.pop(context),
),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context), child: const Text('Done')),
],
),
);
}
/// Home's top-left: avatar + name, click to edit.
class ProfileChip extends StatelessWidget {
/// 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) {
final p = Profile.instance;
return Tooltip(
message: 'Edit profile',
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: () => showProfileDialog(context),
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: [
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)),
],
);
}),
],
),
),
),
);
}
}
@@ -6,57 +6,31 @@ import '../../playback/playback_controller.dart';
import '../../shaders/shader_presets.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import 'profile.dart';
import 'ui.dart';
import 'update_indicator.dart';
/// In-session keys, as handled by the session screen's global key handler.
/// Player keys, as handled by the session player's key handler (while
/// the player fills the window).
const sessionShortcuts = [
('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'),
('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'),
('Tab', 'Open / close chat & activity'),
('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'),
('< or Shift+P', 'Put up the previous Jellyfin episode'),
('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'),
('Esc', 'Close panel or fullscreen, else ask to leave'),
('Esc', 'Close the panel or fullscreen, else ask to leave'),
];
class SettingsPage extends StatelessWidget {
final UpdateController? updates;
const SettingsPage({super.key, this.updates});
@override
Widget build(BuildContext context) {
return PageScroll(
maxWidth: 680,
children: [
const PageTitle('Settings'),
const _Section(title: 'Profile', child: ProfileEditor()),
const _Section(
title: 'Player',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_HwdecSetting(), SizedBox(height: 12), _MpvControlsSetting()],
),
),
const _Section(title: 'Jellyfin', child: _AutoAdvanceSetting()),
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
const _Section(title: 'Video quality & shaders', child: _ShaderSettings()),
],
);
}
}
class _Section extends StatelessWidget {
/// One titled panel of the Settings tab.
class SettingsPanel extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
const SettingsPanel({super.key, required this.title, required this.child});
@override
Widget build(BuildContext context) {
@@ -72,14 +46,14 @@ class _Section extends StatelessWidget {
}
}
class _HwdecSetting extends StatefulWidget {
const _HwdecSetting();
class HwdecSetting extends StatefulWidget {
const HwdecSetting({super.key});
@override
State<_HwdecSetting> createState() => _HwdecSettingState();
State<HwdecSetting> createState() => _HwdecSettingState();
}
class _HwdecSettingState extends State<_HwdecSetting> {
class _HwdecSettingState extends State<HwdecSetting> {
final _s = AppSettings.instance;
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
@@ -130,8 +104,8 @@ class _HwdecSettingState extends State<_HwdecSetting> {
}
}
class _ShaderSettings extends StatelessWidget {
const _ShaderSettings();
class ShaderSettings extends StatelessWidget {
const ShaderSettings({super.key});
Future<void> _addFiles() async {
final List<PlatformFile> files;
@@ -249,9 +223,9 @@ class _ShaderSettings extends StatelessWidget {
}
}
class _UpdateSettings extends StatelessWidget {
class UpdateSettings extends StatelessWidget {
final UpdateController? controller;
const _UpdateSettings({this.controller});
const UpdateSettings({super.key, this.controller});
@override
Widget build(BuildContext context) {
@@ -301,8 +275,8 @@ class _UpdateSettings extends StatelessWidget {
}
}
class _MpvControlsSetting extends StatelessWidget {
const _MpvControlsSetting();
class MpvControlsSetting extends StatelessWidget {
const MpvControlsSetting({super.key});
@override
Widget build(BuildContext context) {
@@ -321,8 +295,8 @@ class _MpvControlsSetting extends StatelessWidget {
}
}
class _AutoAdvanceSetting extends StatelessWidget {
const _AutoAdvanceSetting();
class AutoAdvanceSetting extends StatelessWidget {
const AutoAdvanceSetting({super.key});
@override
Widget build(BuildContext context) {
@@ -341,8 +315,8 @@ class _AutoAdvanceSetting extends StatelessWidget {
}
}
class _ShortcutList extends StatelessWidget {
const _ShortcutList();
class ShortcutList extends StatelessWidget {
const ShortcutList({super.key});
@override
Widget build(BuildContext context) {
@@ -1,160 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../update/update_controller.dart';
import '../../widgets/window_buttons.dart';
import '../demo/shell_demo.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),
if (kDebugMode)
IconButton(
tooltip: 'Layout demo (debug builds only)',
icon: const Icon(Icons.bug_report_outlined, size: 20, color: Colors.amber),
onPressed: () => Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => const ShellDemo())),
),
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)),
],
),
),
),
);
}
}
+2 -55
View File
@@ -7,9 +7,8 @@ const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
const kRadius = 12.0;
const kGap = 16.0;
const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28);
/// Rounded panel used by every Home/Settings section.
/// Rounded panel used by every Settings section.
class Panel extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
@@ -38,33 +37,6 @@ class Panel extends StatelessWidget {
}
}
/// Page heading + optional subtitle, same spacing everywhere.
class PageTitle extends StatelessWidget {
final String title;
final String? subtitle;
const PageTitle(this.title, {super.key, this.subtitle});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: kGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: t.headlineSmall),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(subtitle!,
style: t.bodyMedium?.copyWith(color: Colors.grey)),
),
],
),
);
}
}
/// Small grey heading inside a panel.
class SectionLabel extends StatelessWidget {
final String text;
@@ -87,7 +59,7 @@ class SectionLabel extends StatelessWidget {
}
}
/// Text field style shared by the connect cards.
/// Outlined text field style (settings, Jellyfin sign-in).
InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
InputDecoration(
labelText: label,
@@ -97,31 +69,6 @@ InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
);
/// Single-item scrollable page body, width-capped so it doesn't sprawl
/// on wide windows.
class PageScroll extends StatelessWidget {
final List<Widget> children;
final double maxWidth;
const PageScroll({super.key, required this.children, this.maxWidth = 920});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: kPagePadding,
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
);
}
}
/// Widest a single text field gets, however wide the window.
const kFieldMaxWidth = 720.0;
@@ -4,7 +4,7 @@ import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart';
import 'ui.dart';
/// Compact "Update vX" pill for Home's top-right; hidden until there is
/// Compact "Update vX" pill for the party strip; hidden until there is
/// something to install. Click opens the full [UpdateBanner].
class UpdateIndicator extends StatelessWidget {
final UpdateController? controller;
@@ -4,7 +4,7 @@ import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import 'player_controls.dart' show FloatingPill;
// Jellyfin pieces of the session screen. Plain widgets fed by the
// Jellyfin pieces of the session player. Plain widgets fed by the
// coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is
@@ -4,28 +4,34 @@ import '../../jellyfin/accounts.dart';
import '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../home/media_picker.dart';
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab
/// (Jellyfin browser, Link, File) and a button that puts the pick up for
/// everyone. A file streams from this computer: the host's app serves it,
/// anyone else's goes through the server's relay. [onSwap] returns an
/// error text, or null when sent.
/// The Library tab's body: the [MediaPicker] (Jellyfin browser, Link,
/// File) and a button that puts the pick up for everyone. A file streams from this computer: the host's app
/// serves it, anyone else's goes through the server's relay. [onSwap]
/// returns an error text, or null when sent.
class SwapPanel extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool isHost;
final BrowseStart? jellyfinStart;
final Future<String?> Function(PickedMedia media) onSwap;
final VoidCallback onClose;
/// After a pick went up.
final VoidCallback? onDone;
final VoidCallback? onOpenIntegrations;
/// Set when nothing can go up (offline): shown instead of the pick, and
/// the button is off.
final String? disabledHint;
const SwapPanel({
super.key,
required this.controller,
required this.accounts,
required this.isHost,
required this.onSwap,
required this.onClose,
this.onDone,
this.jellyfinStart,
this.onOpenIntegrations,
this.disabledHint,
});
@override
@@ -38,7 +44,7 @@ class _SwapPanelState extends State<SwapPanel> {
Future<void> _swap() async {
final media = widget.controller.selection;
if (media == null || _busy) return;
if (media == null || _busy || widget.disabledHint != null) return;
setState(() {
_busy = true;
_error = null;
@@ -54,79 +60,79 @@ class _SwapPanelState extends State<SwapPanel> {
_busy = false;
_error = error;
});
if (error == null) widget.onClose();
if (error == null) widget.onDone?.call();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
final hint = widget.disabledHint;
return Padding(
key: const ValueKey('swap-panel'),
elevation: 12,
borderRadius: BorderRadius.circular(16),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: Text('Change video', style: t.titleLarge)),
IconButton(
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
],
),
const SizedBox(height: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
linkRelayChoice: true,
fileHelp: widget.isHost
? 'Friends stream it from your computer.'
: 'Streams from your computer through the server. Keep the app open while it plays.',
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
padding: const EdgeInsets.fromLTRB(20, 16, 12, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
linkRelayChoice: true,
fileHelp: widget.isHost
? 'Friends stream it from your computer.'
: 'Streams from your computer through the server. Keep the app open while it plays.',
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
),
const SizedBox(height: 12),
ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final sel = widget.controller.selection;
return Row(
children: [
Expanded(
child: Text(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null ? Theme.of(context).colorScheme.error : null),
),
const SizedBox(height: 12),
ListenableBuilder(
listenable: widget.controller,
builder: (context, _) {
final sel = widget.controller.selection;
return Row(
children: [
Expanded(
child: Text(
_error ??
hint ??
(sel == null
? 'Pick something to watch'
: 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null
? Theme.of(context).colorScheme.error
: null,
),
),
const SizedBox(width: 12),
FilledButton.icon(
key: const ValueKey('swap-go'),
onPressed: sel == null || _busy ? null : _swap,
icon: _busy
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
],
);
},
),
],
),
),
const SizedBox(width: 12),
FilledButton.icon(
key: const ValueKey('swap-go'),
onPressed: sel == null || _busy || hint != null
? null
: _swap,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
],
);
},
),
],
),
);
}
File diff suppressed because it is too large. Load diff
@@ -14,6 +14,7 @@ import '../playback/playback_controller.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
import '../state/settings.dart';
import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart';
import '../sync/sync_client.dart';
import '../sync/user_seek.dart';
@@ -140,6 +141,8 @@ class SessionCoordinator {
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
final PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff();
@@ -953,6 +956,26 @@ class SessionCoordinator {
sync.sendChat(text);
}
/// Name and/or picture (base64 PNG, empty removes it) change for
/// everyone now; a later fresh join sends them too.
void updateProfile({String? name, String? avatar}) {
final p = _params;
if (p != null) {
_params = ConnectParams(
host: p.host,
port: p.port,
secret: p.secret,
name: name ?? p.name,
avatar: avatar ?? p.avatar,
mediaPath: p.mediaPath,
linkUrl: p.linkUrl,
hostKey: p.hostKey,
media: p.media,
);
}
sync.sendProfile(name: name, avatar: avatar);
}
void kick(String memberId, {String reason = ''}) {
markInput();
sync.sendKick(memberId, reason: reason);
@@ -1060,6 +1083,10 @@ class SessionCoordinator {
// ------------------------------------------------------------- jellyfin
/// A toast from outside the session (e.g. the Schedule tab's request
/// results), shown like the session's own.
void showToast(String text) => _pushToast(text);
void _pushToast(String text) {
_toastId++;
toasts.value = [
+620
View File
@@ -0,0 +1,620 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../screens/home/profile.dart';
import '../screens/home/update_indicator.dart';
import '../state/session_state.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
import 'profile_card.dart';
import '../schedule/ui/schedule_page.dart';
import 'session_player.dart';
import 'settings_tab.dart';
import 'toasts.dart';
/// The one window: the party strip over a body that fades through between
/// the tabs and the full player, the floating mini player, the chat panel
/// and the toasts. Online/offline follows [SessionState.route].
///
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
/// the body: full it fills the body (fullscreen: the window, strip
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
/// fades the layer out, moves it while invisible and fades it back in, so
/// the video is never mounted twice nor remounted.
class AppShell extends StatefulWidget {
final UpdateController? updates;
final bool autoUpdateCheck;
/// Off in tests: the drag area, window buttons and fullscreen need
/// window_manager.
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Stand-in for media_kit's `Video` (tests).
final VideoViewBuilder? videoBuilder;
const AppShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = true,
this.healthGet = defaultHealthGet,
this.jellyfin,
this.videoBuilder,
});
@override
State<AppShell> createState() => AppShellState();
}
class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
static const miniSize = Size(384, 216);
static const _miniMargin = 16.0;
ShellTab _tab = ShellTab.library;
SettingsSection _section = SettingsSection.profile;
/// Where the player is going ([_mode]) and where it is drawn ([_shown]):
/// they differ for the first part of a change, while it fades out.
PlayerMode _mode = PlayerMode.none;
PlayerMode _shown = PlayerMode.none;
PlayerMode _from = PlayerMode.none;
int _modeSeq = 0;
late final AnimationController _fade = AnimationController(vsync: this, value: 1);
bool _fullscreen = false;
bool _chatOpen = false;
int _unread = 0;
int _feedSeen = 0;
final _chat = TextEditingController();
final _chatFocus = FocusNode();
final _playerKey = GlobalKey();
Alignment _corner = Alignment.bottomRight;
Offset? _drag; // mini top-left while dragging
bool _snapping = false;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late MediaPickerController _picker = _newPicker();
BrowseStart? _libraryStart;
int _libraryGen = 0;
/// The next media to come up opens full (just joined, or put up here).
bool _autoFull = false;
int _lastMediaId = -1;
double? _mutedVolume;
bool _dialogOpen = false;
String? _lastKickShown;
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
PlayerMode get mode => _mode;
PlayerMode get shownMode => _shown;
bool get fullscreen => _fullscreen;
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
final f = SessionState.instance.lastForm;
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
return c;
}
@override
void initState() {
super.initState();
unawaited(Profile.instance.ensureLoaded());
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKicked;
final session = SessionState.instance;
final coord = session.coordinator;
_feedSeen = coord.feedLog.peek().length;
_stops.add(effect(() {
final online = session.route.value == AppRoute.session;
// Set now, not later: the media effect below may run first in this
// same flush, and its scheduled show reads these.
_autoFull = online;
if (!online) _lastMediaId = -1;
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
}));
// Something comes up while the player is closed: show it (full right
// after joining or a put-up of ours, else as the mini player).
_stops.add(effect(() {
final r = coord.serverState.value;
final online = session.route.value == AppRoute.session;
if (!online || r == null || r.media == null) return;
if (r.mediaId == _lastMediaId) return;
_lastMediaId = r.mediaId;
_later(() {
if (_mode == PlayerMode.none) {
_show(_autoFull ? PlayerMode.full : PlayerMode.mini);
} else if (_autoFull) {
_show(PlayerMode.full);
}
_autoFull = false;
});
}));
// Chat that arrives while the panel is closed.
_stops.add(effect(() {
final log = coord.feedLog.value;
final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen);
_feedSeen = log.length;
final chats = fresh.where((f) => f.action == 'chat').length;
if (chats == 0) return;
untracked(() => _later(() {
if (!_chatOpen) setState(() => _unread += chats);
}));
}));
// Kicked: back offline, and say so.
_stops.add(effect(() {
final kick = coord.kickReason.value;
if (kick == null) {
_lastKickShown = null;
return;
}
untracked(() {
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
});
if (kick.isNotEmpty) _later(() => _showKicked(kick));
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
final crash = session.serverCrash.value;
if (crash == null) return;
_later(() {
session.serverCrash.value = null;
_alert('Server crashed', crash);
});
}));
}
@override
void dispose() {
for (final stop in _stops) {
stop();
}
_fade.dispose();
_online.dispose();
_hasMedia.dispose();
_flow.dispose();
_join.dispose();
_host.dispose();
_picker.dispose();
_chat.dispose();
_chatFocus.dispose();
if (_fullscreen) _setWindowFullscreen(false);
super.dispose();
}
/// Runs [f] after the current signal notification / build.
void _later(VoidCallback f) => scheduleMicrotask(() {
if (mounted) f();
});
Future<void> _autoCheck() async {
final s = AppSettings.instance;
await s.ensureLoaded();
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
}
void _wentOnline() {
_unread = 0;
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
// Hosting starts with nothing up: the library is where to pick it.
setState(() => _tab = ShellTab.library);
}
void _wentOffline() {
_mutedVolume = null;
if (_fullscreen) _toggleFullscreen();
setState(() {
_chatOpen = false;
_unread = 0;
});
_show(PlayerMode.none);
}
// ----------------------------------------------------------- player mode
/// Fade through to [m]: the player layer (and, when full is involved,
/// the tabs) fades out, moves while invisible, then fades in.
Future<void> _show(PlayerMode m) async {
if (m == _mode) return;
final seq = ++_modeSeq;
if (m != PlayerMode.none) _unmute();
setState(() {
_from = _shown;
_mode = m;
});
if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus();
if (_shown != PlayerMode.none || m == PlayerMode.full) {
await _fade.animateTo(0,
duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn);
} else {
_fade.value = 0;
}
if (!mounted || seq != _modeSeq) return;
setState(() => _shown = m);
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
}
void _expand() => _show(PlayerMode.full);
void _collapse() {
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.mini);
}
/// Mini's ×: stop watching but stay in the server (muted until shown).
void _closePlayer() {
final playback = SessionState.instance.coordinator.playback;
if (playback.alive.peek()) {
try {
_mutedVolume = playback.player.state.volume;
playback.player.setVolume(0);
} catch (_) {}
}
_show(PlayerMode.none);
}
void _unmute() {
final v = _mutedVolume;
if (v == null) return;
_mutedVolume = null;
final playback = SessionState.instance.coordinator.playback;
if (!playback.alive.peek()) return;
try {
playback.player.setVolume(v);
} catch (_) {}
}
Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return;
try {
await windowManager.setFullScreen(on);
} catch (_) {
// No window manager.
}
}
void _toggleFullscreen() {
final on = !_fullscreen;
setState(() => _fullscreen = on);
if (on && _mode != PlayerMode.full) _show(PlayerMode.full);
_setWindowFullscreen(on);
}
void _selectTab(ShellTab t) {
if (_fullscreen) _toggleFullscreen();
setState(() => _tab = t);
if (_mode == PlayerMode.full) _show(PlayerMode.mini);
}
void _openSettings(SettingsSection s) {
setState(() => _section = s);
_selectTab(ShellTab.settings);
}
void _toggleChat() => setState(() {
_chatOpen = !_chatOpen;
_unread = 0;
if (!_chatOpen) _chatFocus.unfocus();
});
// --------------------------------------------------------------- library
/// "Change video": the library, opened on the series of the episode
/// playing (season selected, episode highlighted), else where it was.
void _changeVideo() {
final coord = SessionState.instance.coordinator;
coord.markInput();
final ref = coord.currentRef.peek();
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
final plan = coord.jellyfinPlan.peek();
final old = _picker;
_picker = pick == null
? _newPicker()
: _newPicker(jellyfin: pick, source: MediaKind.jellyfin);
old.dispose();
_libraryStart = ref != null && plan is OwnPlay && plan.item != null
? BrowseStart.seriesOf(ref.serverId, plan.item!)
: null;
_libraryGen++;
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full.
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
if (media is JellyfinMedia) {
try {
putUpJellyfinMedia(_jellyfin, media);
} on FormatException catch (e) {
return e.message;
}
unawaited(_jellyfin.rememberPick(media));
}
final error = await session.coordinator.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
f
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
unawaited(session.saveForm().catchError((Object _) {}));
_autoFull = true;
_show(PlayerMode.full);
return null;
}
// --------------------------------------------------------------- dialogs
Future<void> _confirmLeave() async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave server?'),
content: const Text('You can rejoin right away.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(
key: const ValueKey('confirm-leave'),
onPressed: () => Navigator.pop(context, true),
child: const Text('Leave'),
),
],
),
);
if (ok == true) await SessionState.instance.leave();
}
Future<void> _showKicked(String reason) async {
if (reason == _lastKickShown) return;
_lastKickShown = reason;
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
}
Future<void> _alert(String title, String text) async {
if (!mounted || _dialogOpen) return;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(text),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
void _connect(ConnectMode mode) => showConnectCard(
context,
mode: mode,
flow: _flow,
join: _join,
host: _host,
healthGet: widget.healthGet,
);
// ----------------------------------------------------------------- build
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
},
child: Scaffold(
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
body: SignalBuilder(builder: (context) {
final online = _online.value;
final hasMedia = _hasMedia.value;
return Column(
children: [
if (!_fullscreen)
PartyStrip(
key: const ValueKey('strip'),
tab: _tab,
onTab: _selectTab,
chatOpen: _chatOpen,
unread: _unread,
onChat: _toggleChat,
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onLeave: _confirmLeave,
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
updates: widget.updates,
windowControls: widget.windowControls,
),
Expanded(
key: const ValueKey('body'),
child: Row(
children: [
Expanded(child: _body(online)),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.centerLeft,
child: _chatOpen && online
? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat)
: const SizedBox(height: double.infinity),
),
],
),
),
],
);
}),
),
);
}
Widget _body(bool online) {
final coord = SessionState.instance.coordinator;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
online: online,
isHost: coord.hostFile != null,
jellyfinStart: _libraryStart,
onPutUp: _putUp,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
),
ShellTab.schedule => SchedulePage(
online: online,
onPutUp: _putUp,
onToast: coord.showToast,
),
ShellTab.settings => SettingsTab(
section: _section,
onSection: (s) => setState(() => _section = s),
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
updates: widget.updates,
jellyfin: _jellyfin,
),
};
final full = _shown == PlayerMode.full;
final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full;
return LayoutBuilder(builder: (context, box) {
final area = box.biggest;
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: _fade,
builder: (context, child) {
// The tabs take part in the fade only when the full player
// comes or goes; a mini player appearing leaves them be.
final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0);
return Opacity(
opacity: v,
child: Transform.scale(scale: 0.985 + 0.015 * v, child: child),
);
},
child: IgnorePointer(
ignoring: full,
child: ExcludeFocus(
excluding: full,
child: TickerMode(
enabled: !full,
child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)),
),
),
),
),
),
if (online && _shown != PlayerMode.none) _playerLayer(area),
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
],
);
});
}
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
);
Widget _playerLayer(Size area) {
final mini = _shown == PlayerMode.mini;
final size = mini
? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height))
: area;
final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero;
return AnimatedPositioned(
key: const ValueKey('player-layer'),
duration: _snapping ? easeTime : Duration.zero,
curve: ease,
left: pos.dx,
top: pos.dy,
width: size.width,
height: size.height,
onEnd: () => _snapping = false,
child: AnimatedBuilder(
animation: _fade,
builder: (context, child) {
final v = _fade.value;
return IgnorePointer(
ignoring: v < 0.5 || _shown != _mode,
child: Opacity(
opacity: v,
child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child),
),
);
},
// Same widgets in both modes (only their settings change), so the
// player below never moves in the element tree.
child: GestureDetector(
onPanStart: mini ? (_) => setState(() => _drag = pos) : null,
onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null,
onPanEnd: mini
? (_) => setState(() {
final c = _drag! + Offset(size.width / 2, size.height / 2);
_corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1);
_drag = null;
_snapping = true;
})
: null,
child: DoubleClickListener(
onDoubleClick: mini ? _expand : () {},
skipTop: 40,
skipBottom: 60,
child: Material(
elevation: mini ? 16 : 0,
color: Colors.black,
borderRadius: BorderRadius.circular(mini ? 14 : 0),
clipBehavior: Clip.antiAlias,
child: KeyedSubtree(
key: _playerKey,
child: SessionPlayer(
mode: _shown,
fullscreen: _fullscreen,
chatOpen: _chatOpen,
unread: _unread,
onExpand: _expand,
onCollapse: _collapse,
onClose: _closePlayer,
onToggleFullscreen: _toggleFullscreen,
onToggleChat: _toggleChat,
onChangeVideo: _changeVideo,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
onLeave: _confirmLeave,
videoBuilder: widget.videoBuilder,
),
),
),
),
),
),
);
}
}
+321
View File
@@ -0,0 +1,321 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/profile.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// What an avatar shows: who, their picture or initial on [color], and
/// the status the ring and badges carry.
class AvatarInfo {
final String id;
final String name;
final Color color;
final ImageProvider? image;
final bool ready, buffering, relaying, stale, host, self;
const AvatarInfo({
this.id = '',
required this.name,
required this.color,
this.image,
this.ready = false,
this.buffering = false,
this.relaying = false,
this.stale = false,
this.host = false,
this.self = false,
});
}
/// Stable colour for someone else (colours are not on the wire).
Color colorForName(String name) {
const palette = [
Colors.teal,
Colors.orange,
Colors.pink,
Colors.blue,
Colors.green,
Colors.indigo,
Colors.amber,
Colors.cyan,
Colors.red,
Colors.deepPurple,
];
var h = 0;
for (final c in name.codeUnits) {
h = (h * 31 + c) & 0x7fffffff;
}
return palette[h % palette.length];
}
final _images = <Object, MemoryImage>{};
MemoryImage _memo(Object key, Uint8List bytes) {
final hit = _images[key];
if (hit != null && identical(hit.bytes, bytes)) return hit;
if (_images.length > 64) _images.clear();
return _images[key] = MemoryImage(bytes);
}
/// The one lookup for a member's picture: your own from the profile (no
/// round trip), anyone else's from the session's avatar cache by hash.
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
/// (see [PartyWatch]).
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
if (m.id.isNotEmpty && m.id == coord.sync.memberId) {
final own = Profile.instance.picture.value;
if (own != null) return _memo('self', own);
}
final bytes = coord.avatars.bytesFor(m.avatarHash);
return bytes == null ? null : _memo(m.avatarHash, bytes);
}
/// Your own avatar from the profile (also offline).
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
final p = Profile.instance;
final png = p.picture.value;
final name = p.name.value;
return AvatarInfo(
name: name.isEmpty ? '?' : name,
color: p.color.value,
image: png == null ? null : _memo('self', png),
ready: ready,
host: host,
self: true,
);
}
/// [m] as the party strip, toasts and the chat show it.
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
final relay = r == null ? null : stateMediaRelay(r);
final playing = r?.media != null;
return AvatarInfo(
id: m.id,
name: m.name,
color: self ? Profile.instance.color.value : colorForName(m.name),
image: memberAvatarImage(m, coord),
ready: m.ready,
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
stale: m.stale,
// Only your own app knows it hosts: the state carries no host flag.
host: self && coord.hostFile != null,
self: self,
);
}
/// Everyone in the server, from the latest state.
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value;
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
}
/// Rebuilds [builder] when the party state, your profile or a member's
/// picture changes.
class PartyWatch extends StatelessWidget {
final WidgetBuilder builder;
const PartyWatch({super.key, required this.builder});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: SessionState.instance.coordinator.avatars,
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
);
}
/// Circle avatar with a status ring outside it and a gap in the background
/// colour, so overlapping neighbours never hide the status. Crown = host,
/// tower = passing the video on, spinner = buffering, dimmed = stale.
class StatusAvatar extends StatelessWidget {
final AvatarInfo info;
final double size;
/// Host crown and relay tower; off where the context already says it.
final bool badges;
/// Ring colour when not ready (the profile shows none at all).
final bool ring;
const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true});
@override
Widget build(BuildContext context) {
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
final ringColor = !ring
? bg
: info.stale
? Colors.grey.shade700
: info.buffering
? Colors.amber
: info.ready
? Colors.greenAccent.shade400
: Colors.grey;
final badge = size * 0.42;
// Ring and gap scale with the avatar (2 px at strip size).
final stroke = (size * 0.055).clamp(2.0, 4.0);
final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase();
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
// Fills the box: a non-positioned Stack child only gets loose
// constraints, and CircleAvatar then falls back to 40 px.
Positioned.fill(
child: Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor),
padding: EdgeInsets.all(stroke),
child: Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
padding: EdgeInsets.all(stroke),
child: Opacity(
opacity: info.stale ? 0.4 : 1,
child: CircleAvatar(
radius: size / 2,
backgroundColor: info.color,
backgroundImage: info.image,
child: info.image != null
? null
: Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)),
),
),
),
),
),
if (info.buffering)
Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)),
if (badges && info.host)
Positioned(
left: -4,
top: -6,
child: Transform.rotate(
angle: -0.4,
child: Icon(
Icons.workspace_premium,
size: badge,
color: Colors.amber,
shadows: const [Shadow(blurRadius: 3, color: Colors.black)],
),
),
),
if (badges && info.relaying)
Positioned(
right: -3,
bottom: -3,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent),
),
),
],
),
);
}
}
/// Overlapping avatars, host first then ready (the stack itself reads as
/// progress); they spread apart on hover. "+N" past [shown].
class AvatarStack extends StatefulWidget {
final List<AvatarInfo> members;
final VoidCallback? onTap;
final int shown;
final double size;
const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42});
@override
State<AvatarStack> createState() => _AvatarStackState();
}
class _AvatarStackState extends State<AvatarStack> {
bool _hover = false;
@override
Widget build(BuildContext context) {
final size = widget.size;
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
final visible = sorted.take(widget.shown).toList();
final extra = sorted.length - visible.length;
final step = _hover ? size + 6 : size * 0.62;
final count = visible.length + (extra > 0 ? 1 : 0);
final width = count == 0 ? 0.0 : step * (count - 1) + size;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
key: const ValueKey('avatar-stack'),
duration: easeTime,
curve: ease,
width: width,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = visible.length - 1; i >= 0; i--)
AnimatedPositioned(
key: ValueKey('stack-${visible[i].id}-${visible[i].name}'),
duration: easeTime,
curve: ease,
left: i * step,
top: 0,
child: Tooltip(
message: visible[i].name,
child: StatusAvatar(info: visible[i], size: size),
),
),
if (extra > 0)
AnimatedPositioned(
duration: easeTime,
curve: ease,
left: visible.length * step,
top: 0,
child: CircleAvatar(
radius: size / 2,
child: Text('+$extra', style: const TextStyle(fontSize: 12)),
),
),
],
),
),
),
);
}
}
/// Small coloured label ("host", "passing the video on").
class Pill extends StatelessWidget {
final String text;
final Color color;
const Pill(this.text, this.color, {super.key});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
);
}
/// Someone by display name (feed lines carry names, not ids): from the
/// latest state when still there, else their initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
final r = c.serverState.value;
for (final m in r?.members ?? const <MemberView>[]) {
if (m.name == name) return memberAvatarInfo(m, r, c);
}
return AvatarInfo(name: name, color: colorForName(name));
}
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import 'avatar.dart';
/// Side panel: chat with avatars, other activity as quiet lines, and the
/// message field. Width is fixed; the shell slides it in and out.
class ChatPanel extends StatelessWidget {
static const width = 340.0;
final TextEditingController chat;
final FocusNode chatFocus;
final VoidCallback onClose;
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
void _send() {
SessionState.instance.coordinator.sendChat(chat.text);
chat.clear();
chatFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey('chat-panel'),
width: width,
color: scheme.surfaceContainerLow,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
child: Row(
children: [
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
],
),
),
Expanded(
child: PartyWatch(builder: (context) {
final log = SessionState.instance.coordinator.feedLog.value;
if (log.isEmpty) {
return Center(
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: log.length,
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
);
}),
),
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
key: const ValueKey('chat-field'),
controller: chat,
focusNode: chatFocus,
onSubmitted: (_) => _send(),
decoration: InputDecoration(
hintText: 'Message (Enter)',
isDense: true,
filled: true,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
),
),
),
],
),
);
}
}
/// One feed line: a chat message with the sender's avatar, anything else
/// as an icon and "who did what".
class ActivityRow extends StatelessWidget {
final FeedMessage item;
const ActivityRow({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final time = _clock(item.at);
if (item.action == 'chat') {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(item.actor,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
),
const SizedBox(width: 6),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
SelectableText(item.reason),
],
),
),
],
),
);
}
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
final line = item.actor == 'server' ? text : '${item.actor} $text';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
const SizedBox(width: 8),
Icon(icon, size: 14, color: color),
const SizedBox(width: 8),
Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))),
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
],
),
);
}
}
/// Icon, colour and wording of a member's feed action.
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
switch (f.action) {
case 'join':
return (Icons.login, Colors.green, 'joined');
case 'leave':
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
return (Icons.logout, Colors.grey, 'left$reason');
case 'ready':
return (Icons.check_circle, Colors.green, 'is ready');
case 'unready':
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
case 'force-ready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
case 'force-unready':
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
case 'force-play':
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
case 'force-hold':
return (Icons.pause, Colors.orange, 'held playback for everyone');
case 'seek':
return (Icons.fast_forward, scheme.primary, 'seeked');
case 'swap':
return (Icons.swap_horiz, scheme.primary, 'changed the media');
case 'stale':
return (Icons.cloud_off, Colors.orange, 'lost signal');
case 'kick':
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
default:
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
return (Icons.info_outline, Colors.grey, '${f.action}$target');
}
}
/// The server's own lines (pause / resume).
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
};
String _clock(DateTime at) {
final l = at.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${two(l.hour)}:${two(l.minute)}';
}
@@ -0,0 +1,489 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.dart';
import '../screens/home/health_probe.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// Join card text, owned by the shell so it survives the card closing.
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();
}
}
/// Host card text, owned by the shell so it survives the card closing.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret);
final TextEditingController port;
final TextEditingController secret;
void dispose() {
port.dispose();
secret.dispose();
}
}
/// The strip's Join / Host buttons: a card over the window. It closes
/// itself once the attempt got in; a failed one shows its error inside.
Future<void> showConnectCard(
BuildContext context, {
required ConnectMode mode,
required ConnectFlow flow,
required JoinFields join,
required HostFields host,
HealthGetter healthGet = defaultHealthGet,
}) =>
showPopover<void>(
context,
dim: true,
at: const Alignment(0.8, -0.75),
child: _CloseWhenOnline(
child: mode == ConnectMode.host
? HostCard(flow: flow, fields: host)
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
),
);
class _CloseWhenOnline extends StatefulWidget {
final Widget child;
const _CloseWhenOnline({required this.child});
@override
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
}
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
late final void Function() _stop;
@override
void initState() {
super.initState();
_stop = effect(() {
if (SessionState.instance.route.value != AppRoute.session) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
});
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Card frame shared by Join and Host.
class _Card extends StatelessWidget {
final String title;
final List<Widget> children;
const _Card({required this.title, required this.children});
@override
Widget build(BuildContext context) => Material(
elevation: 12,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 360,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...children,
],
),
),
),
);
}
const _dense = InputDecoration(isDense: true);
/// Recent servers as one-click rows (with live status), then address and
/// password.
class JoinCard extends StatelessWidget {
final ConnectFlow flow;
final JoinFields fields;
final HealthGetter healthGet;
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
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) {
return ListenableBuilder(
listenable: flow,
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
return _Card(
title: 'Join a server',
children: [
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 Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Recent', style: TextStyle(color: Colors.grey)),
RecentServerRows(
servers: list,
enabled: flow.busy == null,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
),
const Divider(),
],
);
}),
AbsorbPointer(
absorbing: busy,
child: Column(
key: const ValueKey('join-row'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('join-address'),
controller: fields.host,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
onSubmitted: (_) => _join(),
),
),
const SizedBox(width: 12),
SizedBox(
width: 72,
child: TextField(
key: const ValueKey('join-port'),
controller: fields.port,
decoration: _dense.copyWith(labelText: 'Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _join(),
),
),
],
),
const SizedBox(height: 8),
TextField(
key: const ValueKey('join-password'),
controller: fields.secret,
obscureText: true,
decoration: _dense.copyWith(labelText: 'Password (optional)'),
onSubmitted: (_) => _join(),
),
],
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
],
);
},
);
}
}
/// Port and password; what to watch is picked afterwards, in the library.
class HostCard extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
const HostCard({super.key, required this.flow, required this.fields});
void _host() => flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text,
);
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: 'Host a server',
children: [
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('host-port'),
controller: fields.port,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => _host(),
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: fields.secret,
builder: (context, _) => TextField(
key: const ValueKey('host-password'),
controller: fields.secret,
obscureText: true,
decoration: _dense.copyWith(
labelText: 'Password (optional)',
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
),
onSubmitted: (_) => _host(),
),
),
_HostedRecents(fields: fields, enabled: flow.busy == null),
],
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'You pick what to watch afterwards, in the library.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}
/// 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: 12),
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),
),
],
),
);
});
}
}
/// Recent servers as rows; polls each one's `/health` every
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
class RecentServerRows extends StatefulWidget {
final List<RecentServer> servers;
final bool enabled;
final HealthGetter healthGet;
final ValueChanged<RecentServer> onJoin;
final ValueChanged<RecentServer> onForget;
const RecentServerRows({
super.key,
required this.servers,
required this.enabled,
required this.healthGet,
required this.onJoin,
required this.onForget,
});
@override
State<RecentServerRows> createState() => _RecentServerRowsState();
}
class _RecentServerRowsState extends State<RecentServerRows> {
final _health = <String, ServerHealth>{};
final _inFlight = <String>{};
Timer? _timer;
@override
void initState() {
super.initState();
_pollAll();
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
}
@override
void didUpdateWidget(RecentServerRows 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) {
return Column(
children: [
for (final r in widget.servers)
_RecentRow(
key: ValueKey('recent-${r.key}'),
server: r,
health: _health[r.key],
enabled: widget.enabled,
onJoin: () => widget.onJoin(r),
onForget: () => widget.onForget(r),
),
],
);
}
}
class _RecentRow extends StatelessWidget {
final RecentServer server;
/// Null until the first probe answers.
final ServerHealth? health;
final bool enabled;
final VoidCallback onJoin;
final VoidCallback onForget;
const _RecentRow({
super.key,
required this.server,
required this.health,
required this.enabled,
required this.onJoin,
required this.onForget,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
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 ListTile(
dense: true,
contentPadding: const EdgeInsets.only(left: 4),
enabled: enabled,
leading: Icon(server.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 18),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(address, style: const TextStyle(color: Colors.grey)),
Text(status, key: const ValueKey('recent-status'), style: TextStyle(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: 6, vertical: 1),
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
child: Text('needs update', style: TextStyle(fontSize: 11, color: scheme.onErrorContainer)),
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Forget',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: onForget,
icon: const Icon(Icons.close),
),
const Icon(Icons.login, size: 18),
],
),
onTap: onJoin,
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../jellyfin/accounts.dart';
import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../screens/session/swap_panel.dart';
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
/// Throws [FormatException] with a user-facing message when the server is
/// not linked any more or its sign-in expired.
HelloMedia putUpJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
if (accounts.account(media.serverId) == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations');
}
return mediaForPick(media);
}
/// Brings back the last Jellyfin pick (kept in the Jellyfin box) once the
/// accounts are read, unless something else was picked meanwhile or
/// [alive] says the picker is gone.
Future<void> restoreLastPick(
MediaPickerController c, JellyfinAccounts accounts, bool Function() alive) async {
final hadMedia = c.selection != null;
try {
await accounts.ensureLoaded();
} catch (_) {
return;
}
final pick = accounts.lastPick;
if (!alive() || pick == null || c.jellyfin != null) return;
if (accounts.account(pick.serverId) == null) return;
final source = c.source;
c.pickJellyfin(pick);
if (hadMedia) c.source = source;
}
/// Library (round 1): the media picker as the "put it up" view. Offline
/// it browses, with a hint that watching needs a server.
class LibraryTab extends StatelessWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool online;
final bool isHost;
final BrowseStart? jellyfinStart;
/// Puts the pick up; returns an error text, or null when sent.
final Future<String?> Function(PickedMedia media) onPutUp;
final VoidCallback? onPutUpDone;
final VoidCallback? onOpenIntegrations;
const LibraryTab({
super.key,
required this.controller,
required this.accounts,
required this.online,
required this.isHost,
required this.onPutUp,
this.onPutUpDone,
this.jellyfinStart,
this.onOpenIntegrations,
});
@override
Widget build(BuildContext context) {
return SwapPanel(
controller: controller,
accounts: accounts,
isHost: isHost,
jellyfinStart: jellyfinStart,
onSwap: onPutUp,
onDone: onPutUpDone,
onOpenIntegrations: onOpenIntegrations,
disabledHint: online ? null : 'Join or host to watch together',
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
// The shell's shared motion: one curve and one duration for everything
// that moves, a fade-through for views that replace each other, popovers
// that grow from where they open, and mouse-friendly horizontal lists.
const ease = Curves.fastEaseInToSlowEaseOut;
const easeTime = Duration(milliseconds: 320);
/// Fade-through length; the old view is gone after [fadeOutPart] of it.
const fadeThroughTime = Duration(milliseconds: 360);
const fadeOutPart = 0.35;
/// Fade through (Material motion): the old view is gone after the first
/// 35%, then the new one fades and scales in; they never overlap. Key
/// [child] by what it shows so a change of key runs the transition.
Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
key: key,
duration: fadeThroughTime,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
children: [...previous, ?current],
),
transitionBuilder: (c, anim) {
if (c.key == child.key) {
// Incoming (anim 0 -> 1): wait, then fade and scale up.
final a = CurvedAnimation(
parent: anim,
curve: const Interval(fadeOutPart, 1, curve: ease),
);
return FadeTransition(
opacity: a,
child: ScaleTransition(scale: Tween(begin: 0.985, end: 1.0).animate(a), child: c),
);
}
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65.
return FadeTransition(
opacity: CurvedAnimation(
parent: anim,
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
),
child: c,
);
},
child: child,
);
/// A card over the window that fades and scales in from [at] (window
/// alignment) instead of popping; a click outside or Esc closes it. Short
/// windows: the card scrolls instead of overflowing.
Future<T?> showPopover<T>(
BuildContext context, {
required Alignment at,
required Widget child,
bool dim = false,
}) =>
showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: 'close',
barrierColor: dim ? Colors.black26 : Colors.transparent,
transitionDuration: easeTime,
pageBuilder: (context, _, _) => SafeArea(
child: Align(
alignment: at,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height - 32),
child: SingleChildScrollView(child: child),
),
),
),
transitionBuilder: (context, anim, _, child) {
final a = CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn);
return FadeTransition(
opacity: a,
child: ScaleTransition(
scale: Tween(begin: 0.96, end: 1.0).animate(a),
alignment: at,
child: child,
),
);
},
);
/// Horizontal lists for mouse users: drag with the mouse, and the plain
/// (vertical) wheel moves them sideways. At either end the wheel is left
/// to the page, so a row never traps vertical scrolling.
class HScroll extends StatefulWidget {
final ScrollController? controller;
final Widget Function(ScrollController controller) builder;
const HScroll({super.key, this.controller, required this.builder});
@override
State<HScroll> createState() => _HScrollState();
}
class _HScrollState extends State<HScroll> {
ScrollController? _own;
ScrollController get _c => widget.controller ?? (_own ??= ScrollController());
@override
void dispose() {
_own?.dispose();
super.dispose();
}
void _wheel(PointerSignalEvent e) {
if (e is! PointerScrollEvent) return;
final dy = e.scrollDelta.dy;
if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return;
final p = _c.position;
final target = (p.pixels + dy * 1.5).clamp(p.minScrollExtent, p.maxScrollExtent);
if (target == p.pixels) return; // at the end: let the page scroll
GestureBinding.instance.pointerSignalResolver.register(e, (_) {
_c.animateTo(target, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic);
});
}
@override
Widget build(BuildContext context) => ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(dragDevices: PointerDeviceKind.values.toSet()),
child: Listener(onPointerSignal: _wheel, child: widget.builder(_c)),
);
}
+486
View File
@@ -0,0 +1,486 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import '../screens/demo/shell_demo.dart';
import '../screens/home/update_indicator.dart';
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
import '../state/session_state.dart';
import '../update/update_controller.dart';
import '../widgets/dialogs.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
/// The shell's tabs, in strip order.
enum ShellTab {
library('Library'),
schedule('Schedule'),
settings('Settings');
final String label;
const ShellTab(this.label);
}
const partyStripHeight = 60.0;
/// Below this window width the strip shows 3 avatars and no server text,
/// so the tabs keep their room.
const stripCompactBelow = 1100.0;
/// The member plays the current Jellyfin item through the host's relay.
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
ref != null && m.jf[ref.serverId] == false;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join / Host, online the avatars (members card),
/// server and readiness, chat and Leave. Empty space drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
final bool chatOpen;
final int unread;
final VoidCallback onChat;
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls;
const PartyStrip({
super.key,
required this.tab,
required this.onTab,
required this.chatOpen,
required this.unread,
required this.onChat,
required this.onJoin,
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onShowPlayer,
this.updates,
this.windowControls = true,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
final row = SignalBuilder(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null;
return Row(
children: [
const SizedBox(width: 16),
Tooltip(
message: 'Your profile',
child: InkWell(
key: const ValueKey('strip-profile'),
customBorder: const CircleBorder(),
onTap: onProfile,
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && coord.hostFile != null,
),
size: 32,
ring: online,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: HScroll(
builder: (c) => SingleChildScrollView(
controller: c,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in ShellTab.values)
StripTab(
key: ValueKey('tab-${t.name}'),
label: t.label,
selected: t == tab,
compact: compact,
onTap: () => onTab(t),
),
],
),
),
),
),
),
if (kDebugMode)
IconButton(
tooltip: 'Layout demo (debug builds only)',
icon: const Icon(
Icons.bug_report_outlined,
size: 20,
color: Colors.amber,
),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
),
),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
if (online) ...[
if (onShowPlayer != null)
IconButton(
key: const ValueKey('show-player'),
tooltip: 'Show the player',
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
members: partyAvatars(coord),
shown: compact ? 3 : 5,
onTap: () => showMembersCard(context),
),
),
const SizedBox(width: 10),
if (!compact) _ServerLabel(scheme: scheme),
const SizedBox(width: 6),
Badge(
isLabelVisible: unread > 0,
label: Text('$unread'),
child: IconButton(
key: const ValueKey('strip-chat'),
tooltip: 'Chat & activity',
isSelected: chatOpen,
onPressed: onChat,
icon: const Icon(Icons.chat_bubble_outline, size: 20),
selectedIcon: Icon(
Icons.chat_bubble,
size: 20,
color: scheme.primary,
),
),
),
const SizedBox(width: 4),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
),
] else ...[
if (!compact)
Text(
'Offline',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(width: 12),
FilledButton.tonal(
key: const ValueKey('strip-join'),
onPressed: onJoin,
child: const Text('Join'),
),
const SizedBox(width: 8),
FilledButton(
key: const ValueKey('strip-host'),
onPressed: onHost,
child: const Text('Host'),
),
],
const SizedBox(width: 8),
if (windowControls) ...[
SizedBox(
height: 20,
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
),
const WindowButtons(),
] else
const SizedBox(width: 8),
],
);
},
);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest,
border: Border(
bottom: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.4),
),
),
),
child: SizedBox(
height: partyStripHeight,
child: Stack(
children: [
// Empty strip space drags the window; the controls on top take
// their own clicks.
if (windowControls)
const Positioned.fill(
child: DragToMoveArea(child: SizedBox.expand()),
),
Positioned.fill(child: row),
],
),
),
);
}
}
/// Server name and "ready n/m".
class _ServerLabel extends StatelessWidget {
final ColorScheme scheme;
const _ServerLabel({required this.scheme});
@override
Widget build(BuildContext context) => SignalBuilder(
builder: (context) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
final members = r?.members ?? const <MemberView>[];
final ready = members.where((m) => m.ready).length;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
serverName(),
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
r == null ? 'connecting…' : 'ready $ready/${members.length}',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontSize: 12,
height: 1.2,
),
),
],
);
},
);
}
/// "Your server" when hosting, else the address joined.
String serverName() {
final session = SessionState.instance;
if (session.coordinator.hostFile != null) return 'Your server';
return session.lastForm.host;
}
/// One strip tab: label and an underline that grows in when selected.
class StripTab extends StatelessWidget {
final String label;
final bool selected;
final bool compact;
final VoidCallback onTap;
const StripTab({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
child: SizedBox(
height: partyStripHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
AnimatedContainer(
duration: easeTime,
curve: ease,
height: 3,
width: selected ? 22 : 0,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
),
);
}
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// and per member force ready and kick.
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
class MembersCard extends StatelessWidget {
const MembersCard({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('members-card'),
elevation: 12,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 340,
child: PartyWatch(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final r = coord.serverState.value;
final hosting = coord.hostFile != null;
final address = hosting
? null
: '${session.lastForm.host}:${session.lastForm.port}';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final m in r?.members ?? const <MemberView>[])
_MemberTile(
member: m,
info: memberAvatarInfo(m, r, coord),
viaHost: viaHostOf(m, coord.currentRef.value),
),
if (r == null)
Padding(
padding: const EdgeInsets.all(20),
child: Text(
'connecting…',
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
if (address != null) ...[
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
Clipboard.setData(ClipboardData(text: address)),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy address'),
),
),
),
] else
const SizedBox(height: 8),
],
);
},
),
),
);
}
}
class _MemberTile extends StatelessWidget {
final MemberView member;
final AvatarInfo info;
final bool viaHost;
const _MemberTile({
required this.member,
required this.info,
required this.viaHost,
});
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final m = member;
final status = m.stale
? 'reconnecting…'
: info.buffering
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
: m.ready
? 'ready'
: 'not ready';
return ListTile(
key: ValueKey('member-${m.id}'),
dense: true,
contentPadding: const EdgeInsets.only(left: 20, right: 8),
leading: StatusAvatar(info: info, size: 36, badges: false),
title: Wrap(
spacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(info.self ? '${m.name} (you)' : m.name),
if (info.host) const Pill('host', Colors.amber),
if (info.relaying)
const Pill('passing the video on', Colors.lightBlueAccent),
if (viaHost) const ViaHostBadge(),
],
),
subtitle: Text(status),
trailing: PopupMenuButton<String>(
tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) async {
if (v == 'force') {
coord.forceTarget(m.id, !m.ready);
} else if (v == 'kick') {
final ok = await showKickConfirmDialog(context, m.name);
if (ok == true) coord.kick(m.id);
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'force',
child: Text(m.ready ? 'Force unready' : 'Force ready'),
),
if (!info.self)
PopupMenuItem(
value: 'kick',
child: Text(
'Kick',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
);
}
}
@@ -0,0 +1,550 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../jellyfin/accounts.dart';
import '../screens/home/profile.dart';
import '../state/session_state.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
import 'settings_tab.dart' show SettingsSection;
/// Your profile, from your avatar in the strip: name and picture apply
/// live (pushed to the server while in one), colour is yours only; links
/// to the app-wide pages.
Future<void> showProfileCard(
BuildContext context, {
required ValueChanged<SettingsSection> onOpenSettings,
UpdateController? updates,
}) =>
showPopover<void>(
context,
at: const Alignment(-0.97, -0.84),
child: ProfileCard(onOpenSettings: onOpenSettings, updates: updates),
);
class ProfileCard extends StatefulWidget {
final ValueChanged<SettingsSection> onOpenSettings;
final UpdateController? updates;
const ProfileCard({super.key, required this.onOpenSettings, this.updates});
@override
State<ProfileCard> createState() => _ProfileCardState();
}
class _ProfileCardState extends State<ProfileCard> {
final _p = Profile.instance;
late final _name = TextEditingController(text: _p.name.peek());
bool _hoverPic = false;
@override
void dispose() {
_name.dispose();
super.dispose();
}
void _open(SettingsSection s) {
Navigator.of(context).maybePop();
widget.onOpenSettings(s);
}
Future<void> _changePicture() => showPopover<void>(
context,
at: Alignment.center,
dim: true,
child: CropPicker(onUse: _p.setPicture),
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final small = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
return Material(
elevation: 12,
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: 360,
child: SignalBuilder(builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final self = online ? coord.selfMember.value : null;
final hosting = online && coord.hostFile != null;
final color = _p.color.value;
final png = _p.picture.value;
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header tinted with your colour.
AnimatedContainer(
duration: easeTime,
curve: ease,
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0.08)],
),
),
child: Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hoverPic = true),
onExit: (_) => setState(() => _hoverPic = false),
child: GestureDetector(
key: const ValueKey('profile-picture'),
onTap: _changePicture,
child: SizedBox(
width: 88,
height: 88,
child: Stack(
children: [
StatusAvatar(info: me, size: 88, badges: false, ring: online),
Positioned.fill(
child: AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: _hoverPic ? 1 : 0,
child: Container(
margin: const EdgeInsets.all(4),
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black54),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_camera_outlined, color: Colors.white, size: 22),
Text('Change', style: TextStyle(color: Colors.white, fontSize: 11)),
],
),
),
),
),
],
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
key: const ValueKey('profile-name'),
controller: _name,
maxLength: 32,
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
decoration: const InputDecoration(
isDense: true,
counterText: '',
border: InputBorder.none,
hintText: 'Your name',
suffixIcon: Icon(Icons.edit_outlined, size: 16),
suffixIconConstraints: BoxConstraints(minWidth: 20),
),
onChanged: _p.setName,
onSubmitted: (_) => Navigator.of(context).maybePop(),
),
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (hosting) const Pill('host', Colors.amber),
Pill(online ? 'in a server' : 'offline',
online ? Colors.greenAccent : Colors.grey),
if (online)
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
],
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 4),
child: Row(
children: [
Text('Colour', style: small),
const Spacer(),
for (final c in profileColors)
Padding(
padding: const EdgeInsets.only(left: 8),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _p.setColor(c),
child: AnimatedContainer(
duration: easeTime,
curve: ease,
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: color.toARGB32() == c.toARGB32() ? scheme.onSurface : Colors.transparent,
width: 2,
),
),
),
),
),
],
),
),
if (png != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
child: Row(
children: [
Icon(Icons.image_outlined, size: 16, color: scheme.onSurfaceVariant),
const SizedBox(width: 6),
Text('PNG · ${(png.length / 1024).toStringAsFixed(0)} KB', style: small),
const Spacer(),
TextButton(onPressed: () => _p.setPicture(null), child: const Text('Remove picture')),
],
),
),
if (_p.error.value case final err?)
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
child: Text(err, style: TextStyle(color: scheme.error, fontSize: 12)),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 0),
child: Text(
online
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
: 'Others see your name and picture when you join. Colour is yours only.',
style: small,
),
),
const Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 0), child: Divider(height: 1)),
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(children: [
_link(
Icons.extension_outlined,
'Jellyfin & integrations',
trailing: ListenableBuilder(
listenable: JellyfinAccounts.instance,
builder: (context, _) {
final n = JellyfinAccounts.instance.accounts.length;
return Text(n == 0 ? 'none' : '$n linked', style: small);
},
),
onTap: () => _open(SettingsSection.integrations),
),
_link(Icons.settings_outlined, 'Settings', onTap: () => _open(SettingsSection.profile)),
ValueListenableBuilder(
valueListenable: (widget.updates ?? UpdateController.instance).status,
builder: (context, s, _) {
final m = s.manifest;
final available = s.phase != UpdatePhase.idle && m != null;
return _link(
Icons.system_update_alt,
available ? 'Update to ${m.version}' : 'Updates',
trailing: available ? Pill('new', Colors.greenAccent.shade400) : null,
onTap: () => _open(SettingsSection.updates),
);
},
),
_link(Icons.info_outline, 'About', onTap: () => _open(SettingsSection.about)),
]),
),
],
);
}),
),
);
}
Widget _link(IconData icon, String label, {Widget? trailing, required VoidCallback onTap}) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: Icon(icon, size: 20),
title: Text(label),
trailing: trailing,
onTap: onTap,
);
}
/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live
/// previews at the sizes it is shown. "Use this" renders a 256 px PNG.
class CropPicker extends StatefulWidget {
final Future<void> Function(Uint8List png) onUse;
/// Opens the file dialog right away (that is why the picker opened).
final bool autoPick;
const CropPicker({super.key, required this.onUse, this.autoPick = true});
@override
State<CropPicker> createState() => _CropPickerState();
}
class _CropPickerState extends State<CropPicker> {
static const _view = 300.0, _out = 256;
ui.Image? _img;
double _zoom = 1; // 1 = the image just covers the circle
Offset _pan = Offset.zero; // in viewport pixels
String? _error;
bool _busy = false;
@override
void initState() {
super.initState();
if (widget.autoPick) WidgetsBinding.instance.addPostFrameCallback((_) => _pick());
}
@override
void dispose() {
_img?.dispose();
super.dispose();
}
Future<void> _pick() async {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick a picture',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
);
} catch (e) {
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
try {
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
final frame = await codec.getNextFrame();
if (!mounted) return;
_img?.dispose();
setState(() {
_img = frame.image;
_zoom = 1;
_pan = Offset.zero;
_error = null;
});
} catch (e) {
if (mounted) setState(() => _error = 'could not read that image');
}
}
double get _scale {
final img = _img!;
return math.max(_view / img.width, _view / img.height) * _zoom;
}
/// Keeps the circle covered: the image edge never shows inside it.
Offset _clamp(Offset p) {
final img = _img!;
final mx = (img.width * _scale - _view) / 2, my = (img.height * _scale - _view) / 2;
return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my));
}
void _setZoom(double z) => setState(() {
_zoom = z.clamp(1.0, 6.0);
_pan = _clamp(_pan);
});
Future<void> _use() async {
final img = _img;
if (img == null) return;
setState(() => _busy = true);
final rec = ui.PictureRecorder();
final canvas = Canvas(rec);
canvas.scale(_out / _view);
CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view));
final out = await rec.endRecording().toImage(_out, _out);
final png = await out.toByteData(format: ui.ImageByteFormat.png);
out.dispose();
if (!mounted || png == null) return;
await widget.onUse(png.buffer.asUint8List());
if (mounted) Navigator.of(context).maybePop();
}
Widget _preview(double size) => ClipOval(
child: SizedBox(
width: size,
height: size,
child: CustomPaint(painter: CropPainter(_img!, _scale * size / _view, _pan * (size / _view))),
),
);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final img = _img;
const caption = TextStyle(fontSize: 11, color: Colors.grey);
final editor = img == null
? InkWell(
customBorder: const CircleBorder(),
onTap: _pick,
child: Container(
width: _view,
height: _view,
decoration: BoxDecoration(shape: BoxShape.circle, color: scheme.surfaceContainerHighest),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined, size: 48, color: scheme.onSurfaceVariant),
const SizedBox(height: 8),
Text(_error ?? 'Choose a picture', style: TextStyle(color: scheme.onSurfaceVariant)),
],
),
),
)
: Listener(
onPointerSignal: (e) {
if (e is PointerScrollEvent) _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07));
},
child: GestureDetector(
onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)),
child: MouseRegion(
cursor: SystemMouseCursors.move,
// The dimmed surroundings stay inside this rounded square.
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: SizedBox(
width: _view,
height: _view,
child: Stack(
children: [
// The whole image, dimmed outside the circle.
Positioned.fill(
child: Opacity(
opacity: 0.3,
child: CustomPaint(painter: CropPainter(img, _scale, _pan, clipToView: false)),
),
),
ClipOval(
child: CustomPaint(size: const Size(_view, _view), painter: CropPainter(img, _scale, _pan)),
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: scheme.primary, width: 2),
),
),
),
],
),
),
),
),
),
);
return Material(
elevation: 16,
borderRadius: BorderRadius.circular(18),
child: SizedBox(
width: 560,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Your picture', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
img == null ? 'PNG, JPG, WebP, GIF or BMP' : 'Drag to move · scroll to zoom',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
editor,
if (img != null) ...[
const SizedBox(width: 24),
Column(
children: [
_preview(88),
const SizedBox(height: 4),
const Text('profile', style: caption),
const SizedBox(height: 14),
_preview(42),
const SizedBox(height: 4),
const Text('party', style: caption),
const SizedBox(height: 14),
_preview(24),
const SizedBox(height: 4),
const Text('chat', style: caption),
],
),
],
],
),
if (img != null)
Row(
children: [
const Icon(Icons.zoom_out, size: 18),
Expanded(child: Slider(value: _zoom, min: 1, max: 6, onChanged: _setZoom)),
const Icon(Icons.zoom_in, size: 18),
],
)
else
const SizedBox(height: 16),
Row(
children: [
TextButton.icon(
onPressed: _pick,
icon: const Icon(Icons.folder_open, size: 18),
label: Text(img == null ? 'Choose…' : 'Other file…'),
),
const Spacer(),
TextButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: img == null || _busy ? null : _use, child: const Text('Use this')),
],
),
],
),
),
),
);
}
}
/// Draws [img] scaled by [scale], centred in the box and moved by [pan].
class CropPainter extends CustomPainter {
final ui.Image img;
final double scale;
final Offset pan;
final bool clipToView;
CropPainter(this.img, this.scale, this.pan, {this.clipToView = true});
@override
void paint(Canvas canvas, Size size) {
if (clipToView) canvas.clipRect(Offset.zero & size);
final w = img.width * scale, h = img.height * scale;
final dst = Rect.fromLTWH((size.width - w) / 2 + pan.dx, (size.height - h) / 2 + pan.dy, w, h);
canvas.drawImageRect(
img,
Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()),
dst,
Paint()..filterQuality = FilterQuality.high,
);
}
@override
bool shouldRepaint(CropPainter old) => old.img != img || old.scale != scale || old.pan != pan;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/accounts_panel.dart';
import '../screens/home/about_page.dart';
import '../screens/home/settings_page.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
/// The Settings tab's pages, in nav order.
enum SettingsSection {
profile(Icons.person_outline, 'Profile'),
player(Icons.play_circle_outline, 'Player'),
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
updates(Icons.system_update_alt, 'Updates'),
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
about(Icons.info_outline, 'About');
final IconData icon;
final String label;
const SettingsSection(this.icon, this.label);
}
/// App-wide settings under a left nav (chips on top when narrow). The
/// pages hold the existing settings panels; the shell owns [section] so
/// the profile card can open a page.
class SettingsTab extends StatelessWidget {
final SettingsSection section;
final ValueChanged<SettingsSection> onSection;
final VoidCallback? onEditProfile;
final UpdateController? updates;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const SettingsTab({
super.key,
required this.section,
required this.onSection,
this.onEditProfile,
this.updates,
this.jellyfin,
});
List<Widget> _page(BuildContext context) {
switch (section) {
case SettingsSection.profile:
return [
SettingsPanel(
title: 'You',
child: SignalBuilder(
builder: (context) {
final me = selfAvatarInfo();
return ListTile(
contentPadding: EdgeInsets.zero,
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
title: Text(me.name == '?' ? 'No name yet' : me.name),
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
trailing: onEditProfile == null
? null
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
);
},
),
),
];
case SettingsSection.player:
return const [
SettingsPanel(
title: 'Player',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
),
),
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
];
case SettingsSection.integrations:
return [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
),
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
];
case SettingsSection.updates:
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
case SettingsSection.shortcuts:
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
case SettingsSection.about:
return [AboutPanel(updates: updates)];
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) {
final narrow = box.maxWidth < 760;
final nav = narrow
? SizedBox(
height: 52,
child: HScroll(
builder: (c) => ListView(
controller: c,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
children: [
for (final s in SettingsSection.values)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
key: ValueKey('settings-${s.name}'),
label: Text(s.label),
selected: section == s,
onSelected: (_) => onSection(s),
),
),
],
),
),
)
: SizedBox(
width: 250,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
children: [
for (final s in SettingsSection.values)
ListTile(
key: ValueKey('settings-${s.name}'),
selected: section == s,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: Icon(s.icon, size: 20),
title: Text(s.label),
onTap: () => onSection(s),
),
],
),
);
final page = fadeThrough(
KeyedSubtree(
key: ValueKey(section),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
children: [
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 14),
..._page(context),
],
),
),
),
],
),
),
);
return narrow
? Column(children: [nav, Expanded(child: page)])
: Row(children: [nav, Expanded(child: page)]);
},
);
}
}
+228
View File
@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show MemberView;
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import 'avatar.dart';
import 'motion.dart';
/// One toast on screen: who (null = the app itself), what, and whether it
/// is on its way out.
class ShellToast {
final int id;
final AvatarInfo? who;
final IconData? icon;
final String text;
final bool chat;
bool leaving = false;
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
}
/// Splits a coordinator toast into who and what: feed toasts start with
/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's.
ShellToast shellToastFor(ToastItem t, Iterable<String> names, AvatarInfo Function(String name) avatar) {
final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length);
for (final n in sorted) {
if (t.text.startsWith('$n: ')) {
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
}
if (t.text.startsWith('$n ')) {
final rest = t.text.substring(n.length + 1);
final icon = rest.startsWith('joined')
? Icons.login
: rest.startsWith('left')
? Icons.logout
: rest.startsWith('kicked')
? Icons.gavel
: rest.startsWith('changed')
? Icons.swap_horiz
: Icons.info_outline;
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
}
}
return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
}
/// The coordinator's toasts as cards with avatars, bottom-left: each
/// slides in, stays [life], then slides out and collapses its gap.
class PartyToasts extends StatefulWidget {
final Duration life;
const PartyToasts({super.key, this.life = const Duration(seconds: 5)});
@override
State<PartyToasts> createState() => _PartyToastsState();
}
class _PartyToastsState extends State<PartyToasts> {
final _shown = <ShellToast>[];
final _timers = <Timer>[];
Set<int> _seen = {};
late final void Function() _stop;
@override
void initState() {
super.initState();
final coord = SessionState.instance.coordinator;
// What was there before this mounted is old news.
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
final items = coord.toasts.value;
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
_seen = {for (final t in items) t.id};
if (fresh.isEmpty) return;
// Only new toasts re-run this, not the party they mention.
untracked(() {
final r = coord.serverState.value;
final names = [
for (final m in r?.members ?? const <MemberView>[]) m.name,
for (final f in coord.feedLog.value) f.actor,
];
for (final t in fresh) {
_add(shellToastFor(t, names, (n) => avatarForName(n, coord)));
}
});
});
}
void _add(ShellToast t) {
// Not from inside the effect's build-time notification.
scheduleMicrotask(() {
if (!mounted) return;
setState(() => _shown.add(t));
_timers.add(Timer(widget.life, () {
if (mounted) setState(() => t.leaving = true);
}));
_timers.add(Timer(widget.life + easeTime, () {
if (mounted) setState(() => _shown.remove(t));
}));
});
}
@override
void dispose() {
_stop();
for (final t in _timers) {
t.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList()));
}
class ToastColumn extends StatelessWidget {
final List<ShellToast> toasts;
const ToastColumn({super.key, required this.toasts});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final t in toasts)
TweenAnimationBuilder<double>(
key: ValueKey(t.id),
tween: Tween(begin: 0, end: 1),
duration: easeTime,
curve: ease,
builder: (context, v, child) => Opacity(
opacity: v,
child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child),
),
// Leaving: slide back out, fade, and collapse the gap it leaves.
child: AnimatedAlign(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
heightFactor: t.leaving ? 0 : 1,
child: AnimatedSlide(
duration: easeTime,
curve: ease,
offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero,
child: AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: t.leaving ? 0 : 1,
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)),
),
),
),
),
],
);
}
}
class ToastCard extends StatelessWidget {
final ShellToast toast;
const ToastCard({super.key, required this.toast});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final who = toast.who;
return Material(
elevation: 8,
color: scheme.surfaceContainerHigh.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(14),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 16, 10),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
if (who != null)
StatusAvatar(info: who, size: 38, badges: false)
else
CircleAvatar(
radius: 19,
backgroundColor: scheme.primary.withValues(alpha: 0.15),
child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (who != null) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(who.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
),
if (toast.icon != null) ...[
const SizedBox(width: 6),
Icon(toast.icon, size: 16, color: scheme.primary),
],
],
),
const SizedBox(height: 2),
],
Text(
toast.text,
style: TextStyle(
fontSize: toast.chat ? 15 : 14,
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,97 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'sync_client.dart';
/// Member pictures by content hash (`state.members[].avatarHash`), fetched
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and
/// kept in memory. A hash names fixed bytes, so entries never go stale;
/// listeners are notified when one arrives.
class AvatarCache extends ChangeNotifier {
final SyncClient sync;
/// Fetches one picture; null on any failure. Injectable for tests.
final Future<Uint8List?> Function(Uri url) _fetch;
final Map<String, Uint8List> _bytes = {};
final Set<String> _inFlight = {};
final Map<String, DateTime> _failedAt = {};
StreamSubscription? _sub;
bool _disposed = false;
static const _retryAfter = Duration(seconds: 10);
/// Above this many pictures, ones nobody in the latest state uses go.
static const _keep = 32;
AvatarCache(this.sync, {Future<Uint8List?> Function(Uri url)? fetch})
: _fetch = fetch ?? _httpGet {
_sub = sync.states.listen((s) {
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash};
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h));
live.forEach(_want);
});
}
/// The picture for [hash], or null (none, or not here yet: it is fetched
/// and listeners hear when it lands).
Uint8List? bytesFor(String hash) {
if (hash.isEmpty) return null;
final hit = _bytes[hash];
if (hit == null) _want(hash);
return hit;
}
void _want(String hash) {
if (_bytes.containsKey(hash) || _inFlight.contains(hash)) return;
final failed = _failedAt[hash];
if (failed != null && DateTime.now().difference(failed) < _retryAfter) return;
final ws = sync.wsUri;
final token = sync.token;
if (ws == null || token.isEmpty) return;
final url = Uri(
scheme: ws.scheme == 'wss' ? 'https' : 'http',
host: ws.host,
port: ws.port,
pathSegments: ['a', 'default', token, hash]);
_inFlight.add(hash);
unawaited(_fetch(url).then((bytes) {
_inFlight.remove(hash);
if (_disposed) return;
if (bytes == null || bytes.isEmpty) {
_failedAt[hash] = DateTime.now();
return;
}
_failedAt.remove(hash);
_bytes[hash] = bytes;
notifyListeners();
}));
}
static Future<Uint8List?> _httpGet(Uri url) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final resp = await (await client.getUrl(url)).close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200) {
await resp.drain<void>();
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
}
return builder.takeBytes();
} catch (_) {
return null;
} finally {
client.close(force: true);
}
}
@override
void dispose() {
_disposed = true;
unawaited(_sub?.cancel());
super.dispose();
}
}
@@ -33,6 +33,8 @@ class SyncClient {
Stream<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream;
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
Uri? wsUri;
String token = '';
String memberId = '';
String? mediaUrl;
@@ -53,6 +55,7 @@ class SyncClient {
}) async {
await disconnect();
clockSync.reset();
this.wsUri = wsUri;
_status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {}));
@@ -240,6 +243,13 @@ class SyncClient {
_send(ChatMessage(text: clean).toJson());
}
/// Mid-session name/picture change; null leaves it, an empty [avatar]
/// (base64 PNG) removes the picture.
void sendProfile({String? name, String? avatar}) {
if (name == null && avatar == null) return;
_send(ProfileIntent(name: name, avatar: avatar).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
}
@@ -5,23 +5,30 @@ import 'dart:typed_data';
import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart';
/// An image file shrunk to 128 px as base64 PNG ('' for an empty path).
/// Throws [FormatException] with a user-facing message.
Future<String> encodeAvatar(String path) async {
final trimmed = path.trim();
if (trimmed.isEmpty) return '';
final file = File(trimmed);
if (!await file.exists()) {
throw const FormatException('avatar file not found — check the path or pick again with Browse');
throw const FormatException('avatar file not found — pick it again');
}
late final List<int> raw;
try {
raw = await file.readAsBytes();
} catch (_) {
throw const FormatException('could not read that avatar file — pick it again with Browse');
throw const FormatException('could not read that avatar file — pick it again');
}
if (raw.isEmpty) {
throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo');
}
final decoded = img.decodeImage(Uint8List.fromList(raw));
return encodeAvatarBytes(Uint8List.fromList(raw));
}
/// Any image's bytes shrunk to 128 px as base64 PNG.
String encodeAvatarBytes(Uint8List raw) {
final decoded = img.decodeImage(raw);
if (decoded == null) {
throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo');
}
@@ -33,10 +40,16 @@ Future<String> encodeAvatar(String path) async {
backgroundColor: img.PixelUndefined(),
interpolation: img.Interpolation.cubic,
);
final png = img.encodePng(resized);
final b64 = base64Encode(png);
final b64 = base64Encode(img.encodePng(resized));
if (b64.length > RelayConstants.maxAvatarBytes) {
throw const FormatException('that photo is still too large after shrinking — pick a smaller one');
}
return b64;
}
/// The profile's cropped PNG as sent to the server: as is when it fits
/// the cap, else shrunk like [encodeAvatarBytes].
String avatarWireBase64(Uint8List png) {
final b64 = base64Encode(png);
return b64.length <= RelayConstants.maxAvatarBytes ? b64 : encodeAvatarBytes(png);
}
@@ -1,58 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
class MemberAvatar extends StatelessWidget {
final String avatarBase64;
final String name;
final double size;
static final Map<String, MemoryImage> _images = {};
const MemberAvatar({super.key, required this.avatarBase64, required this.name, this.size = 40});
static MemoryImage? _providerFor(String raw) {
if (raw.isEmpty) return null;
var hit = _images[raw];
if (hit != null) return hit;
try {
hit = MemoryImage(base64Decode(raw));
} catch (_) {
return null;
}
if (_images.length > 64) _images.clear();
_images[raw] = hit;
return hit;
}
@override
Widget build(BuildContext context) {
final image = _providerFor(avatarBase64);
final decoration = BoxDecoration(shape: BoxShape.circle);
if (image != null) {
return Container(
decoration: decoration,
child: ClipOval(
child: Image(
image: image,
width: size,
height: size,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => _fallback(),
),
),
);
}
return Container(decoration: decoration, child: _fallback());
}
Widget _fallback() => CircleAvatar(
radius: size / 2,
backgroundColor: Colors.grey.shade800,
child: Text(
name.isEmpty ? '?' : name[0].toUpperCase(),
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
),
);
}
@@ -6,10 +6,9 @@ import 'package:flutter/widgets.dart';
import '../playback/frame_watchdog.dart';
/// Fingerprints what the compositor really shows of the video. Place
/// [anchor] in the video controls (built in the window and again in the
/// fullscreen route); [sample] captures the route around the newest
/// mounted anchor at ~200 px wide — the texture included — and hashes its
/// centre. This is the only per-presented-frame signal: mpv's own
/// [anchor] under a `RepaintBoundary` around the video (the session player
/// does); [sample] captures that boundary around the newest mounted anchor
/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own
/// counters keep ticking when the Flutter texture is stuck.
class ShownFrameProbe {
final _anchors = <_AnchorState>[];
@@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'window_buttons.dart';
class WindowChromeBar extends StatelessWidget {
final String title;
final Widget? leading;
const WindowChromeBar({super.key, required this.title, this.leading});
@override
Widget build(BuildContext context) {
return DragToMoveArea(
child: Container(
height: 36,
color: Theme.of(context).scaffoldBackgroundColor,
child: Row(
children: [
if (leading != null) leading!,
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Spacer(),
const WindowButtons(),
],
),
),
);
}
}
@@ -1,68 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
typedef ExclusionZone = Rect Function(Size);
class WindowDragLayer extends StatefulWidget {
final bool enabled;
final List<ExclusionZone> exclusions;
const WindowDragLayer({
super.key,
this.enabled = true,
this.exclusions = const [],
});
@override
State<WindowDragLayer> createState() => _WindowDragLayerState();
}
class _WindowDragLayerState extends State<WindowDragLayer> {
int? _armedPointer;
Offset? _downPos;
bool _dragging = false;
void _onDown(PointerDownEvent e) {
if (!widget.enabled) return;
_armedPointer = e.pointer;
_downPos = e.position;
_dragging = false;
}
void _onMove(PointerMoveEvent e) {
if (e.pointer != _armedPointer || _dragging) return;
final down = _downPos;
if (down == null) return;
if ((e.position - down).distance < kTouchSlop) return;
final size = context.size;
if (size != null) {
for (final zone in widget.exclusions) {
if (zone(size).contains(down)) {
_armedPointer = null;
return;
}
}
}
_dragging = true;
windowManager.startDragging();
}
void _onEnd(PointerEvent e) {
if (e.pointer == _armedPointer) {
_armedPointer = null;
_dragging = false;
}
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _onDown,
onPointerMove: _onMove,
onPointerUp: _onEnd,
onPointerCancel: _onEnd,
child: const SizedBox.expand(),
);
}
}