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
+5728 -3523

No files matched your search

+76 -44
View File
@@ -28,7 +28,7 @@ From the Releases page of this repo:
Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator
appears in Home's top bar and opens the update card. "Update and restart" downloads,
appears in the strip at the top of the window and opens the update card. "Update and restart" downloads,
verifies, applies in place and restarts; the install folder must be writable
by you. Settings has Check now and an auto-check toggle. From a terminal:
`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A
@@ -36,19 +36,51 @@ release with a newer protocol is a required update: everyone on a server
must run the same protocol version, and a mismatch is refused with a message
telling you to update.
## The window
One window. Along the top, the strip: your avatar (click it for your
profile: name, picture with a crop editor, colour, and links to the
settings), the Library / Schedule / Settings tabs, and the party. Offline
the party side has Join and Host; in a server it shows everyone's avatar
with a ring for their state (green ready, amber buffering, grey not ready,
dimmed reconnecting; a crown on yours when you host, a tower on whoever
passes the video on). Click the avatars for the member list (force ready,
kick). Beside them the server and "ready n/m", the chat button with unread
count, and Leave. Empty strip space drags the window.
The player fills the window under the strip. Pick a tab and it shrinks to
a mini player in a corner: drag it anywhere and it snaps to the nearest
corner; double-click or ⤢ makes it big again, × stops watching (you stay in
the server, muted) and the strip's screen button brings it back. F or
double-click on the picture goes fullscreen (the strip hides, the party
shows in a corner while paused), Esc leaves it. While playback waits, a
pill under the picture says for whom ("Waiting for you · press Space when
ready"). Chat and activity open in a panel on the right (chat button or
Tab); joins, leaves, chat and other news also pop up bottom left with the
person's avatar.
Your name and picture change for everyone in the server right away; the
colour is yours only.
## Host from the app
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank =
anyone can join), then Host. Your name and avatar come from your profile
(the chip at the top left). The app starts a
server inside itself and joins it; you are the host and nobody can kick you.
Leaving or closing the app stops the server. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
Host in the strip: set a port and an optional password (blank = anyone can
join), then Host. The app starts a server inside itself and joins it; you
are the host and nobody can kick you. Nothing is up yet: pick what to
watch in the Library tab (below) and "Put it up for everyone". Leaving or
closing the app stops the server. Friends need to reach your machine: open
the TCP port on your router (or use a LAN or VPN address). Friends open
nothing. Ports you hosted on before are one click away in the Host card.
If you pick a local file, the server streams it to everyone from your disk.
Nothing is uploaded or stored anywhere.
If you put up a local file, the server streams it to everyone from your
disk. Nothing is uploaded or stored anywhere.
## Library
What to watch: Jellyfin (browse your linked servers, see below), Link
(paste an http(s) URL; Ctrl+V outside a field pastes it in) or File (path
or Browse), then "Put it up for everyone" and the player opens. Offline the
library browses but nothing goes up until you join or host.
## Dedicated server
@@ -77,29 +109,30 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use
## Join
Home, Join tab. Enter the host's address, port, and the password if there
is one, then Enter or Join. Your name and avatar come from your profile (top
bar chip or Settings). Servers you got into appear as up to 6 Recent server
tiles with live status (online and how many are watching, or offline, plus
"needs update" when the server runs another protocol): click one to rejoin,
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
Join in the strip. Servers you got into are listed first (up to 6), with
live status (online and how many are watching, or offline, plus "needs
update" when the server runs another protocol): click one to rejoin, x to
forget it. Or enter the host's address, port, and the password if there
is one, then Enter or Join. Your name and picture come from your profile
(your avatar at the top left).
## Settings
Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
"Experimental: mpv-style controls" (below), updates, playing the next
Jellyfin episode automatically, video shaders (see below), and the
keyboard shortcuts (read-only).
Integrations links Jellyfin servers (below). About shows the version.
The Settings tab (Ctrl+,) has a page per topic: Profile; Player (hardware
decoding: auto, off or custom, the env var `SYNC_RELAY_HWDEC` overrides
it, applies on the next join or Reload video; "Experimental: mpv-style
controls", below; video shaders, see below); Jellyfin & integrations
(linked Jellyfin servers, below, and playing the next episode
automatically); Updates; Keyboard shortcuts (read-only); About (the
version).
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30,
Keys while the player fills the window: Space = ready / unready, Tab =
chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P /
Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B,
C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump
moves everyone.
close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
`<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
Every seek, skip and chapter jump moves everyone.
Controls: the seek bar marks chapter starts with a diamond; hovering it
shows the time and the chapter title. The chapters button lists them
@@ -114,8 +147,9 @@ timeline across the bottom shows the played part, the cached part ahead
chapter title and the time there. With the controls hidden it stays as a
thin line. Above it flat buttons: ≡ menu, Subtitles and Audio (with the
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, people,
volume and fullscreen. Keys and skip buttons work the same.
ready, chapters, 1:30, previous / next episode, change video, volume and
fullscreen; chat and back-to-library top right. Keys and skip buttons work
the same.
Skipping: during the opening a "Skip OP" button floats in the corner (it
jumps to the end of the opening), during the ending "Skip ED" (to the end
@@ -137,29 +171,27 @@ Only the host needs the port reachable.
## Jellyfin
Integrations > Jellyfin > Add server: type the address, then sign in with
Settings > Jellyfin & integrations > Add server: type the address, then sign in with
Quick Connect (the app shows a code; approve it in Jellyfin under Profile >
Quick Connect) or your username and password. Several servers can be
linked; each shows its status, and a star picks the default. Sign out
revokes the app's sign-in on the server. If a sign-in expires the server
shows "Reconnect" instead of disappearing.
The Host tab's Jellyfin source is a library browser: Continue watching (with
The Library's Jellyfin source is a library browser: Continue watching (with
time left), Next up and the latest per library on the start page; library
grids with sort (name, date added, release date, rating, random) and
Unwatched / Favorites filters; series pages with seasons, episode progress
and a "Next up" button; details with the file's tracks; search. Clicking a
movie or episode picks it; the button in the top right enlarges the browser
and moves port, password and Host into a bar below it. On wide windows a
side pane shows details of whatever is under the mouse.
movie or episode picks it. On wide windows a side pane shows details of
whatever is under the mouse.
Keys in the browser: arrows move, Enter picks or opens, I = details,
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
search.
Host from the library: pick a movie or episode and Host. Only the item
goes to the server (which Jellyfin server and which item), never a link or
your sign-in. Pasting a Jellyfin link (web page or stream link) does the
Putting up a movie or episode sends only the item to the server (which
Jellyfin server and which item), never a link or your sign-in. Pasting a Jellyfin link (web page or stream link) does the
same when the app can reach that Jellyfin; otherwise it is shared as a
plain link.
@@ -172,7 +204,7 @@ Watching:
- Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never
leaves their computer (only the current item, only to people in the
session). They show "via host" in the member list. When the app hosts,
session). They show "via host" in the member list (click the avatars). When the app hosts,
or a dedicated server has its own login (`--jellyfin <file>`, see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
fetches it itself instead.
@@ -195,9 +227,9 @@ Your choice is remembered per series (and as a default) and the closest
match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected.
"Change video" in the session panel (Tab) opens the same picker as the Host
tab: the library starts on the series you are watching with the current
season, else where you left it.
"Change video" in the player's controls opens the Library tab (the player
goes mini): the library starts on the series you are watching with the
current season, else where you left it.
Anyone can put up a file from their own computer, also on a dedicated
server: it streams from your computer through the server to everyone (the
@@ -226,7 +258,7 @@ C (480p), A+A, B+B, C+A, each as HQ (strong GPUs, GTX 1080 and up) or Fast
Pick one with the ✨ button in the player's control bar (also in
fullscreen) or Ctrl+0..6; the change applies live and is saved, so the next
session starts with it. Settings → Video quality & shaders sets the default,
session starts with it. Settings → Player sets the default,
HQ or Fast (used by Ctrl+1..6) and the custom files. Shaders cost GPU time
every frame and need hardware video rendering: on media_kit's software
fallback they do nothing, and the app says so. A shader that is missing or
+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})
+85 -239
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);
push(avatar: png == null ? '' : avatarWireBase64(png));
} on FormatException catch (e) {
error.value = e.message;
return;
} catch (_) {
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
return;
}
SessionState.instance.lastForm.avatarPath = path;
preview.value = b64.isEmpty ? null : base64Decode(b64);
await _save();
} finally {
picking.value = false;
}
}
Future<void> clearAvatar() async {
SessionState.instance.lastForm.avatarPath = '';
preview.value = null;
error.value = null;
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,30 +60,19 @@ 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),
padding: const EdgeInsets.fromLTRB(20, 16, 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),
@@ -104,20 +99,32 @@ class _SwapPanelState extends State<SwapPanel> {
children: [
Expanded(
child: Text(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
_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),
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,
onPressed: sel == null || _busy || hint != null
? null
: _swap,
icon: _busy
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.swap_horiz),
label: const Text('Put it up for everyone'),
),
@@ -127,7 +134,6 @@ class _SwapPanelState extends State<SwapPanel> {
),
],
),
),
);
}
}
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(),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/sync/avatar_cache.dart';
import 'package:watch_together/src/sync/sync_client.dart';
Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 50));
}
fail('timed out waiting for $what');
}
void main() {
test('pictures arrive by hash over /a/; profile renames and swaps them', () async {
final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'avatars'));
await server.start();
final client = SyncClient();
final cache = AvatarCache(client);
var notified = 0;
cache.addListener(() => notified++);
final first = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(196, (i) => i % 256)]);
final second = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(197, (i) => 255 - i % 256)]);
try {
await client.connect(
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'),
secret: 's',
name: 'alice',
avatar: base64Encode(first),
);
final hash = avatarHashOf(first);
expect(client.lastState!.members.single.avatarHash, hash);
await _poll(() => cache.bytesFor(hash) != null, 'first picture');
expect(cache.bytesFor(hash), first);
expect(notified, 1);
client.sendProfile(name: 'bob', avatar: base64Encode(second));
await _poll(() => client.lastState?.members.single.name == 'bob', 'rename');
final next = client.lastState!.members.single.avatarHash;
expect(next, avatarHashOf(second));
await _poll(() => cache.bytesFor(next) != null, 'second picture');
expect(cache.bytesFor(hash), first, reason: 'earlier pictures stay cached');
client.sendProfile(avatar: '');
await _poll(() => client.lastState?.members.single.avatarHash == '', 'picture removed');
expect(cache.bytesFor(''), isNull);
} finally {
cache.dispose();
await client.dispose();
await server.stop();
}
});
}
+86 -96
View File
@@ -2,11 +2,11 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/home_screen.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/home/ui.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
@@ -107,64 +107,87 @@ void main() {
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: HomeShell(
home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false,
windowControls: false,
healthGet: health ?? fakeHealth(const {}),
),
),
));
await tester.pump();
}
setUp(() => SessionState.instance.recents.value = const []);
testWidgets('tabs and page buttons switch the body', (tester) async {
await pumpShell(tester, const Size(1280, 900));
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(find.byKey(const ValueKey('host-picker')), findsNothing);
await tester.tap(find.byKey(const ValueKey('tab-host')));
Future<void> openCard(WidgetTester tester, String key) async {
await tester.tap(find.byKey(ValueKey(key)));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
expect(find.byKey(const ValueKey('join-row')), findsNothing);
expect(find.text('No password — anyone can join'), findsOneWidget);
await tester.tap(find.byTooltip('Settings'));
}
setUp(() {
SessionState.instance.recents.value = const [];
SessionState.instance.route.value = AppRoute.home;
});
testWidgets('tabs and settings pages switch the body', (tester) async {
await pumpShell(tester, const Size(1280, 900));
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-schedule')));
// The schedule shows a spinner while it loads: pump frames, not settle.
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(find.byKey(const ValueKey('swap-panel')), findsNothing);
expect(find.byType(SchedulePage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-settings')));
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
await tester.pumpAndSettle();
expect(find.text('Name, picture and colour: click your avatar in the top left'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-player')));
await tester.pumpAndSettle();
expect(find.text('Hardware decoding'), findsOneWidget);
expect(find.text('Check automatically on start'), findsOneWidget);
expect(find.text('Toggle ready'), findsOneWidget);
await tester.tap(find.byTooltip('Integrations'));
await tester.tap(find.byKey(const ValueKey('settings-updates')));
await tester.pumpAndSettle();
expect(find.text('Jellyfin'), findsOneWidget);
await tester.tap(find.byTooltip('About'));
expect(find.text('Check automatically on start'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-shortcuts')));
await tester.pumpAndSettle();
expect(find.text('Toggle ready'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-integrations')));
await tester.pumpAndSettle();
expect(find.text('Play the next episode automatically'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('settings-about')));
await tester.pumpAndSettle();
expect(find.text('Dev build — update checks are off'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('tab-join')));
await tester.tap(find.byKey(const ValueKey('tab-library')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('Enter in the join row submits; errors show under it', (tester) async {
testWidgets('Enter in the join card submits; errors show under it', (tester) async {
SessionState.instance.lastForm.name = '';
await pumpShell(tester, const Size(1280, 720));
await openCard(tester, 'strip-join');
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
// The button submits too, and typing in the address clears the error.
await tester.enterText(find.byKey(const ValueKey('join-address')), 'friend.example2');
await tester.pump();
expect(find.textContaining('display name', findRichText: true), findsNothing);
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
await tester.pump();
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Join').last);
await tester.pumpAndSettle();
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
// A click outside closes the card; the error is still there on reopen.
await tester.tapAt(const Offset(20, 700));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('join-address')), findsNothing);
await openCard(tester, 'strip-join');
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
});
testWidgets('recent tiles show live status from /health', (tester) async {
testWidgets('recent rows show live status from /health', (tester) async {
SessionState.instance.recents.value = [
join('alpha.example', '8080'),
join('beta.example', '9000', 'pw'),
@@ -181,54 +204,34 @@ void main() {
},
'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0},
}));
await tester.pump();
Finder inTile(String key, Finder f) =>
await openCard(tester, 'strip-join');
Finder inRow(String key, Finder f) =>
find.descendant(of: find.byKey(ValueKey('recent-$key')), matching: f);
expect(inTile('alpha.example:8080', find.text('Movie night')), findsOneWidget);
expect(inTile('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget);
expect(inTile('alpha.example:8080', find.text('needs update')), findsNothing);
expect(inTile('beta.example:9000', find.text('○ offline')), findsOneWidget);
expect(inTile('beta.example:9000', find.text('beta.example')), findsOneWidget);
expect(inTile('gamma.example:7000', find.text('needs update')), findsOneWidget);
// Hosted entries live on the Host tab, not in the join grid.
expect(inRow('alpha.example:8080', find.text('Movie night')), findsOneWidget);
expect(inRow('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget);
expect(inRow('alpha.example:8080', find.text('needs update')), findsNothing);
expect(inRow('beta.example:9000', find.text('○ offline')), findsOneWidget);
expect(inRow('beta.example:9000', find.text('beta.example')), findsOneWidget);
expect(inRow('gamma.example:7000', find.text('needs update')), findsOneWidget);
// Hosted entries live on the Host card, not in the join list.
expect(find.byKey(const ValueKey('recent-host:8181')), findsNothing);
// Tiles share a row on a wide window.
final a = tester.getRect(find.byKey(const ValueKey('recent-alpha.example:8080')));
final b = tester.getRect(find.byKey(const ValueKey('recent-beta.example:9000')));
expect(a.top, b.top);
// x forgets.
await tester.tap(inTile('beta.example:9000', find.byTooltip('Forget')));
await tester.tap(inRow('beta.example:9000', find.byTooltip('Forget')));
await tester.pump();
expect(find.byKey(const ValueKey('recent-beta.example:9000')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('no recents shows the empty state', (tester) async {
testWidgets('no recents: the join card is just the form', (tester) async {
await pumpShell(tester, const Size(1280, 720));
expect(find.byKey(const ValueKey('recents-empty')), findsOneWidget);
await openCard(tester, 'strip-join');
expect(find.text('Recent'), findsNothing);
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
});
testWidgets('wide windows: grid fills the width, fields stay compact', (tester) async {
SessionState.instance.recents.value = [
for (var i = 0; i < 6; i++) join('10.0.0.$i', '8080'),
];
await pumpShell(tester, const Size(3440, 1440));
final first = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.0:8080')));
final last = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.5:8080')));
expect(first.top, last.top);
expect(tester.getSize(find.byKey(const ValueKey('join-address'))).width, 280);
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const ValueKey('host-column'))).width, 340);
final file = find.descendant(
of: find.byKey(const ValueKey('media-file')), matching: find.byType(TextField));
expect(tester.getSize(file).width, lessThanOrEqualTo(kFieldMaxWidth));
expect(tester.takeException(), isNull);
});
testWidgets('media picker segments switch the input', (tester) async {
testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.tap(find.text('File'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
expect(find.text('Browse'), findsOneWidget);
@@ -236,6 +239,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
expect(find.text('Paste'), findsOneWidget);
// Offline nothing goes up.
expect(find.text('Join or host to watch together'), findsOneWidget);
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
// No linked server: sign-in right in the picker.
@@ -246,44 +251,29 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('host column sits right when wide, below when narrow', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.pumpAndSettle();
var picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
var column = tester.getRect(find.byKey(const ValueKey('host-column')));
expect(picker.right, lessThan(column.left));
expect(picker.top, column.top);
await pumpShell(tester, const Size(640, 1000));
picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
column = tester.getRect(find.byKey(const ValueKey('host-column')));
expect(picker.bottom, lessThan(column.top));
expect(tester.takeException(), isNull);
});
testWidgets('host without a name errors in the server column', (tester) async {
testWidgets('host without a name errors in the host card', (tester) async {
SessionState.instance.lastForm.name = '';
SessionState.instance.recents.value = [const RecentServer(hosted: true, host: '', port: '8181', secret: 's')];
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.byKey(const ValueKey('tab-host')));
await openCard(tester, 'strip-host');
expect(find.text('No password — anyone can join'), findsOneWidget);
expect(find.text('You pick what to watch afterwards, in the library.'), findsOneWidget);
// A hosted port refills the fields.
await tester.tap(find.byKey(const ValueKey('recent-host:8181')));
await tester.pump();
expect(find.text('No password — anyone can join'), findsNothing);
await tester.tap(find.widgetWithText(FilledButton, 'Host').last);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Host'));
await tester.pump();
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('host-column')),
matching: find.textContaining('display name', findRichText: true),
),
findsOneWidget,
);
expect(find.textContaining('display name', findRichText: true), findsOneWidget);
});
testWidgets('narrow window: bar, join row and host tab fit', (tester) async {
testWidgets('narrow window: strip, cards and library fit', (tester) async {
await pumpShell(tester, const Size(480, 800));
expect(tester.takeException(), isNull);
await openCard(tester, 'strip-join');
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle();
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
@@ -7,7 +7,6 @@ import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.dart';
import 'package:watch_together/src/screens/home/host_tab.dart' show mediaSummary;
import 'package:watch_together/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
@@ -327,9 +326,8 @@ void main() {
expect(back, m);
expect(back.runtime, const Duration(minutes: 23));
expect(back.subtitle, 'Black Summoner · S1E6');
expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6');
expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv');
expect(mediaSummary(null), 'No media selected (optional).');
expect(m.label, 'Rice and Thieves and Heroes — Black Summoner · S1E6');
expect(const FileMedia('/a/b/movie.mkv').label, 'movie.mkv');
expect(JellyfinMedia.fromJson({'serverId': 1}), isNull);
});
+31 -24
View File
@@ -5,10 +5,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/screens/home/connect_flow.dart';
import 'package:watch_together/src/screens/home/host_tab.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/shell/library_tab.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
@@ -270,7 +268,7 @@ void main() {
expect(find.byType(JellyfinBrowser), findsOneWidget);
});
testWidgets('Host tab: browser beside the server column, enlarged with a host bar, stacked when narrow',
testWidgets('Library tab: the last pick comes back; offline it only browses',
(tester) async {
await setSize(tester, const Size(1800, 1000));
final (accounts, _) = await linkedAccounts();
@@ -281,32 +279,41 @@ void main() {
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6');
await accounts.rememberPick(pick);
final fields = HostFields(ConnectForm(), jellyfin: accounts);
final flow = ConnectFlow();
addTearDown(() {
fields.dispose();
flow.dispose();
});
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields))));
final c = MediaPickerController();
addTearDown(c.dispose);
await restoreLastPick(c, accounts, () => true);
final put = <PickedMedia>[];
Future<void> pump({required bool online}) => tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(
body: LibraryTab(
controller: c,
accounts: accounts,
online: online,
isHost: false,
onPutUp: (m) async {
put.add(m);
return null;
},
),
)));
await pump(online: false);
await settle(tester);
// The saved pick comes back on the Jellyfin source.
expect(fields.media.selection, pick);
expect(c.selection, pick);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(find.byKey(const ValueKey('host-column')), findsOneWidget);
expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
expect(find.text('Join or host to watch together'), findsOneWidget);
final go = find.byKey(const ValueKey('swap-go'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
await tester.tap(find.byKey(const ValueKey('jf-expand')));
await settle(tester, 2);
expect(find.byKey(const ValueKey('host-column')), findsNothing);
expect(find.byKey(const ValueKey('host-bar')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const ValueKey('jf-expand')));
await settle(tester, 2);
await pump(online: true);
await settle(tester);
expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
await tester.tap(go);
await settle(tester);
expect(put, [pick]);
tester.view.physicalSize = const Size(800, 900);
await settle(tester, 2);
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(tester.takeException(), isNull);
});
+7 -8
View File
@@ -9,12 +9,12 @@ import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/host_tab.dart' show hostJellyfinMedia;
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/screens/session/swap_panel.dart';
import 'package:watch_together/src/screens/session_screen.dart' show viaHostOf;
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf;
import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle;
@@ -125,16 +125,16 @@ void main() {
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6',
runtime: Duration(minutes: 23));
final media = hostJellyfinMedia(accounts, pick);
final media = putUpJellyfinMedia(accounts, pick);
expect(media.url, isNull);
expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6');
expect(media.jellyfin!.runtimeSecs, 23 * 60);
expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN')));
expect(() => hostJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
expect(() => putUpJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
throwsFormatException);
accounts.markExpired(fixtureServerId);
expect(() => hostJellyfinMedia(accounts, pick), throwsFormatException);
expect(() => putUpJellyfinMedia(accounts, pick), throwsFormatException);
});
test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)',
@@ -532,7 +532,7 @@ void main() {
swapped.add(m);
return null;
},
onClose: () => closed++,
onDone: () => closed++,
)));
await settle(tester);
// A guest's file goes through the server's relay.
@@ -553,7 +553,7 @@ void main() {
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
await tester.pumpWidget(app(SwapPanel(
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x')));
await settle(tester);
expect(find.text('File'), findsOneWidget);
await tester.tap(find.text('File'));
@@ -580,7 +580,6 @@ void main() {
isHost: false,
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
onSwap: (_) async => null,
onClose: () {},
)));
await settle(tester, 10);
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
+339
View File
@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/profile.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/avatar.dart';
import 'package:watch_together/src/shell/motion.dart';
import 'package:watch_together/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/profile_card.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/shell/settings_tab.dart' show SettingsTab;
import 'package:watch_together/src/shell/toasts.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show ToastItem;
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/update/update_controller.dart';
/// Stands in for media_kit's `Video` (which hangs flutter_test) and
/// counts how often one is created.
class FakeVideo extends StatefulWidget {
static int created = 0;
const FakeVideo({super.key});
@override
State<FakeVideo> createState() => _FakeVideoState();
}
class _FakeVideoState extends State<FakeVideo> {
@override
void initState() {
super.initState();
FakeVideo.created++;
}
@override
Widget build(BuildContext context) => const ColoredBox(color: Colors.black);
}
MemberView member(String id, String name, {bool ready = false, bool stale = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: ready, buffered: 30, stale: stale);
void main() {
final session = SessionState.instance;
final coord = session.coordinator;
void online(List<MemberView> members, {bool media = true}) {
coord.serverState.value = StateBroadcast(
eventId: 1,
transport: 'held',
position: 0,
serverTime: DateTime.now().toUtc(),
mediaId: media ? 1 : 0,
media: media ? {'name': 'Movie.mkv', 'size': 1, 'duration': 0} : null,
members: members,
);
session.route.value = AppRoute.session;
}
setUp(() {
FakeVideo.created = 0;
session.recents.value = const [];
});
tearDown(() {
session.route.value = AppRoute.home;
coord.serverState.value = null;
coord.playback.alive.value = false;
coord.toasts.value = [];
});
Future<void> pumpShell(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: AppShell(
updates: UpdateController(currentVersion: '0.0.0-dev'),
autoUpdateCheck: false,
windowControls: false,
videoBuilder: (context, controls) => const FakeVideo(),
),
));
await tester.pumpAndSettle();
}
final party = [
member('a', 'Alex', ready: true),
member('b', 'Bob'),
member('c', 'Cleo', ready: true),
member('d', 'Dan'),
member('e', 'Eve', stale: true),
member('f', 'Finn'),
];
group('party strip', () {
testWidgets('offline: Join and Host, no party', (tester) async {
await pumpShell(tester, const Size(1280, 800));
expect(find.byKey(const ValueKey('strip-join')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-host')), findsOneWidget);
expect(find.text('Offline'), findsOneWidget);
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-leave')), findsNothing);
expect(tester.getSize(find.byType(PartyStrip)).height, partyStripHeight);
});
testWidgets('online: avatars, server and readiness, chat, Leave', (tester) async {
await pumpShell(tester, const Size(1280, 800));
online(party, media: false);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('strip-join')), findsNothing);
expect(find.byKey(const ValueKey('strip-leave')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget);
expect(find.text('ready 2/6'), findsOneWidget);
final stack = find.byKey(const ValueKey('party-avatars'));
// Five shown, the sixth as "+1"; ready ones first.
expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(5));
expect(find.descendant(of: stack, matching: find.text('+1')), findsOneWidget);
final alex = tester.getTopLeft(find.descendant(of: stack, matching: find.byTooltip('Alex')));
final bob = tester.getTopLeft(find.descendant(of: stack, matching: find.byTooltip('Bob')));
expect(alex.dx, lessThan(bob.dx));
// Leave asks first.
await tester.tap(find.byKey(const ValueKey('strip-leave')));
await tester.pumpAndSettle();
expect(find.text('Leave server?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(session.route.value, AppRoute.session);
});
testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
await pumpShell(tester, const Size(1000, 800));
online(party, media: false);
await tester.pumpAndSettle();
final stack = find.byKey(const ValueKey('party-avatars'));
expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(3));
expect(find.descendant(of: stack, matching: find.text('+3')), findsOneWidget);
expect(find.byKey(const ValueKey('server-label')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('tabs: the underline moves to the selected one', (tester) async {
await pumpShell(tester, const Size(1280, 800));
double underline(String tab) => tester
.getSize(find.descendant(of: find.byKey(ValueKey('tab-$tab')), matching: find.byType(AnimatedContainer)))
.width;
expect(underline('library'), 22);
expect(underline('settings'), 0);
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await tester.pumpAndSettle();
expect(underline('library'), 0);
expect(underline('settings'), 22);
});
});
group('popovers', () {
testWidgets('members card opens from the avatars and closes outside', (tester) async {
await pumpShell(tester, const Size(1280, 800));
online(party, media: false);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('avatar-stack')));
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('members-card'));
expect(card, findsOneWidget);
for (final m in party) {
expect(find.descendant(of: card, matching: find.text(m.name)), findsOneWidget);
}
expect(find.descendant(of: card, matching: find.text('reconnecting…')), findsOneWidget);
// The kick menu asks before kicking.
await tester.tap(find.descendant(of: find.byKey(const ValueKey('member-b')), matching: find.byTooltip('More')));
await tester.pumpAndSettle();
await tester.tap(find.text('Kick'));
await tester.pumpAndSettle();
expect(find.text('Remove Bob?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(40, 700));
await tester.pumpAndSettle();
expect(card, findsNothing);
});
testWidgets('profile card opens from your avatar; its links open Settings', (tester) async {
await pumpShell(tester, const Size(1280, 800));
await tester.tap(find.byKey(const ValueKey('strip-profile')));
await tester.pumpAndSettle();
expect(find.byType(ProfileCard), findsOneWidget);
expect(find.text('offline'), findsOneWidget);
await tester.tap(find.text('About'));
await tester.pumpAndSettle();
expect(find.byType(ProfileCard), findsNothing);
expect(find.text('Dev build — update checks are off'), findsOneWidget);
});
testWidgets('showPopover fades and scales in, Esc closes it', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () => showPopover<void>(context, at: Alignment.topRight, child: const Text('card')),
child: const Text('open'),
),
),
),
));
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(easeTime ~/ 2);
final fade = tester.widget<FadeTransition>(
find.ancestor(of: find.text('card'), matching: find.byType(FadeTransition)).first);
expect(fade.opacity.value, inExclusiveRange(0.0, 1.0));
await tester.pumpAndSettle();
expect(find.text('card'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('card'), findsNothing);
});
});
testWidgets('profile rename goes to the server through the hook', (tester) async {
final pushed = <String?>[];
final p = Profile.instance;
final before = p.push;
p.push = ({String? name, String? avatar}) => pushed.add(name);
addTearDown(() => p.push = before);
session.lastForm.name = 'Old';
p.name.value = 'Old';
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: Center(child: ProfileCard(onOpenSettings: (_) {}))),
));
await tester.enterText(find.byKey(const ValueKey('profile-name')), 'Neo');
await tester.pump();
expect(p.name.value, 'Neo');
expect(session.lastForm.name, 'Neo');
// Typing settles before it is sent, once.
expect(pushed, isEmpty);
await tester.pump(const Duration(milliseconds: 700));
expect(pushed, ['Neo']);
});
testWidgets('player: mini and full share one video, never two', (tester) async {
coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800));
online(party);
await tester.pumpAndSettle();
final videos = find.byType(FakeVideo);
AppShellState shell() => tester.state<AppShellState>(find.byType(AppShell));
// Joined with something up: it opens full.
expect(shell().mode, PlayerMode.full);
expect(videos, findsOneWidget);
expect(tester.getSize(videos), tester.getSize(find.byKey(const ValueKey('body'))));
Future<void> stepThrough() async {
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
expect(videos, findsOneWidget);
}
await tester.pumpAndSettle();
expect(videos, findsOneWidget);
}
// A tab sends it to the mini player.
await tester.tap(find.byKey(const ValueKey('tab-settings')));
await stepThrough();
expect(shell().mode, PlayerMode.mini);
expect(tester.getSize(videos), AppShellState.miniSize);
expect(find.byType(SettingsTab), findsOneWidget);
// Double-click on the mini: full again.
final mini = tester.getCenter(videos);
await tester.tapAt(mini);
await tester.tapAt(mini);
await stepThrough();
expect(shell().mode, PlayerMode.full);
// F: fullscreen hides the strip; Esc leaves it.
await tester.sendKeyEvent(LogicalKeyboardKey.keyF);
await stepThrough();
expect(shell().fullscreen, isTrue);
expect(find.byType(PartyStrip), findsNothing);
// Player keys are debounced on the wall clock.
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 400)));
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await stepThrough();
expect(shell().fullscreen, isFalse);
expect(find.byType(PartyStrip), findsOneWidget);
// Never rebuilt from scratch on the way.
expect(FakeVideo.created, 1);
// Offline: no player at all.
session.route.value = AppRoute.home;
await tester.pumpAndSettle();
expect(videos, findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('waiting pill: who is not ready, or you', (tester) async {
coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800));
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
await tester.pumpAndSettle();
expect(find.text('Waiting for Bob, Cleo'), findsOneWidget);
});
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
AvatarInfo who(String n) => AvatarInfo(name: n, color: Colors.teal);
final at = DateTime.now();
final names = ['Bob', 'Bob Jr'];
final chat = shellToastFor(ToastItem(id: 1, text: 'Bob Jr: hi there', at: at), names, who);
expect(chat.who!.name, 'Bob Jr');
expect(chat.text, 'hi there');
expect(chat.chat, isTrue);
final joined = shellToastFor(ToastItem(id: 2, text: 'Bob joined', at: at), names, who);
expect(joined.who!.name, 'Bob');
expect(joined.text, 'joined');
expect(joined.icon, Icons.login);
final app = shellToastFor(ToastItem(id: 3, text: 'Video froze — reloaded', at: at), names, who);
expect(app.who, isNull);
});
testWidgets('toasts slide in with an avatar and leave after their time', (tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: PartyToasts(life: Duration(seconds: 1)))));
coord.toasts.value = [ToastItem(id: 1, text: 'Video froze — reloaded', at: DateTime.now())];
await tester.pump();
await tester.pump();
expect(find.text('Video froze — reloaded'), findsOneWidget);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Video froze — reloaded'), findsNothing);
});
testWidgets('status avatar fills its box at any size', (tester) async {
await tester.pumpWidget(const MaterialApp(
home: Center(
child: StatusAvatar(info: AvatarInfo(name: 'Zed', color: Colors.teal, ready: true), size: 88),
),
));
final circle = tester.getSize(find.byType(CircleAvatar));
// Ring and gap (2 x 4 px each side at this size) inside the 88 px box.
expect(circle.width, 88 - 4 * 4);
expect(find.text('Z'), findsOneWidget);
});
}
+8 -2
View File
@@ -11,9 +11,9 @@ import 'package:watch_together/src/controls/uosc/timeline.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/playback/playback_controller.dart' show RenderMode;
import 'package:watch_together/src/screens/home/settings_page.dart';
import 'package:watch_together/src/screens/session/player_controls.dart' show TrackPick;
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
@@ -456,7 +456,13 @@ void main() {
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(body: SettingsPage(updates: UpdateController(currentVersion: '0.0.0-dev'))),
home: Scaffold(
body: SettingsTab(
section: SettingsSection.player,
onSection: (_) {},
updates: UpdateController(currentVersion: '0.0.0-dev'),
),
),
));
final toggle = find.byKey(const ValueKey('mpv-controls'));
expect(find.text('Experimental: mpv-style controls'), findsOneWidget);
+113 -49
View File
@@ -36,8 +36,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
`127.0.0.1` like any other client. Server logs go to the app's log with a
`[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; Home
shows it on the Host card (during setup the host attempt fails with it).
dies anyway, the session shows "server crashed: ..." and leaves; the
shell says so in a dialog (during setup the host attempt fails with it).
Stop (leave, close, cancel during setup, a failed own join, being kicked)
sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
@@ -500,37 +500,95 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
matched with `matchTrack` against the source's streams (mapped to mpv ids
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
on the wire.
- **Swap panel**: "Change video" in the side panel opens the shared
`MediaPicker` over the video (`screens/session/swap_panel.dart`); Link
- **Change video**: the player's "Change video" opens the Library tab
(the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only)
through `ServerIsolate.setFile` and a matching `swap`.
## Desktop UI
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
profile chip, Join and Host tabs, the update indicator, Settings /
Integrations / About buttons, and the window drag area and buttons. Only the
selected view is mounted; form state lives in `HomeShell`. Join
(`join_tab.dart`): one compact address/port/password row, then up to 6
Recent server tiles in a grid that adds columns as the window widens. While
the Join tab is visible each tile polls the server's `GET /health` every
10 s (`health_probe.dart`) for online, name, viewer count and a "needs
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget
driven by a `MediaPickerController` whose `selection` is a sealed
`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see
`picked_media.dart`) fills the main area; port, password and Host sit in a
340 px column on the right, below the picker under 860 px. With the
Jellyfin source the browser fills the height and can be enlarged, which
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
they stay compact on ultrawide screens. Submit state (busy, cancel,
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
decoding, experimental mpv-style controls, Jellyfin auto-advance, updates,
video shaders, shortcuts), Integrations
(linked Jellyfin servers) and About open in the same body. Settings persist
in two Hive boxes: `connect_form` (last form and recents; saved even when a
join fails) and `settings`. Link media names are derived from the URL path
and never contain a query string.
One window, `AppShell` (`lib/src/shell/`), whatever the connection state
(online = `SessionState.route` is `session`). Motion is shared
(`motion.dart`): one curve (`fastEaseInToSlowEaseOut`, 320 ms), a fade
through for views that replace each other (the old one gone after 35%, the
new one fading and scaling in from 0.985), `showPopover` (cards that fade
and scale in from where they open; they scroll when the window is short)
and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them
sideways until an end, then the page).
- **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card),
the Library / Schedule / Settings tabs with a growing underline (an
`HScroll` when narrow), the update indicator, then offline Join and Host
(connect cards), online the party: `AvatarStack` (host first, then
ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
"ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
(asks first). The stack opens the members card (status, host / relay
pills, "via host", force ready, kick). Empty strip space is the window
drag area; the window buttons sit at its end. Debug builds add a button
that opens the layout mock (`screens/demo/`).
- **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
picture with a gap in the background colour (both scale with the size):
green ready, amber buffering (with a spinner), grey not ready, dimmed
stale; a crown for the host (only your own app knows it hosts: the
state has no host flag) and a tower for the member passing the video
on. `memberAvatarImage` is the one picture lookup: yours from the
profile, anyone else's from `coordinator.avatars` by `avatarHash`.
Colours are local: yours is picked in the profile, others' come from
their name.
- **Connect cards** (`connect_cards.dart`): Join lists recent servers as
one-click rows that poll `GET /health` every 10 s while the card is open
(`health_probe.dart`: online, name, viewer count, "needs update" when
`proto` differs from ours), then address, port and password. Host takes
a port and a password (hosted ports as chips) and puts nothing up: what
to watch is picked in the library afterwards. Submit state (busy,
cancel, per-card errors) is `ConnectFlow`, owned by the shell so an
attempt and its error outlive the card; a card closes itself once
online.
- **Profile** (`profile_card.dart`, `screens/home/profile.dart`): a card
tinted with your colour: picture (hover "Change" opens `CropPicker`:
drag, wheel or slider zoom, previews at 88/42/24 px, exports a 256 px
PNG), name edited in place, colour swatches, links to the Settings pages.
`Profile` stores the name in the connect form and the PNG and colour in
the `connect_form` box (an old picture path is shrunk once and kept as
bytes). Online, a name change (once typing settles) or picture change
goes out through `Profile.push` (`coordinator.updateProfile`).
- **Tabs**: Library (`library_tab.dart`) is the media picker
(`media_picker.dart`: Jellyfin, Link, File; a `MediaPickerController`
whose `selection` is a sealed `PickedMedia`, see `picked_media.dart`)
with "Put it up for everyone" (`screens/session/swap_panel.dart`); it
starts on what was last put up. Offline it only browses. Schedule is a
placeholder. Settings (`settings_tab.dart`) has a left nav (chips under
760 px) over the settings panels (`screens/home/settings_page.dart`):
profile, player (hardware decoding, mpv-style controls, shaders),
Jellyfin & integrations (linked servers, auto-advance), updates,
shortcuts, about.
- **Player** (`session_player.dart`): `SessionPlayer` holds the one
`Video` of the current player (a new `GlobalKey` per player generation).
The shell keeps it in one layer of the body at one place in the element
tree: full it fills the body, mini it is 384x216 in a corner (drag it, it
snaps to the nearest corner with the shared curve). A mode change fades
the layer out, moves it while invisible and fades it in (with the tabs
when the full player comes or goes), so the video is never mounted twice
nor remounted. A tab sends the full player to mini; × on the mini closes
it (muted, still in the server) and the strip brings it back. Fullscreen
is `windowManager.setFullScreen` with the strip hidden (party avatars in
a corner while paused); F, Esc and double-click on the picture toggle it.
The player keys (Settings → Keyboard shortcuts) work while the player is
full and nothing is in front of it. Something coming up while the player
is closed opens it: full right after joining or after putting it up
here, else mini. While held, a pill under the picture names who
everyone waits for, or says "Waiting for you · press Space" (a click
marks you ready); the mini shows it in its hover bar.
- **Chat** (`chat_panel.dart`): a 340 px panel sliding in at the right
(strip button or Tab): chat with avatars, other activity as quiet lines.
**Toasts** (`toasts.dart`): the coordinator's toasts bottom left, with
the actor's avatar when the text starts with a member's name; each
slides in, stays 5 s, then slides out and collapses its gap.
Settings persist in two Hive boxes: `connect_form` (last form, recents and
the profile picture and colour; saved even when a join fails) and
`settings`. Link media names are derived from the URL path and never
contain a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
@@ -555,34 +613,37 @@ Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
key changes only when the image does. An inspector pane follows
hover/focus from 1400 px browser width.
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
remembers the last pick in the `jellyfin` box and hosts it as a ref
(`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server
gets the relay credential (`relayCredentialFor`: the account of the
mediaSourceId?, title, subtitle, runtime; no credentials). The library
remembers the last pick in the `jellyfin` box and puts it up as a ref
(`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay
credential (`relayCredentialFor`: the account of the
item's server, else the default one, not expired; its token with
exactly `JellyfinAccounts.clientInfo`'s device fields).
- Browser location: every navigation saves the route stack per server
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
names and the fields pages start from), so the Host tab, the swap panel
and the next start open where the browser was. A `BrowseStart` overrides
it: the swap panel opens on the series of the episode playing, its
names and the fields pages start from), so the library and the next
start open where the browser was. A `BrowseStart` overrides it: "Change
video" opens the library on the series of the episode playing, its
season selected and the episode highlighted.
Session controls: media_kit's `MaterialDesktopVideoControls` with our
bars (`screens/session_screen.dart`, pieces in
`screens/session/player_controls.dart`). Its seek bar is off; ours
bars (`shell/session_player.dart`, pieces in
`screens/session/player_controls.dart`); its own double-click and keys are
off (its double-click pushes a fullscreen route with a second `Video`), the
picture's double-click is a raw pointer observer (`DoubleClickListener`).
The top bar holds the title, Change video, chat and back-to-library. Its seek bar is off; ours
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
sits in the bottom bar, which is taller by the bar's height (the top bar
keeps its row at the top). A drag sends no hover events, so the bar keeps
the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls, so
they show in fullscreen (whose route renders only the video and controls).
they stay up when the controls hide.
Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme;
Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s
controls builder wraps both sets in `SessionControlsSwitch`, which follows
the setting live, in the window and in fullscreen. `UoscControls` is our
the setting live. `UoscControls` is our
own frame (no media_kit controls state): mouse movement shows it, 3 s
without any hides it and the cursor; `hold` keeps it up during a timeline
drag and while a menu route is open, double-click toggles fullscreen.
@@ -591,19 +652,20 @@ drag and while a menu route is open, double-click toggles fullscreen.
diamonds on the top edge, current time and duration in boxes inside it, a
hover tip with the chapter title over the hover time; hidden, it shrinks
to a 3 px line with chapter ticks. Above it a row of flat buttons: menu (≡:
change video, people, reload, mark everyone ready, fullscreen, leave),
change video, chat & activity, reload, mark everyone ready, fullscreen,
leave),
subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
chapters, previous / next episode, change video, people, volume,
fullscreen. Menus (`showUoscMenu`: a popup route above the button, the
chapters, previous / next episode, change video, volume, fullscreen; chat
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path;
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are
the same in both sets.
Window chrome is frameless (`window_manager`): every screen brings its own
drag area and min/max/close buttons (Home's top bar; the session uses
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
Window chrome is frameless (`window_manager`): the party strip is the drag
area and holds the min/max/close buttons (`widgets/window_buttons.dart`);
fullscreen has neither.
## Health reporting
@@ -614,7 +676,7 @@ host offline, and starvation ("waiting for X - N.N s buffered").
Errors are logged with a stack trace at the boundary, then turned into a
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so
it carries nothing sensitive. Home's recent-server tiles poll it for live
it carries nothing sensitive. The Join card's recent servers poll it for live
status.
## Updater
@@ -634,7 +696,7 @@ running exe on NTFS. The manifest's `assets` maps `linux-x64` /
`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is
stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
the CLI; `UpdateController` runs the same code for Home's update indicator,
the CLI; `UpdateController` runs the same code for the strip's update indicator,
which opens the update card, and restarts the app ("Update and restart").
Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`.
@@ -667,5 +729,7 @@ this repo, written by `tool/release.sh`.
their machine. Prior art (Plex Relay, Tailscale DERP) relays as a
bandwidth-capped fallback on someone else's servers; here the server is
the owner's, so it carries full quality.
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
simple, costs host uplink; hash-based serving is on the roadmap.
- Avatars by content hash: `state` carries only `avatarHash`; the server keeps
the bytes of current members in memory and serves them at `/a/...`, which
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
small whatever the picture size (cap 256 KiB base64 per avatar).
+17 -11
View File
@@ -64,9 +64,10 @@ and `e2e_check` (simulated viewers against a live server).
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
Traps:
- Never pump the session screen or media_kit `Video` in `flutter test`: the
runner hangs (kill the `flutter_tester` by PID). Test pure logic
(`StateDiff`, coordinator without widgets) instead.
- Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
so shell tests run the real player layout without it; otherwise test pure
logic (`StateDiff`, coordinator without widgets).
- Never depend on a server-side WebSocket `onDone`; assert presence with
deadline polling.
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
@@ -125,14 +126,19 @@ Host. Without the app: `dart run packages/server/bin/server.dart`.
- Overlays that are hidden but mounted keep focus (Space falls into a hidden
button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse
the shared no-focus node for overlay buttons.
- `modifyVolumeOnScroll: false` on the session controls is load-bearing for
window drag (volume is the speaker button only). A `GestureDetector` below
media_kit's overlay loses the gesture arena; the drag layer is a raw pointer
observer on top.
- New screens must bring their own drag area and window buttons (frameless
window).
- `toggleFullscreenOnDoublePress: false` on the session controls is
load-bearing: media_kit's double-click pushes its own fullscreen route with
a second `Video`. A `GestureDetector` below media_kit's overlay never sees
the click (its `MouseRegion` takes the hit) and one around it would delay
every button click (double-tap holds the arena), so the double-click is a
raw pointer observer (`DoubleClickListener`).
- The window is frameless: the party strip is the only drag area and holds
the window buttons.
- Signal-backed singletons (`Profile`, `SessionState`) live across tests;
never cache a `Future` in them (each test runs in its own zone, and a
Future from an earlier test never completes in the next).
- Chat arrives twice from the server (see PROTOCOL.md); the client drops the
textless feed copy.
- Tab traversal is disabled on the session screen on purpose; Tab toggles the
side panel.
- Tab traversal is disabled in the session player on purpose; Tab toggles
chat & activity.
- After editing constants (ports, thresholds), grep to confirm the value landed.
+1 -1
View File
@@ -44,7 +44,7 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
- **Dedicated server**: `watch-together-server`, a separate program in the
release tarball, for an always-on box. See the README.
Friends join either way with the Join card on Home.
Friends join either way with Join in the strip at the top of the window.
## One session, start to finish
+34 -10
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 8)
# Protocol (proto = 9)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -23,12 +23,13 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
serves the host's file (Range table in ARCHITECTURE.md).
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
member's media (see "Relay" below).
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
a member's picture (see "Avatars" below).
## Client to server
```
{"t":"hello","proto":7,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":9,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -42,6 +43,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
{"t":"profile","name":"...","avatar":"<base64>"}
```
Field notes:
@@ -68,8 +70,13 @@ Field notes:
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped.
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
trimmed, empty becomes `?`, cut to 32 characters.
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
characters.
- `profile`: changes this member's name and/or picture mid-session. A field
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
Same rules as `hello.user`; a too large avatar gets `err`
`rejected: avatar too large` (the socket stays open, nothing changes).
- `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
@@ -85,7 +92,7 @@ Field notes:
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale","jf"?}],
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?}],
"seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
@@ -111,19 +118,36 @@ Field notes:
it. `seekBy` (member id) and `seekSeq` are present only while the current
`eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last
`report.jf`, omitted when it never sent one.
`report.jf`, omitted when it never sent one. `members[].avatarHash`
(omitted when the member has no picture) names the picture's bytes (see
"Avatars").
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
`seek`, `pause`, `resume`, `finished`, `chat`. `target` and `reason` are optional;
`reason` carries `auto`/`manual` for ready and `silent too long` or
`no signal` for leave/stale.
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
new name, `target` the old one. `avatar`: the actor changed only the
picture.
- Chat is delivered twice: a `chat` frame with the text and a textless
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
client drops the textless feed entry.
## Avatars
- The picture is sent as base64 in `hello.user.avatar` or `profile.avatar`;
`state` carries only its hash: the first 16 hex characters of the sha256
of the decoded bytes. An avatar that is not valid base64, or whose bytes
are not a PNG, JPEG, WebP or GIF (by magic bytes), counts as none.
- `GET /a/<sid>/<token>/<hash>`: the bytes with their image content type,
`cache-control: private, max-age=31536000, immutable` (a hash names fixed
bytes; clients fetch each hash once, from the host they dialled).
Errors: 401 bad token, 403 kicked, 404 no current member has that
picture, 410 unknown session. Pictures go with their member (leave,
change, removal).
## Jellyfin media
```
-2
View File
@@ -42,8 +42,6 @@ off by deleting it in the same commit as the code. Wire changes also edit
outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
OOM, no stuck session, drift recovers.
- Chat history pane (today: activity log only).
- Avatar caching: serve avatar bytes once by hash instead of base64 in
every `state` broadcast.
- The `state` broadcast carries the full media URL, which can include an
`api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server.
+6 -8
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 8;
static const int protoVersion = 9;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -32,13 +32,11 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5;
// Base64-char count of the avatar string, enforced at hello. 256 KiB
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
// for private-server use. PNG keeps transparency; JPEG did not.
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host
// uplink scales with this cap. Follow-up (not now): send avatar bytes
// once, reference by hash afterwards.
// Base64-char count of the avatar string, enforced at hello and
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
// (raw bitmap <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate
// over-size for private-server use. PNG keeps transparency; JPEG did
// not. Only the hash rides in `state`; the bytes are fetched once.
static const int maxAvatarBytes = 256 * 1024;
static const int maxNameLength = 32;
static const int maxChatLength = 500;
+34 -4
View File
@@ -18,6 +18,14 @@ int finiteInt(Object? value, String field, {int fallback = 0}) {
return n.toInt();
}
/// A member name as shown to everyone: trimmed, cut to
/// [RelayConstants.maxNameLength], `?` when empty.
String cleanMemberName(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
return trimmed.substring(0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
}
class HelloUser {
final String name;
final String avatar;
@@ -450,10 +458,32 @@ class ChatMessage {
}
}
/// Changes this member's name and/or picture mid-session. Null fields stay
/// as they are; an empty [avatar] removes the picture.
class ProfileIntent {
final String? name;
/// Base64 PNG, like `hello.user.avatar`.
final String? avatar;
const ProfileIntent({this.name, this.avatar});
Map<String, Object?> toJson() => {
't': 'profile',
if (name != null) 'name': name,
if (avatar != null) 'avatar': avatar,
};
factory ProfileIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'profile') throw const ProtocolException('not a profile intent');
return ProfileIntent(name: json['name'] as String?, avatar: json['avatar'] as String?);
}
}
class MemberView {
final String id;
final String name;
final String avatar;
/// Content hash of the member's picture, fetched at `/a/...`; empty when
/// none.
final String avatarHash;
final bool ready;
final bool inSet;
final double buffered;
@@ -463,7 +493,7 @@ class MemberView {
const MemberView({
required this.id,
required this.name,
this.avatar = '',
this.avatarHash = '',
required this.ready,
required this.inSet,
required this.buffered,
@@ -474,7 +504,7 @@ class MemberView {
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'avatar': avatar,
if (avatarHash.isNotEmpty) 'avatarHash': avatarHash,
'ready': ready,
'inSet': inSet,
'buffered': buffered,
@@ -485,7 +515,7 @@ class MemberView {
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatar: (json['avatar'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
+79 -4
View File
@@ -1,3 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'protocol.dart';
@@ -14,11 +19,34 @@ Correction correctionFor({required double ownPosition, required double serverPos
return Correction.none;
}
/// Content hash of a picture: the first 16 hex chars of its sha256.
String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substring(0, 16);
/// `(type, subtype)` of a picture by its magic bytes; null for anything
/// that is not PNG, JPEG, WebP or GIF (those are never stored or served).
(String, String)? avatarImageType(List<int> b) {
bool at(int i, List<int> sig) {
if (b.length < i + sig.length) return false;
for (var k = 0; k < sig.length; k++) {
if (b[i + k] != sig[k]) return false;
}
return true;
}
if (at(0, const [0x89, 0x50, 0x4E, 0x47])) return ('image', 'png');
if (at(0, const [0xFF, 0xD8, 0xFF])) return ('image', 'jpeg');
if (at(0, const [0x52, 0x49, 0x46, 0x46]) && at(8, const [0x57, 0x45, 0x42, 0x50])) return ('image', 'webp');
if (at(0, const [0x47, 0x49, 0x46, 0x38])) return ('image', 'gif');
return null;
}
class EngineMember {
final String id;
final String token;
String name;
String avatar;
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
Uint8List? avatarBytes;
String avatarHash = '';
bool ready = false;
bool inSet = false;
double buffered = 0;
@@ -29,14 +57,35 @@ class EngineMember {
required this.id,
required this.token,
required this.name,
this.avatar = '',
String avatar = '',
required DateTime now,
}) : lastSeen = now;
}) : lastSeen = now {
setAvatar(avatar);
}
/// Sets the picture from base64 (empty or undecodable: none). Returns
/// whether it changed.
bool setAvatar(String base64) {
Uint8List? bytes;
if (base64.isNotEmpty) {
try {
bytes = base64Decode(base64);
} on FormatException {
bytes = null;
}
}
if (bytes != null && avatarImageType(bytes) == null) bytes = null;
final hash = bytes == null || bytes.isEmpty ? '' : avatarHashOf(bytes);
if (hash == avatarHash) return false;
avatarHash = hash;
avatarBytes = hash.isEmpty ? null : bytes;
return true;
}
MemberView view({required bool stale}) => MemberView(
id: id,
name: name,
avatar: avatar,
avatarHash: avatarHash,
ready: ready,
inSet: inSet,
buffered: buffered,
@@ -129,6 +178,32 @@ class SyncEngine {
return id;
}
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
/// empty [avatar] removes the picture. [name] is already cleaned.
void applyProfile(String token, {String? name, String? avatar}) {
final member = members[token];
if (member == null) return;
_seen(member);
final oldName = member.name;
final renamed = name != null && name != oldName;
if (renamed) member.name = name;
final pictured = avatar != null && member.setAvatar(avatar);
if (renamed) {
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
} else if (pictured) {
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
}
}
/// Picture bytes of a current member by [EngineMember.avatarHash].
Uint8List? avatarFor(String hash) {
if (hash.isEmpty) return null;
for (final member in members.values) {
if (member.avatarHash == hash) return member.avatarBytes;
}
return null;
}
/// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) {
final member = members[token];
+54
View File
@@ -537,4 +537,58 @@ void main() {
expect(s.seekBy, isNull);
});
});
group('profile + avatars', () {
// Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped.
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]);
final other = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => 255 - i)]);
test('join keeps bytes, state carries only the hash', () {
engine.applyJoin('a', 'alice', png);
final hash = engine.members['a']!.avatarHash;
expect(hash, hasLength(16));
expect(engine.avatarFor(hash), base64Decode(png));
final view = engine.broadcast().members.single;
expect(view.avatarHash, hash);
expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(png)));
join('b', 'bob');
expect(engine.members['b']!.avatarHash, isEmpty);
engine.applyLeave('a');
expect(engine.avatarFor(hash), isNull, reason: 'dropped with the member');
});
test('a picture that is not an image is dropped', () {
final e = SyncEngine();
e.applyJoin('x', 'x', base64Encode(List<int>.generate(64, (i) => i)));
expect(e.members['x']!.avatarHash, isEmpty);
expect(avatarImageType([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), ('image', 'webp'));
});
test('rename feeds rename (actor new, target old); picture only feeds avatar', () {
engine.applyJoin('a', 'alice', png);
engine.feed.drain();
engine.applyProfile('a', name: 'bob');
var feed = engine.feed.drain().single;
expect(feed.action, 'rename');
expect(feed.actor, 'bob');
expect(feed.target, 'alice');
engine.applyProfile('a', avatar: other);
feed = engine.feed.drain().single;
expect(feed.action, 'avatar');
expect(feed.actor, 'bob');
expect(engine.members['a']!.avatarHash, avatarHashOf(base64Decode(other)));
engine.applyProfile('a', name: 'carol', avatar: '');
feed = engine.feed.drain().single;
expect(feed.action, 'rename');
expect(engine.members['a']!.avatarHash, isEmpty);
expect(engine.members['a']!.avatarBytes, isNull);
engine.applyProfile('a', name: 'carol', avatar: '');
expect(engine.feed.drain(), isEmpty, reason: 'nothing changed');
engine.applyProfile('a', avatar: 'not base64!');
expect(engine.members['a']!.avatarHash, isEmpty);
});
});
}
+45 -1
View File
@@ -159,6 +159,37 @@ class ServerHub {
}
}
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
/// content hash, for members (valid, unkicked token) only. The bytes for a
/// hash never change, so clients may cache them for good.
void mountAvatarRoute(RelicApp app, ServerHub hub) {
Response status(int code) => Response(code, body: Body.fromString('status $code'));
app.get('/a/:sid/:token/:hash', (final req) {
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final hash = req.pathParameters.raw[#hash] ?? '';
final session = hub.sessions.get(sid);
if (session == null) return status(410);
switch (session.gate(token)) {
case MediaGate.unauthorized:
return status(401);
case MediaGate.forbidden:
return status(403);
default:
break;
}
final bytes = hub.engine.avatarFor(hash);
final type = bytes == null ? null : avatarImageType(bytes);
if (bytes == null || type == null) return status(404);
return Response.ok(
body: Body.fromData(bytes, mimeType: MimeType(type.$1, type.$2)),
headers: Headers.build((h) {
h['cache-control'] = ['private, max-age=31536000, immutable'];
}),
);
});
}
/// Media names are shown to everyone and must never carry a URL (api_key etc.).
/// A URL-looking name is reduced to its last path segment, no query/fragment.
String sanitizeMediaName(String name) {
@@ -358,6 +389,19 @@ void mountSyncRoute(
mediaId: hub.engine.mediaId);
}
hub.changed();
case 'profile':
if (token == null || viewer == null) return;
final intent = ProfileIntent.fromJson(decoded);
final avatar = intent.avatar;
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
wsErr(ws, 'rejected: avatar too large');
return;
}
final name = intent.name == null ? null : cleanMemberName(intent.name!);
// Chat and kick name the sender by its token's name.
if (name != null) viewer.name = name;
hub.engine.applyProfile(token, name: name, avatar: avatar);
hub.changed();
case 'report':
if (token == null) return;
final report = ReportMessage.fromJson(decoded);
@@ -470,7 +514,7 @@ void mountSyncRoute(
await close();
return;
}
memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
}
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
final activeToken = token;
+5 -9
View File
@@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline }
class ViewerToken {
final String token;
final String name;
final String avatar;
/// Cleaned ([cleanMemberName]); follows `profile` renames.
String name;
bool kicked = false;
/// Sent the server's host key in hello: the hosting app's own client.
bool isHost = false;
int bytesServed = 0;
DateTime lastSeen;
Future<void> Function(String reason)? onKick;
ViewerToken({required this.token, required this.name, this.avatar = ''})
ViewerToken({required this.token, required this.name})
: lastSeen = DateTime.now().toUtc();
bool isStale(Duration timeout, [DateTime? now]) =>
@@ -53,18 +53,14 @@ class Session {
Session({required this.id, required this.secret});
/// The avatar is only checked here; the engine keeps the picture.
String issueToken(String name, String avatar) {
if (avatar.length > RelayConstants.maxAvatarBytes) {
throw const AuthException('avatar too large');
}
final trimmed = name.trim();
final cleanName = trimmed.isEmpty
? '?'
: trimmed.substring(
0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar);
tokens[token] = ViewerToken(token: token, name: cleanMemberName(name));
return token;
}
+1
View File
@@ -64,6 +64,7 @@ class SyncServer {
});
mountSessionMediaRoute(_app, sessions);
mountRelayRoutes(_app, sessions, () => hub.relay);
mountAvatarRoute(_app, hub);
mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
@@ -389,5 +389,93 @@ void main() {
await ch.sink.close();
} catch (_) {}
});
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final messages = <Map<String, Object?>>[];
final sub = ch.stream.listen(
(m) => messages.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
Future<Map<String, Object?>> waitFor(bool Function(Map<String, Object?>) test) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(deadline)) {
for (final m in messages) {
if (test(m)) return m;
}
await Future.delayed(const Duration(milliseconds: 20));
}
fail('timed out; got $messages');
}
Map<String, Object?> member(Map<String, Object?> state) =>
Map<String, Object?>.from((state['members'] as List).single as Map);
Future<HttpClientResponse> avatarGet(String token, String hash) async =>
(await http.get('127.0.0.1', server.boundPort, '/a/default/$token/$hash')).close();
try {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
final welcome = await waitFor((m) => m['t'] == 'welcome');
final token = welcome['token'] as String;
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
expect(first.containsKey('avatar'), isFalse, reason: 'no base64 in state');
final hash = first['avatarHash'] as String;
expect(hash, avatarHashOf(png));
final ok = await avatarGet(token, hash);
expect(ok.statusCode, 200);
expect(ok.headers.contentType?.mimeType, 'image/png');
expect(ok.headers.value('cache-control'), contains('max-age'));
expect(await ok.expand((c) => c).toList(), png);
final unknown = await avatarGet(token, '0123456789abcdef');
expect(unknown.statusCode, 404);
await unknown.drain();
final noToken = await avatarGet('nope', hash);
expect(noToken.statusCode, 401);
await noToken.drain();
// Rename: feed rename (actor new, target old); chat uses the new name.
messages.clear();
ch.sink.add(jsonEncode(const ProfileIntent(name: ' bob ').toJson()));
final rename = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'rename');
expect(rename['actor'], 'bob');
expect(rename['target'], 'alice');
final renamed = await waitFor((m) => m['t'] == 'state' && member(m)['name'] == 'bob');
expect(member(renamed)['avatarHash'], hash, reason: 'picture unchanged');
ch.sink.add(jsonEncode(const ChatMessage(text: 'hi').toJson()));
expect((await waitFor((m) => m['t'] == 'chat'))['from'], 'bob');
// Picture only: feed avatar; removing it drops the hash and the bytes.
messages.clear();
ch.sink.add(jsonEncode(const ProfileIntent(avatar: '').toJson()));
final pic = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'avatar');
expect(pic['actor'], 'bob');
await waitFor((m) => m['t'] == 'state' && !member(m).containsKey('avatarHash'));
final gone = await avatarGet(token, hash);
expect(gone.statusCode, 404);
await gone.drain();
// Too large: rejected, nothing changes.
messages.clear();
ch.sink.add(jsonEncode(
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
final err = await waitFor((m) => m['t'] == 'err');
expect(err['message'], 'rejected: avatar too large');
await server.session.kick(token);
final kicked = await avatarGet(token, hash);
expect(kicked.statusCode, 403);
await kicked.drain();
} finally {
await sub.cancel();
try {
await ch.sink.close();
} catch (_) {}
}
});
});
}
+16 -3
View File
@@ -47,7 +47,7 @@ void main() {
final s = Session(id: 'default', secret: 'shh');
final png = 'x' * (64 * 1024);
expect(s.issueToken('alice', png), isNotEmpty);
expect(s.tokens.values.single.avatar, hasLength(64 * 1024));
expect(s.tokens, hasLength(1));
});
test('names truncated to cap, blank becomes ?', () {
@@ -80,8 +80,21 @@ void main() {
});
group('core welcome + proto', () {
test('proto 8: relayed media', () {
expect(RelayConstants.protoVersion, 8);
test('proto 9: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 9);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);
const h = MemberView(
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
expect(p.name, isNull);
expect(p.avatar, '');
});
test('relayed media', () {
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
expect(back.relay!.source, 'm1');