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

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

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

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

No files matched your search

+76 -44
View File
@@ -28,7 +28,7 @@ From the Releases page of this repo:
Windows desktop; reports welcome. Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator 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 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: 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 `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 must run the same protocol version, and a mismatch is refused with a message
telling you to update. 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 ## Host from the app
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked Host in the strip: set a port and an optional password (blank = anyone can
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field join), then Host. The app starts a server inside itself and joins it; you
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank = are the host and nobody can kick you. Nothing is up yet: pick what to
anyone can join), then Host. Your name and avatar come from your profile watch in the Library tab (below) and "Put it up for everyone". Leaving or
(the chip at the top left). The app starts a closing the app stops the server. Friends need to reach your machine: open
server inside itself and joins it; you are the host and nobody can kick you. the TCP port on your router (or use a LAN or VPN address). Friends open
Leaving or closing the app stops the server. Friends need to reach your machine: open nothing. Ports you hosted on before are one click away in the Host card.
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
If you pick a local file, the server streams it to everyone from your disk. If you put up a local file, the server streams it to everyone from your
Nothing is uploaded or stored anywhere. 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 ## Dedicated server
@@ -77,29 +109,30 @@ To run it as a service, unpack the tarball to `/opt/watch-together` and use
## Join ## Join
Home, Join tab. Enter the host's address, port, and the password if there Join in the strip. Servers you got into are listed first (up to 6), with
is one, then Enter or Join. Your name and avatar come from your profile (top live status (online and how many are watching, or offline, plus "needs
bar chip or Settings). Servers you got into appear as up to 6 Recent server update" when the server runs another protocol): click one to rejoin, x to
tiles with live status (online and how many are watching, or offline, plus forget it. Or enter the host's address, port, and the password if there
"needs update" when the server runs another protocol): click one to rejoin, is one, then Enter or Join. Your name and picture come from your profile
x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host. (your avatar at the top left).
## Settings ## Settings
Profile, hardware decoding (auto, off or custom; the env var The Settings tab (Ctrl+,) has a page per topic: Profile; Player (hardware
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video), decoding: auto, off or custom, the env var `SYNC_RELAY_HWDEC` overrides
"Experimental: mpv-style controls" (below), updates, playing the next it, applies on the next join or Reload video; "Experimental: mpv-style
Jellyfin episode automatically, video shaders (see below), and the controls", below; video shaders, see below); Jellyfin & integrations
keyboard shortcuts (read-only). (linked Jellyfin servers, below, and playing the next episode
Integrations links Jellyfin servers (below). About shows the version. automatically); Updates; Keyboard shortcuts (read-only); About (the
version).
Keys in a session: Space = ready / unready, Tab = side panel (people, log, Keys while the player fills the window: Space = ready / unready, Tab =
chat, change video), Left/Right = seek 10 s, Shift+Right = skip 1:30, chat & activity, Left/Right = seek 10 s, Shift+Right = skip 1:30,
PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc =
close panel or ask to leave, Ctrl+R = reload video, `<` / `>` or Shift+P / close the chat or fullscreen, else ask to leave, Ctrl+R = reload video,
Shift+N = previous / next Jellyfin episode, Ctrl+1..6 = Anime4K mode A, B, `<` / `>` or Shift+P / Shift+N = previous / next Jellyfin episode,
C, A+A, B+B, C+A, Ctrl+0 = shaders off. Every seek, skip and chapter jump Ctrl+1..6 = Anime4K mode A, B, C, A+A, B+B, C+A, Ctrl+0 = shaders off.
moves everyone. Every seek, skip and chapter jump moves everyone.
Controls: the seek bar marks chapter starts with a diamond; hovering it Controls: the seek bar marks chapter starts with a diamond; hovering it
shows the time and the chapter title. The chapters button lists them 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 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 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"), track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
ready, chapters, 1:30, previous / next episode, change video, people, ready, chapters, 1:30, previous / next episode, change video, volume and
volume and fullscreen. Keys and skip buttons work the same. 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 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 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 ## 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 (the app shows a code; approve it in Jellyfin under Profile >
Quick Connect) or your username and password. Several servers can be Quick Connect) or your username and password. Several servers can be
linked; each shows its status, and a star picks the default. Sign out 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 revokes the app's sign-in on the server. If a sign-in expires the server
shows "Reconnect" instead of disappearing. 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 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 grids with sort (name, date added, release date, rating, random) and
Unwatched / Favorites filters; series pages with seasons, episode progress Unwatched / Favorites filters; series pages with seasons, episode progress
and a "Next up" button; details with the file's tracks; search. Clicking a 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 movie or episode picks it. On wide windows a side pane shows details of
and moves port, password and Host into a bar below it. On wide windows a whatever is under the mouse.
side pane shows details of whatever is under the mouse.
Keys in the browser: arrows move, Enter picks or opens, I = details, 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 = Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
search. search.
Host from the library: pick a movie or episode and Host. Only the item Putting up a movie or episode sends only the item to the server (which
goes to the server (which Jellyfin server and which item), never a link or Jellyfin server and which item), never a link or your sign-in. Pasting a Jellyfin link (web page or stream link) does the
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 same when the app can reach that Jellyfin; otherwise it is shared as a
plain link. plain link.
@@ -172,7 +204,7 @@ Watching:
- Friends without an account watch through the server: whoever put the - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never 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 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 or a dedicated server has its own login (`--jellyfin <file>`, see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
fetches it itself instead. 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 match (language, then title, then codec and channels) is selected on the
next episode. This is per person; nobody else is affected. next episode. This is per person; nobody else is affected.
"Change video" in the session panel (Tab) opens the same picker as the Host "Change video" in the player's controls opens the Library tab (the player
tab: the library starts on the series you are watching with the current goes mini): the library starts on the series you are watching with the
season, else where you left it. current season, else where you left it.
Anyone can put up a file from their own computer, also on a dedicated 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 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 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 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 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 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 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: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/home/ui.dart';
import 'src/screens/session_screen.dart'; import 'src/shell/app_shell.dart';
import 'src/state/session_state.dart';
class WatchTogetherApp extends StatelessWidget { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance;
return MaterialApp( return MaterialApp(
title: appName, title: appName,
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: SignalBuilder( home: const AppShell(),
builder: (context) {
return switch (session.route.value) {
AppRoute.home => const HomeScreen(),
AppRoute.session => const SessionScreen(),
};
},
),
); );
} }
} }
@@ -6,11 +6,10 @@ import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
// The experimental mpv-style (uosc) controls frame: a full-width timeline // 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 // right. Mouse movement shows them; after [UoscControls.hideAfter] without
// any they fade out, the timeline shrinks to a thin line and the cursor // any they fade out, the timeline shrinks to a thin line and the cursor
// hides. Everything lives here because media_kit's fullscreen route // hides. Our own frame: no media_kit controls state to reach into.
// renders only the video and its controls.
class UoscControls extends StatefulWidget { class UoscControls extends StatefulWidget {
/// The timeline; [expanded] is false while the controls are hidden. /// 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. /// Buttons left and right of the row above the timeline.
final List<Widget> left; final List<Widget> left;
final List<Widget> right; 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 Widget? top;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final Duration hideAfter; final Duration hideAfter;
+1 -1
View File
@@ -207,7 +207,7 @@ class JellyfinAccounts extends ChangeNotifier {
/// Last failure text for an offline account. /// Last failure text for an offline account.
String? errorOf(String serverId) => _errors[serverId]; 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; JellyfinMedia? get lastPick => _lastPick;
/// Stable per install: Jellyfin keys devices and tokens by it. /// Stable per install: Jellyfin keys devices and tokens by it.
@@ -74,7 +74,7 @@ class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
Text('Jellyfin', style: t.titleMedium), Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( 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.', 'Sign in with Quick Connect or your password; several servers are fine.',
style: TextStyle(color: Colors.grey), style: TextStyle(color: Colors.grey),
), ),
+1 -1
View File
@@ -91,7 +91,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
const [HomeRoute()]; const [HomeRoute()];
/// Every navigation is saved per server (Hive), so the next browser /// 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() { void _remember() {
final id = _account?.serverId; final id = _account?.serverId;
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack))); 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. /// Seeks from the video controls (seek bar) go here instead of to mpv.
void Function(Duration position)? onUserSeek; 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); final shaderNotice = signal<ShaderNotice?>(null);
/// How media_kit renders the current player's video (shaders need /// 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; final UpdateController? updates;
const AboutPage({super.key, this.updates}); const AboutPanel({super.key, this.updates});
Future<void> _open(BuildContext context) async { Future<void> _open(BuildContext context) async {
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);
@@ -40,11 +41,7 @@ class AboutPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
return PageScroll( return Panel(
maxWidth: 680,
children: [
const PageTitle('About'),
Panel(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -73,8 +70,6 @@ class AboutPage extends StatelessWidget {
), ),
], ],
), ),
), );
],
);
} }
} }
@@ -1,12 +1,8 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show HelloMedia;
import '../../session/session_coordinator.dart'; import '../../session/session_coordinator.dart';
import '../../state/session_state.dart'; import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart'; import 'profile.dart';
import '../../widgets/media_input.dart';
/// What a card shows under its fields after a failed attempt. /// What a card shows under its fields after a failed attempt.
class CardError { class CardError {
@@ -15,7 +11,7 @@ class CardError {
const CardError(this.title, this.message); 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]. /// [cancel] abandons it via [SessionState.cancelPending].
class ConnectFlow extends ChangeNotifier { class ConnectFlow extends ChangeNotifier {
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance; ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
@@ -27,7 +23,8 @@ class ConnectFlow extends ChangeNotifier {
int _attempt = 0; int _attempt = 0;
bool _disposed = false; 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; void Function(String reason)? onKicked;
@override @override
@@ -54,61 +51,38 @@ class ConnectFlow extends ChangeNotifier {
} }
/// Validates and builds the params; throws [FormatException] with a /// Validates and builds the params; throws [FormatException] with a
/// user-facing message. /// user-facing message. [avatar] is the base64 picture (the profile's).
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async { /// 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 rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
final port = int.tryParse(rawPort.trim()); final port = int.tryParse(rawPort.trim());
if (port == null || port <= 0 || port > 65535) { if (port == null || port <= 0 || port > 65535) {
throw const FormatException('port must be 1–65535'); throw const FormatException('port must be 1–65535');
} }
if (f.name.trim().isEmpty) { 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) { if (mode == ConnectMode.join && f.host.trim().isEmpty) {
throw const FormatException('enter the server address'); 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( return ConnectParams(
host: f.host.trim(), host: f.host.trim(),
port: port, port: port,
secret: mode == ConnectMode.host ? f.hostSecret : f.secret, secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
name: f.name.trim(), name: f.name.trim(),
avatar: avatar, 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 /// [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 /// other card's saved values survive.
/// instead of the form's link or file (a Jellyfin ref; the pick itself is Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
/// remembered in the Jellyfin box, not the form).
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{HelloMedia? media}) async {
if (busy != null) return; if (busy != null) return;
final myAttempt = ++_attempt; final myAttempt = ++_attempt;
final form = _session.lastForm.copy(); final form = _session.lastForm.copy();
edit(form); edit(form);
form.name = form.name.trim(); form.name = form.name.trim();
form.host = form.host.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; busy = mode;
errors[mode] = null; errors[mode] = null;
notices[mode] = null; notices[mode] = null;
@@ -116,8 +90,9 @@ class ConnectFlow extends ChangeNotifier {
ConnectParams params; ConnectParams params;
try { try {
params = await buildParams(mode, form); final profile = Profile.instance;
if (media != null && mode == ConnectMode.host) params = params.withMedia(media); await profile.ensureLoaded();
params = buildParams(mode, form, avatar: profile.avatarBase64());
} catch (e) { } catch (e) {
if (myAttempt != _attempt) return; if (myAttempt != _attempt) return;
busy = null; 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 } enum MediaKind { jellyfin, link, file }
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab, /// State of a [MediaPicker], owned by whoever hosts it (the shell, for the
/// later the session's swap panel) so it survives rebuilds. /// Library tab) so it survives rebuilds and tab switches.
class MediaPickerController extends ChangeNotifier { class MediaPickerController extends ChangeNotifier {
MediaPickerController( MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source}) {String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
+87 -241
View File
@@ -1,285 +1,131 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import '../../state/connect_prefs.dart';
import '../../state/session_state.dart'; import '../../state/session_state.dart';
import '../../widgets/avatar_pipeline.dart'; import '../../widgets/avatar_pipeline.dart';
import 'ui.dart';
/// Display name + avatar, shared by the Home profile row and Settings. /// Colours offered in the profile card; the first is the default.
/// Backed by [SessionState.lastForm]; every change is persisted. The const profileColors = <Color>[
/// avatar goes through [encodeAvatar] (the same shrink the server gets) Colors.deepPurple,
/// before its path is stored, and the encoded bytes are the preview. 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 { class Profile {
static final Profile instance = Profile._(); static final Profile instance = Profile._();
Profile._(); Profile._();
final name = signal(''); 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 error = signal<String?>(null);
final picking = signal(false); ProfilePush push = pushProfileToSession;
bool _loaded = false; 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; if (_loaded) return;
_loaded = true; _loaded = true;
final f = SessionState.instance.lastForm; await _load();
name.value = f.name;
_refreshPreview(f.avatarPath);
} }
Future<void> _refreshPreview(String path) async { Future<void> _load() async {
final form = SessionState.instance.lastForm;
name.value = form.name;
try { try {
final b64 = await encodeAvatar(path); final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
preview.value = b64.isEmpty ? null : base64Decode(b64); final png = box.get(_keyPicture);
} catch (_) { final c = box.get(_keyColor);
preview.value = null; 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) { void setName(String value) {
name.value = value.trim(); final v = value.trim();
SessionState.instance.lastForm.name = value.trim(); if (v.isEmpty || v == name.peek()) return;
_save(); 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 { Future<void> setPicture(Uint8List? png) async {
if (picking.peek()) return; picture.value = png;
picking.value = true;
error.value = null; error.value = null;
await _save();
try { try {
final List<PlatformFile> files; push(avatar: png == null ? '' : avatarWireBase64(png));
try { } on FormatException catch (e) {
files = await FilePicker.pickFiles( error.value = e.message;
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
error.value = 'file dialog unavailable — try again';
return;
}
final path = files.isEmpty ? null : files.first.path;
if (path == null) return;
final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
error.value = e.message;
return;
} catch (_) {
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
return;
}
SessionState.instance.lastForm.avatarPath = path;
preview.value = b64.isEmpty ? null : base64Decode(b64);
await _save();
} finally {
picking.value = false;
} }
} }
Future<void> clearAvatar() async { Future<void> setColor(Color c) async {
SessionState.instance.lastForm.avatarPath = ''; color.value = c;
preview.value = null;
error.value = null;
await _save(); await _save();
} }
Future<void> _save() async { Future<void> _save() async {
try { try {
await SessionState.instance.saveForm(); 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 (_) { } catch (_) {
error.value = 'could not save profile — try again'; 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 '../../shaders/shader_presets.dart';
import '../../state/settings.dart'; import '../../state/settings.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import 'profile.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.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 = [ const sessionShortcuts = [
('Space', 'Toggle ready'), ('Space', 'Toggle ready'),
('← / →', 'Seek back / forward 10 s'), ('← / →', 'Seek back / forward 10 s'),
('Shift+→', 'Skip 1:30 (for everyone)'), ('Shift+→', 'Skip 1:30 (for everyone)'),
('PgUp / PgDn', 'Previous / next chapter'), ('PgUp / PgDn', 'Previous / next chapter'),
('F', 'Toggle fullscreen'), ('F', 'Toggle fullscreen'),
('Tab', 'Open / close the people & chat panel'), ('Tab', 'Open / close chat & activity'),
('Ctrl+R', 'Reload the video'), ('Ctrl+R', 'Reload the video'),
('> or Shift+N', 'Put up the next Jellyfin episode'), ('> or Shift+N', 'Put up the next Jellyfin episode'),
('< or Shift+P', 'Put up the previous 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+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'),
('Ctrl+0', 'Shaders off'), ('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 { /// One titled panel of the Settings tab.
final UpdateController? updates; class SettingsPanel extends StatelessWidget {
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 {
final String title; final String title;
final Widget child; final Widget child;
const _Section({required this.title, required this.child}); const SettingsPanel({super.key, required this.title, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -72,14 +46,14 @@ class _Section extends StatelessWidget {
} }
} }
class _HwdecSetting extends StatefulWidget { class HwdecSetting extends StatefulWidget {
const _HwdecSetting(); const HwdecSetting({super.key});
@override @override
State<_HwdecSetting> createState() => _HwdecSettingState(); State<HwdecSetting> createState() => _HwdecSettingState();
} }
class _HwdecSettingState extends State<_HwdecSetting> { class _HwdecSettingState extends State<HwdecSetting> {
final _s = AppSettings.instance; final _s = AppSettings.instance;
late final _custom = TextEditingController(text: _s.hwdecCustom.peek()); late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
@@ -130,8 +104,8 @@ class _HwdecSettingState extends State<_HwdecSetting> {
} }
} }
class _ShaderSettings extends StatelessWidget { class ShaderSettings extends StatelessWidget {
const _ShaderSettings(); const ShaderSettings({super.key});
Future<void> _addFiles() async { Future<void> _addFiles() async {
final List<PlatformFile> files; final List<PlatformFile> files;
@@ -249,9 +223,9 @@ class _ShaderSettings extends StatelessWidget {
} }
} }
class _UpdateSettings extends StatelessWidget { class UpdateSettings extends StatelessWidget {
final UpdateController? controller; final UpdateController? controller;
const _UpdateSettings({this.controller}); const UpdateSettings({super.key, this.controller});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -301,8 +275,8 @@ class _UpdateSettings extends StatelessWidget {
} }
} }
class _MpvControlsSetting extends StatelessWidget { class MpvControlsSetting extends StatelessWidget {
const _MpvControlsSetting(); const MpvControlsSetting({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -321,8 +295,8 @@ class _MpvControlsSetting extends StatelessWidget {
} }
} }
class _AutoAdvanceSetting extends StatelessWidget { class AutoAdvanceSetting extends StatelessWidget {
const _AutoAdvanceSetting(); const AutoAdvanceSetting({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -341,8 +315,8 @@ class _AutoAdvanceSetting extends StatelessWidget {
} }
} }
class _ShortcutList extends StatelessWidget { class ShortcutList extends StatelessWidget {
const _ShortcutList(); const ShortcutList({super.key});
@override @override
Widget build(BuildContext context) { 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 kRadius = 12.0;
const kGap = 16.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 { class Panel extends StatelessWidget {
final Widget child; final Widget child;
final EdgeInsetsGeometry padding; 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. /// Small grey heading inside a panel.
class SectionLabel extends StatelessWidget { class SectionLabel extends StatelessWidget {
final String text; 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 fieldDecoration(String label, {String? hint, Widget? suffix}) =>
InputDecoration( InputDecoration(
labelText: label, labelText: label,
@@ -97,31 +69,6 @@ InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), 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. /// Widest a single text field gets, however wide the window.
const kFieldMaxWidth = 720.0; const kFieldMaxWidth = 720.0;
@@ -4,7 +4,7 @@ import '../../update/update_controller.dart';
import '../../widgets/update_banner.dart'; import '../../widgets/update_banner.dart';
import 'ui.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]. /// something to install. Click opens the full [UpdateBanner].
class UpdateIndicator extends StatelessWidget { class UpdateIndicator extends StatelessWidget {
final UpdateController? controller; final UpdateController? controller;
@@ -4,7 +4,7 @@ import '../../jellyfin/session_media.dart';
import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs; import '../../session/session_coordinator.dart' show AutoAdvance, autoAdvanceSecs;
import 'player_controls.dart' show FloatingPill; 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. // coordinator's signals, so tests can pump them without the player.
/// Previous / next episode in the video controls. Nothing when there is /// 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 '../../jellyfin/browse_scope.dart' show BrowseStart;
import '../home/media_picker.dart'; import '../home/media_picker.dart';
/// "Change video" in a session: the same [MediaPicker] as Home's Host tab /// The Library tab's body: the [MediaPicker] (Jellyfin browser, Link,
/// (Jellyfin browser, Link, File) and a button that puts the pick up for /// File) and a button that puts the pick up for everyone. A file streams from this computer: the host's app
/// everyone. A file streams from this computer: the host's app serves it, /// serves it, anyone else's goes through the server's relay. [onSwap]
/// anyone else's goes through the server's relay. [onSwap] returns an /// returns an error text, or null when sent.
/// error text, or null when sent.
class SwapPanel extends StatefulWidget { class SwapPanel extends StatefulWidget {
final MediaPickerController controller; final MediaPickerController controller;
final JellyfinAccounts accounts; final JellyfinAccounts accounts;
final bool isHost; final bool isHost;
final BrowseStart? jellyfinStart; final BrowseStart? jellyfinStart;
final Future<String?> Function(PickedMedia media) onSwap; final Future<String?> Function(PickedMedia media) onSwap;
final VoidCallback onClose;
/// After a pick went up.
final VoidCallback? onDone;
final VoidCallback? onOpenIntegrations; 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({ const SwapPanel({
super.key, super.key,
required this.controller, required this.controller,
required this.accounts, required this.accounts,
required this.isHost, required this.isHost,
required this.onSwap, required this.onSwap,
required this.onClose, this.onDone,
this.jellyfinStart, this.jellyfinStart,
this.onOpenIntegrations, this.onOpenIntegrations,
this.disabledHint,
}); });
@override @override
@@ -38,7 +44,7 @@ class _SwapPanelState extends State<SwapPanel> {
Future<void> _swap() async { Future<void> _swap() async {
final media = widget.controller.selection; final media = widget.controller.selection;
if (media == null || _busy) return; if (media == null || _busy || widget.disabledHint != null) return;
setState(() { setState(() {
_busy = true; _busy = true;
_error = null; _error = null;
@@ -54,79 +60,79 @@ class _SwapPanelState extends State<SwapPanel> {
_busy = false; _busy = false;
_error = error; _error = error;
}); });
if (error == null) widget.onClose(); if (error == null) widget.onDone?.call();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final t = Theme.of(context).textTheme;
return Material( final hint = widget.disabledHint;
return Padding(
key: const ValueKey('swap-panel'), key: const ValueKey('swap-panel'),
elevation: 12, padding: const EdgeInsets.fromLTRB(20, 16, 12, 16),
borderRadius: BorderRadius.circular(16), child: Column(
clipBehavior: Clip.antiAlias, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Padding( children: [
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16), Expanded(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.only(right: 8),
children: [ child: MediaPicker(
Row( controller: widget.controller,
children: [ jellyfin: widget.accounts,
Expanded(child: Text('Change video', style: t.titleLarge)), enabled: !_busy,
IconButton( linkRelayChoice: true,
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose), fileHelp: widget.isHost
], ? 'Friends stream it from your computer.'
), : 'Streams from your computer through the server. Keep the app open while it plays.',
const SizedBox(height: 8), jellyfinStart: widget.jellyfinStart,
Expanded( onOpenIntegrations: widget.onOpenIntegrations,
child: Padding( onSubmit: _swap,
padding: const EdgeInsets.only(right: 8),
child: MediaPicker(
controller: widget.controller,
jellyfin: widget.accounts,
enabled: !_busy,
linkRelayChoice: true,
fileHelp: widget.isHost
? 'Friends stream it from your computer.'
: 'Streams from your computer through the server. Keep the app open while it plays.',
jellyfinStart: widget.jellyfinStart,
onOpenIntegrations: widget.onOpenIntegrations,
onSubmit: _swap,
),
), ),
), ),
const SizedBox(height: 12), ),
ListenableBuilder( const SizedBox(height: 12),
listenable: widget.controller, ListenableBuilder(
builder: (context, _) { listenable: widget.controller,
final sel = widget.controller.selection; builder: (context, _) {
return Row( final sel = widget.controller.selection;
children: [ return Row(
Expanded( children: [
child: Text( Expanded(
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'), child: Text(
maxLines: 2, _error ??
overflow: TextOverflow.ellipsis, hint ??
style: t.bodyMedium?.copyWith( (sel == null
color: _error != null ? Theme.of(context).colorScheme.error : null), ? 'Pick something to watch'
: 'Put up: ${sel.label}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: _error != null
? Theme.of(context).colorScheme.error
: null,
), ),
), ),
const SizedBox(width: 12), ),
FilledButton.icon( const SizedBox(width: 12),
key: const ValueKey('swap-go'), FilledButton.icon(
onPressed: sel == null || _busy ? null : _swap, key: const ValueKey('swap-go'),
icon: _busy onPressed: sel == null || _busy || hint != null
? const SizedBox( ? null
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : _swap,
: const Icon(Icons.swap_horiz), icon: _busy
label: const Text('Put it up for everyone'), ? const SizedBox(
), width: 16,
], height: 16,
); child: CircularProgressIndicator(strokeWidth: 2),
}, )
), : const Icon(Icons.swap_horiz),
], label: const Text('Put it up for everyone'),
), ),
],
);
},
),
],
), ),
); );
} }
File diff suppressed because it is too large. Load diff
@@ -14,6 +14,7 @@ import '../playback/playback_controller.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart'; import '../sfx/sfx.dart';
import '../state/settings.dart'; import '../state/settings.dart';
import '../sync/avatar_cache.dart';
import '../sync/state_diff.dart'; import '../sync/state_diff.dart';
import '../sync/sync_client.dart'; import '../sync/sync_client.dart';
import '../sync/user_seek.dart'; import '../sync/user_seek.dart';
@@ -140,6 +141,8 @@ class SessionCoordinator {
/// null when this app is not the host. /// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile; Future<({String name, int size})?> Function(String path)? hostFile;
final SyncClient sync = SyncClient(); final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
final PlaybackController playback = PlaybackController(); final PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff(); final StateDiff diff = StateDiff();
@@ -953,6 +956,26 @@ class SessionCoordinator {
sync.sendChat(text); 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 = ''}) { void kick(String memberId, {String reason = ''}) {
markInput(); markInput();
sync.sendKick(memberId, reason: reason); sync.sendKick(memberId, reason: reason);
@@ -1060,6 +1083,10 @@ class SessionCoordinator {
// ------------------------------------------------------------- jellyfin // ------------------------------------------------------------- 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) { void _pushToast(String text) {
_toastId++; _toastId++;
toasts.value = [ 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<String> get errors => _errors.stream;
Stream<SyncStatus> get status => _status.stream; Stream<SyncStatus> get status => _status.stream;
/// The `/sync` URL last dialled; its host serves `/a/...` and the media.
Uri? wsUri;
String token = ''; String token = '';
String memberId = ''; String memberId = '';
String? mediaUrl; String? mediaUrl;
@@ -53,6 +55,7 @@ class SyncClient {
}) async { }) async {
await disconnect(); await disconnect();
clockSync.reset(); clockSync.reset();
this.wsUri = wsUri;
_status.add(SyncStatus.connecting); _status.add(SyncStatus.connecting);
final ch = WebSocketChannel.connect(wsUri); final ch = WebSocketChannel.connect(wsUri);
unawaited(ch.ready.then((_) {}, onError: (_) {})); unawaited(ch.ready.then((_) {}, onError: (_) {}));
@@ -240,6 +243,13 @@ class SyncClient {
_send(ChatMessage(text: clean).toJson()); _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 = ''}) { void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson()); _send(KickRequest(target: target, reason: reason).toJson());
} }
@@ -5,23 +5,30 @@ import 'dart:typed_data';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart'; 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 { Future<String> encodeAvatar(String path) async {
final trimmed = path.trim(); final trimmed = path.trim();
if (trimmed.isEmpty) return ''; if (trimmed.isEmpty) return '';
final file = File(trimmed); final file = File(trimmed);
if (!await file.exists()) { 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; late final List<int> raw;
try { try {
raw = await file.readAsBytes(); raw = await file.readAsBytes();
} catch (_) { } 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) { if (raw.isEmpty) {
throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo'); 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) { if (decoded == null) {
throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo'); 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(), backgroundColor: img.PixelUndefined(),
interpolation: img.Interpolation.cubic, interpolation: img.Interpolation.cubic,
); );
final png = img.encodePng(resized); final b64 = base64Encode(img.encodePng(resized));
final b64 = base64Encode(png);
if (b64.length > RelayConstants.maxAvatarBytes) { if (b64.length > RelayConstants.maxAvatarBytes) {
throw const FormatException('that photo is still too large after shrinking — pick a smaller one'); throw const FormatException('that photo is still too large after shrinking — pick a smaller one');
} }
return b64; 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'; import '../playback/frame_watchdog.dart';
/// Fingerprints what the compositor really shows of the video. Place /// Fingerprints what the compositor really shows of the video. Place
/// [anchor] in the video controls (built in the window and again in the /// [anchor] under a `RepaintBoundary` around the video (the session player
/// fullscreen route); [sample] captures the route around the newest /// does); [sample] captures that boundary around the newest mounted anchor
/// mounted anchor at ~200 px wide — the texture included — and hashes its /// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own
/// centre. This is the only per-presented-frame signal: mpv's own
/// counters keep ticking when the Flutter texture is stuck. /// counters keep ticking when the Flutter texture is stuck.
class ShownFrameProbe { class ShownFrameProbe {
final _anchors = <_AnchorState>[]; 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();
}
});
}
+89 -99
View File
@@ -2,11 +2,11 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.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/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/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:watch_together/src/update/update_controller.dart';
@@ -107,64 +107,87 @@ void main() {
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), theme: ThemeData.dark(),
home: Scaffold( home: AppShell(
body: HomeShell( updates: UpdateController(currentVersion: '0.0.0-dev'),
updates: UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false,
autoUpdateCheck: false, windowControls: false,
healthGet: health ?? fakeHealth(const {}), healthGet: health ?? fakeHealth(const {}),
),
), ),
)); ));
await tester.pump(); await tester.pump();
} }
setUp(() => SessionState.instance.recents.value = const []); Future<void> openCard(WidgetTester tester, String key) async {
await tester.tap(find.byKey(ValueKey(key)));
testWidgets('tabs and page buttons switch the body', (tester) async {
await pumpShell(tester, const Size(1280, 900));
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(find.byKey(const ValueKey('host-picker')), findsNothing);
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.pumpAndSettle(); 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); setUp(() {
await tester.tap(find.byTooltip('Settings')); 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(); await tester.pumpAndSettle();
expect(find.text('Hardware decoding'), findsOneWidget); expect(find.text('Hardware decoding'), findsOneWidget);
expect(find.text('Check automatically on start'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('settings-updates')));
expect(find.text('Toggle ready'), findsOneWidget);
await tester.tap(find.byTooltip('Integrations'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Jellyfin'), findsOneWidget); expect(find.text('Check automatically on start'), findsOneWidget);
await tester.tap(find.byTooltip('About')); 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(); await tester.pumpAndSettle();
expect(find.text('Dev build — update checks are off'), findsOneWidget); 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(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('join-row')), findsOneWidget); expect(find.byKey(const ValueKey('swap-panel')), findsOneWidget);
expect(tester.takeException(), isNull); 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 = ''; SessionState.instance.lastForm.name = '';
await pumpShell(tester, const Size(1280, 720)); 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.enterText(find.byKey(const ValueKey('join-address')), 'friend.example');
await tester.testTextInput.receiveAction(TextInputAction.done); await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump(); await tester.pumpAndSettle();
await tester.pump();
expect(find.textContaining('display name', findRichText: true), findsOneWidget); expect(find.textContaining('display name', findRichText: true), findsOneWidget);
// The button submits too, and typing in the address clears the error. // 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.enterText(find.byKey(const ValueKey('join-address')), 'friend.example2');
await tester.pump(); await tester.pump();
expect(find.textContaining('display name', findRichText: true), findsNothing); expect(find.textContaining('display name', findRichText: true), findsNothing);
await tester.tap(find.widgetWithText(FilledButton, 'Join')); await tester.tap(find.widgetWithText(FilledButton, 'Join').last);
await tester.pump(); await tester.pumpAndSettle();
await tester.pump(); 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); 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 = [ SessionState.instance.recents.value = [
join('alpha.example', '8080'), join('alpha.example', '8080'),
join('beta.example', '9000', 'pw'), join('beta.example', '9000', 'pw'),
@@ -181,54 +204,34 @@ void main() {
}, },
'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0}, 'gamma.example': {'ok': true, 'name': '', 'proto': -1, 'viewers': 0},
})); }));
await tester.pump(); await openCard(tester, 'strip-join');
Finder inTile(String key, Finder f) => Finder inRow(String key, Finder f) =>
find.descendant(of: find.byKey(ValueKey('recent-$key')), matching: f); find.descendant(of: find.byKey(ValueKey('recent-$key')), matching: f);
expect(inTile('alpha.example:8080', find.text('Movie night')), findsOneWidget); expect(inRow('alpha.example:8080', find.text('Movie night')), findsOneWidget);
expect(inTile('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget); expect(inRow('alpha.example:8080', find.text('● online · 3 watching')), findsOneWidget);
expect(inTile('alpha.example:8080', find.text('needs update')), findsNothing); expect(inRow('alpha.example:8080', find.text('needs update')), findsNothing);
expect(inTile('beta.example:9000', find.text('○ offline')), findsOneWidget); expect(inRow('beta.example:9000', find.text('○ offline')), findsOneWidget);
expect(inTile('beta.example:9000', find.text('beta.example')), findsOneWidget); expect(inRow('beta.example:9000', find.text('beta.example')), findsOneWidget);
expect(inTile('gamma.example:7000', find.text('needs update')), findsOneWidget); expect(inRow('gamma.example:7000', find.text('needs update')), findsOneWidget);
// Hosted entries live on the Host tab, not in the join grid. // Hosted entries live on the Host card, not in the join list.
expect(find.byKey(const ValueKey('recent-host:8181')), findsNothing); 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. // 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(); await tester.pump();
expect(find.byKey(const ValueKey('recent-beta.example:9000')), findsNothing); expect(find.byKey(const ValueKey('recent-beta.example:9000')), findsNothing);
expect(tester.takeException(), isNull); 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)); 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 { testWidgets('library: picker segments switch the input; Integrations opens Settings', (tester) async {
SessionState.instance.recents.value = [
for (var i = 0; i < 6; i++) join('10.0.0.$i', '8080'),
];
await pumpShell(tester, const Size(3440, 1440));
final first = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.0:8080')));
final last = tester.getRect(find.byKey(const ValueKey('recent-10.0.0.5:8080')));
expect(first.top, last.top);
expect(tester.getSize(find.byKey(const ValueKey('join-address'))).width, 280);
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const ValueKey('host-column'))).width, 340);
final file = find.descendant(
of: find.byKey(const ValueKey('media-file')), matching: find.byType(TextField));
expect(tester.getSize(file).width, lessThanOrEqualTo(kFieldMaxWidth));
expect(tester.takeException(), isNull);
});
testWidgets('media picker segments switch the input', (tester) async {
await pumpShell(tester, const Size(1280, 900)); await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.byKey(const ValueKey('tab-host'))); await tester.tap(find.text('File'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-file')), findsOneWidget); expect(find.byKey(const ValueKey('media-file')), findsOneWidget);
expect(find.text('Browse'), findsOneWidget); expect(find.text('Browse'), findsOneWidget);
@@ -236,6 +239,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('media-link')), findsOneWidget); expect(find.byKey(const ValueKey('media-link')), findsOneWidget);
expect(find.text('Paste'), 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.tap(find.text('Jellyfin'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// No linked server: sign-in right in the picker. // No linked server: sign-in right in the picker.
@@ -246,44 +251,29 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('host column sits right when wide, below when narrow', (tester) async { testWidgets('host without a name errors in the host card', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.byKey(const ValueKey('tab-host')));
await tester.pumpAndSettle();
var picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
var column = tester.getRect(find.byKey(const ValueKey('host-column')));
expect(picker.right, lessThan(column.left));
expect(picker.top, column.top);
await pumpShell(tester, const Size(640, 1000));
picker = tester.getRect(find.byKey(const ValueKey('host-picker')));
column = tester.getRect(find.byKey(const ValueKey('host-column')));
expect(picker.bottom, lessThan(column.top));
expect(tester.takeException(), isNull);
});
testWidgets('host without a name errors in the server column', (tester) async {
SessionState.instance.lastForm.name = ''; 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 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.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Host')); expect(find.textContaining('display name', findRichText: true), findsOneWidget);
await tester.pump();
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('host-column')),
matching: 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)); 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(find.byKey(const ValueKey('join-row')), findsOneWidget);
expect(tester.takeException(), isNull); 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.pumpAndSettle();
await tester.tap(find.text('Jellyfin')); await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle(); 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/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart'; import 'package:watch_together/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.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'; import 'package:watch_together/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests. /// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
@@ -327,9 +326,8 @@ void main() {
expect(back, m); expect(back, m);
expect(back.runtime, const Duration(minutes: 23)); expect(back.runtime, const Duration(minutes: 23));
expect(back.subtitle, 'Black Summoner · S1E6'); expect(back.subtitle, 'Black Summoner · S1E6');
expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'); expect(m.label, 'Rice and Thieves and Heroes — Black Summoner · S1E6');
expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv'); expect(const FileMedia('/a/b/movie.mkv').label, 'movie.mkv');
expect(mediaSummary(null), 'No media selected (optional).');
expect(JellyfinMedia.fromJson({'serverId': 1}), isNull); 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.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.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/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' import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
@@ -270,7 +268,7 @@ void main() {
expect(find.byType(JellyfinBrowser), findsOneWidget); 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 { (tester) async {
await setSize(tester, const Size(1800, 1000)); await setSize(tester, const Size(1800, 1000));
final (accounts, _) = await linkedAccounts(); final (accounts, _) = await linkedAccounts();
@@ -281,32 +279,41 @@ void main() {
title: 'Rice and Thieves and Heroes', title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6'); subtitle: 'Black Summoner · S1E6');
await accounts.rememberPick(pick); await accounts.rememberPick(pick);
final fields = HostFields(ConnectForm(), jellyfin: accounts); final c = MediaPickerController();
final flow = ConnectFlow(); addTearDown(c.dispose);
addTearDown(() { await restoreLastPick(c, accounts, () => true);
fields.dispose(); final put = <PickedMedia>[];
flow.dispose(); Future<void> pump({required bool online}) => tester.pumpWidget(MaterialApp(
}); theme: ThemeData.dark(),
await tester.pumpWidget(MaterialApp( home: Scaffold(
theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields)))); 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); await settle(tester);
// The saved pick comes back on the Jellyfin source. // 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.byType(JellyfinBrowser), findsOneWidget);
expect(find.byKey(const ValueKey('host-column')), findsOneWidget); expect(find.text('Join or host to watch together'), findsOneWidget);
expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), 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 pump(online: true);
await settle(tester, 2); await settle(tester);
expect(find.byKey(const ValueKey('host-column')), findsNothing); expect(find.text('Put up: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
expect(find.byKey(const ValueKey('host-bar')), findsOneWidget); await tester.tap(go);
expect(tester.takeException(), isNull); await settle(tester);
expect(put, [pick]);
await tester.tap(find.byKey(const ValueKey('jf-expand')));
await settle(tester, 2);
tester.view.physicalSize = const Size(800, 900); tester.view.physicalSize = const Size(800, 900);
await settle(tester, 2); await settle(tester, 2);
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
expect(find.byType(JellyfinBrowser), findsOneWidget); expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(tester.takeException(), isNull); 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/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.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/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.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/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/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_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle; import 'jellyfin_browser_test.dart' show app, setSize, settle;
@@ -125,16 +125,16 @@ void main() {
title: 'Rice and Thieves and Heroes', title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6', subtitle: 'Black Summoner · S1E6',
runtime: Duration(minutes: 23)); runtime: Duration(minutes: 23));
final media = hostJellyfinMedia(accounts, pick); final media = putUpJellyfinMedia(accounts, pick);
expect(media.url, isNull); expect(media.url, isNull);
expect(media.jellyfin!.itemId, episodeId); expect(media.jellyfin!.itemId, episodeId);
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6'); expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6');
expect(media.jellyfin!.runtimeSecs, 23 * 60); expect(media.jellyfin!.runtimeSecs, 23 * 60);
expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN'))); 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); throwsFormatException);
accounts.markExpired(fixtureServerId); 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)', 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); swapped.add(m);
return null; return null;
}, },
onClose: () => closed++, onDone: () => closed++,
))); )));
await settle(tester); await settle(tester);
// A guest's file goes through the server's relay. // A guest's file goes through the server's relay.
@@ -553,7 +553,7 @@ void main() {
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget); expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
await tester.pumpWidget(app(SwapPanel( 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); await settle(tester);
expect(find.text('File'), findsOneWidget); expect(find.text('File'), findsOneWidget);
await tester.tap(find.text('File')); await tester.tap(find.text('File'));
@@ -580,7 +580,6 @@ void main() {
isHost: false, isHost: false,
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep), jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
onSwap: (_) async => null, onSwap: (_) async => null,
onClose: () {},
))); )));
await settle(tester, 10); await settle(tester, 10);
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected, 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/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.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/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/screens/session/player_controls.dart' show TrackPick;
import 'package:watch_together/src/shaders/shader_presets.dart'; 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/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:watch_together/src/update/update_controller.dart';
@@ -456,7 +456,13 @@ void main() {
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), 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')); final toggle = find.byKey(const ValueKey('mpv-controls'));
expect(find.text('Experimental: mpv-style controls'), findsOneWidget); 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 `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 `[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
uncaught async error is logged and the server keeps going. If the isolate uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; Home dies anyway, the session shows "server crashed: ..." and leaves; the
shows it on the Host card (during setup the host attempt fails with it). 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) 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. sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` - **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 matched with `matchTrack` against the source's streams (mapped to mpv ids
with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes with `mpvTrackId`) or mpv's track list, and selected locally. Nothing goes
on the wire. on the wire.
- **Swap panel**: "Change video" in the side panel opens the shared - **Change video**: the player's "Change video" opens the Library tab
`MediaPicker` over the video (`screens/session/swap_panel.dart`); Link (the shared `MediaPicker`, `screens/session/swap_panel.dart`); Link
goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only) goes through `mediaForLink`, a Jellyfin pick as a ref, File (host only)
through `ServerIsolate.setFile` and a matching `swap`. through `ServerIsolate.setFile` and a matching `swap`.
## Desktop UI ## Desktop UI
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`): One window, `AppShell` (`lib/src/shell/`), whatever the connection state
profile chip, Join and Host tabs, the update indicator, Settings / (online = `SessionState.route` is `session`). Motion is shared
Integrations / About buttons, and the window drag area and buttons. Only the (`motion.dart`): one curve (`fastEaseInToSlowEaseOut`, 320 ms), a fade
selected view is mounted; form state lives in `HomeShell`. Join through for views that replace each other (the old one gone after 35%, the
(`join_tab.dart`): one compact address/port/password row, then up to 6 new one fading and scaling in from 0.985), `showPopover` (cards that fade
Recent server tiles in a grid that adds columns as the window widens. While and scale in from where they open; they scroll when the window is short)
the Join tab is visible each tile polls the server's `GET /health` every and `HScroll` (horizontal rows: mouse drag, the vertical wheel scrolls them
10 s (`health_probe.dart`) for online, name, viewer count and a "needs sideways until an end, then the page).
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget - **Party strip** (`party_strip.dart`, 60 px): your avatar (profile card),
driven by a `MediaPickerController` whose `selection` is a sealed the Library / Schedule / Settings tabs with a growing underline (an
`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see `HScroll` when narrow), the update indicator, then offline Join and Host
`picked_media.dart`) fills the main area; port, password and Host sit in a (connect cards), online the party: `AvatarStack` (host first, then
340 px column on the right, below the picker under 860 px. With the ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and
Jellyfin source the browser fills the height and can be enlarged, which "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so (asks first). The stack opens the members card (status, host / relay
they stay compact on ultrawide screens. Submit state (busy, cancel, pills, "via host", force ready, kick). Empty strip space is the window
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware drag area; the window buttons sit at its end. Debug builds add a button
decoding, experimental mpv-style controls, Jellyfin auto-advance, updates, that opens the layout mock (`screens/demo/`).
video shaders, shortcuts), Integrations - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the
(linked Jellyfin servers) and About open in the same body. Settings persist picture with a gap in the background colour (both scale with the size):
in two Hive boxes: `connect_form` (last form and recents; saved even when a green ready, amber buffering (with a spinner), grey not ready, dimmed
join fails) and `settings`. Link media names are derived from the URL path stale; a crown for the host (only your own app knows it hosts: the
and never contain a query string. 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): Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one - `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 key changes only when the image does. An inspector pane follows
hover/focus from 1400 px browser width. hover/focus from 1400 px browser width.
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId, - Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab mediaSourceId?, title, subtitle, runtime; no credentials). The library
remembers the last pick in the `jellyfin` box and hosts it as a ref remembers the last pick in the `jellyfin` box and puts it up as a ref
(`hostJellyfinMedia`, `ConnectFlow.submit(media:)`); the hosted server (`putUpJellyfinMedia`, `swapPicked`); a hosted server gets the relay
gets the relay credential (`relayCredentialFor`: the account of the credential (`relayCredentialFor`: the account of the
item's server, else the default one, not expired; its token with item's server, else the default one, not expired; its token with
exactly `JellyfinAccounts.clientInfo`'s device fields). exactly `JellyfinAccounts.clientInfo`'s device fields).
- Browser location: every navigation saves the route stack per server - Browser location: every navigation saves the route stack per server
(`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids, (`browse:<serverId>` in the `jellyfin` box, `encodeBrowseStack`: ids,
names and the fields pages start from), so the Host tab, the swap panel names and the fields pages start from), so the library and the next
and the next start open where the browser was. A `BrowseStart` overrides start open where the browser was. A `BrowseStart` overrides it: "Change
it: the swap panel opens on the series of the episode playing, its video" opens the library on the series of the episode playing, its
season selected and the episode highlighted. season selected and the episode highlighted.
Session controls: media_kit's `MaterialDesktopVideoControls` with our Session controls: media_kit's `MaterialDesktopVideoControls` with our
bars (`screens/session_screen.dart`, pieces in bars (`shell/session_player.dart`, pieces in
`screens/session/player_controls.dart`). Its seek bar is off; ours `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) (`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 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 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 the controls up through a guarded dynamic `onHover()` on media_kit's
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED / private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
1:30, Next episode) and cards live outside the auto-hiding controls, so 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; Experimental mpv-style controls (`controls/uosc/`, after mpv's uosc theme;
Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s Settings toggle `AppSettings.mpvControls`, default off): the `Video`'s
controls builder wraps both sets in `SessionControlsSwitch`, which follows 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 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 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. 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 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 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 (≡: 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 subtitles and audio with track counts, a shader badge ("A HQ", opening the
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30, shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
chapters, previous / next episode, change video, people, volume, chapters, previous / next episode, change video, volume, fullscreen; chat
fullscreen. Menus (`showUoscMenu`: a popup route above the button, the 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 current row highlighted) list tracks and chapters with start times. All
actions are the coordinator's (seeks through `player.seek`, the user path; actions are the coordinator's (seeks through `player.seek`, the user path;
`selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are `selectTrack`, `skipForward`, `seekTo`); keys and the floating pills are
the same in both sets. the same in both sets.
Window chrome is frameless (`window_manager`): every screen brings its own Window chrome is frameless (`window_manager`): the party strip is the drag
drag area and min/max/close buttons (Home's top bar; the session uses area and holds the min/max/close buttons (`widgets/window_buttons.dart`);
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`. fullscreen has neither.
## Health reporting ## 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 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`, displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated, so `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. status.
## Updater ## 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 `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 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 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"). 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 Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`. 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 their machine. Prior art (Plex Relay, Tailscale DERP) relays as a
bandwidth-capped fallback on someone else's servers; here the server is bandwidth-capped fallback on someone else's servers; here the server is
the owner's, so it carries full quality. the owner's, so it carries full quality.
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar): - Avatars by content hash: `state` carries only `avatarHash`; the server keeps
simple, costs host uplink; hash-based serving is on the roadmap. 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). - Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
Traps: Traps:
- Never pump the session screen or media_kit `Video` in `flutter test`: the - Never pump media_kit's `Video` in `flutter test`: the runner hangs (kill
runner hangs (kill the `flutter_tester` by PID). Test pure logic the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
(`StateDiff`, coordinator without widgets) instead. 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 - Never depend on a server-side WebSocket `onDone`; assert presence with
deadline polling. deadline polling.
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline. - `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 - Overlays that are hidden but mounted keep focus (Space falls into a hidden
button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse button). Use `ExcludeFocus` on closed subtrees and unfocus on close; reuse
the shared no-focus node for overlay buttons. the shared no-focus node for overlay buttons.
- `modifyVolumeOnScroll: false` on the session controls is load-bearing for - `toggleFullscreenOnDoublePress: false` on the session controls is
window drag (volume is the speaker button only). A `GestureDetector` below load-bearing: media_kit's double-click pushes its own fullscreen route with
media_kit's overlay loses the gesture arena; the drag layer is a raw pointer a second `Video`. A `GestureDetector` below media_kit's overlay never sees
observer on top. the click (its `MouseRegion` takes the hit) and one around it would delay
- New screens must bring their own drag area and window buttons (frameless every button click (double-tap holds the arena), so the double-click is a
window). 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 - Chat arrives twice from the server (see PROTOCOL.md); the client drops the
textless feed copy. textless feed copy.
- Tab traversal is disabled on the session screen on purpose; Tab toggles the - Tab traversal is disabled in the session player on purpose; Tab toggles
side panel. chat & activity.
- After editing constants (ports, thresholds), grep to confirm the value landed. - 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 - **Dedicated server**: `watch-together-server`, a separate program in the
release tarball, for an always-on box. See the README. 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 ## 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 Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: [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). serves the host's file (Range table in ARCHITECTURE.md).
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see `GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a "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 ## 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":"...", "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"} "resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto {"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":"ping","c":1767225600000}
{"t":"chat","text":"..."} {"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""} {"t":"kick","target":"<memberId>","reason":""}
{"t":"profile","name":"...","avatar":"<base64>"}
``` ```
Field notes: Field notes:
@@ -68,8 +70,13 @@ Field notes:
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped. up to 64 characters; others are dropped.
- `ping.c`: the client's clock in epoch ms; answered with `pong`. - `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is - `user.avatar`: base64 PNG, at most 256 KiB of characters (else
trimmed, empty becomes `?`, cut to 32 characters. `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. - `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational. - `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is `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, {"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1, "serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?}|absent, "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} "seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012} {"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"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 it. `seekBy` (member id) and `seekSeq` are present only while the current
`eventId` came from a seek. `relay` (omitted when false): the server can `eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last 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 - `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 (the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip. is `s - (c + t3) / 2`, accurate to half the round trip.
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`, - `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
`force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`, `force-play`, `force-hold`, `kick`, `join`, `leave`, `stale`, `swap`,
`seek`, `pause`, `resume`, `finished`, `chat`. `target` and `reason` are optional; `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target`
`reason` carries `auto`/`manual` for ready and `silent too long` or and `reason` are optional; `reason` carries `auto`/`manual` for ready and
`no signal` for leave/stale. `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 - Chat is delivered twice: a `chat` frame with the text and a textless
`feed` with `action:"chat"`. Render the `chat` frame only; the reference `feed` with `action:"chat"`. Render the `chat` frame only; the reference
client drops the textless feed entry. 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 ## 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 outage with cache exhaustion (`tool/shaper.dart --kill-after`); assert no
OOM, no stuck session, drift recovers. OOM, no stuck session, drift recovers.
- Chat history pane (today: activity log only). - 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 - The `state` broadcast carries the full media URL, which can include an
`api_key` (recognised Jellyfin links become refs; other links do not). `api_key` (recognised Jellyfin links become refs; other links do not).
- TLS: document a reverse proxy in front of the server. - TLS: document a reverse proxy in front of the server.
+6 -8
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants { abstract class RelayConstants {
static const int protoVersion = 8; static const int protoVersion = 9;
// Relay (`/r/...`): a member's bytes through the server's buffer. // Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20; static const int relaySliceBytes = 1 << 20;
@@ -32,13 +32,11 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1; static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5; static const int pingIntervalSecs = 5;
// Base64-char count of the avatar string, enforced at hello. 256 KiB // Base64-char count of the avatar string, enforced at hello and
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap // profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size // (raw bitmap <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate
// for private-server use. PNG keeps transparency; JPEG did not. // over-size for private-server use. PNG keeps transparency; JPEG did
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host // not. Only the hash rides in `state`; the bytes are fetched once.
// uplink scales with this cap. Follow-up (not now): send avatar bytes
// once, reference by hash afterwards.
static const int maxAvatarBytes = 256 * 1024; static const int maxAvatarBytes = 256 * 1024;
static const int maxNameLength = 32; static const int maxNameLength = 32;
static const int maxChatLength = 500; 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(); 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 { class HelloUser {
final String name; final String name;
final String avatar; 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 { class MemberView {
final String id; final String id;
final String name; 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 ready;
final bool inSet; final bool inSet;
final double buffered; final double buffered;
@@ -463,7 +493,7 @@ class MemberView {
const MemberView({ const MemberView({
required this.id, required this.id,
required this.name, required this.name,
this.avatar = '', this.avatarHash = '',
required this.ready, required this.ready,
required this.inSet, required this.inSet,
required this.buffered, required this.buffered,
@@ -474,7 +504,7 @@ class MemberView {
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'id': id, 'id': id,
'name': name, 'name': name,
'avatar': avatar, if (avatarHash.isNotEmpty) 'avatarHash': avatarHash,
'ready': ready, 'ready': ready,
'inSet': inSet, 'inSet': inSet,
'buffered': buffered, 'buffered': buffered,
@@ -485,7 +515,7 @@ class MemberView {
factory MemberView.fromJson(Map<String, Object?> json) => MemberView( factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
id: (json['id'] as String?) ?? '', id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '', name: (json['name'] as String?) ?? '',
avatar: (json['avatar'] as String?) ?? '', avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false, ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false, inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'), 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 'constants.dart';
import 'protocol.dart'; import 'protocol.dart';
@@ -14,11 +19,34 @@ Correction correctionFor({required double ownPosition, required double serverPos
return Correction.none; 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 { class EngineMember {
final String id; final String id;
final String token; final String token;
String name; String name;
String avatar; /// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
Uint8List? avatarBytes;
String avatarHash = '';
bool ready = false; bool ready = false;
bool inSet = false; bool inSet = false;
double buffered = 0; double buffered = 0;
@@ -29,14 +57,35 @@ class EngineMember {
required this.id, required this.id,
required this.token, required this.token,
required this.name, required this.name,
this.avatar = '', String avatar = '',
required DateTime now, 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( MemberView view({required bool stale}) => MemberView(
id: id, id: id,
name: name, name: name,
avatar: avatar, avatarHash: avatarHash,
ready: ready, ready: ready,
inSet: inSet, inSet: inSet,
buffered: buffered, buffered: buffered,
@@ -129,6 +178,32 @@ class SyncEngine {
return id; 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`). /// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) { void applyResume(String token) {
final member = members[token]; final member = members[token];
+54
View File
@@ -537,4 +537,58 @@ void main() {
expect(s.seekBy, isNull); 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.). /// 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. /// A URL-looking name is reduced to its last path segment, no query/fragment.
String sanitizeMediaName(String name) { String sanitizeMediaName(String name) {
@@ -358,6 +389,19 @@ void mountSyncRoute(
mediaId: hub.engine.mediaId); mediaId: hub.engine.mediaId);
} }
hub.changed(); 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': case 'report':
if (token == null) return; if (token == null) return;
final report = ReportMessage.fromJson(decoded); final report = ReportMessage.fromJson(decoded);
@@ -470,7 +514,7 @@ void mountSyncRoute(
await close(); await close();
return; 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); session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
final activeToken = token; final activeToken = token;
+5 -9
View File
@@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline }
class ViewerToken { class ViewerToken {
final String token; final String token;
final String name; /// Cleaned ([cleanMemberName]); follows `profile` renames.
final String avatar; String name;
bool kicked = false; bool kicked = false;
/// Sent the server's host key in hello: the hosting app's own client. /// Sent the server's host key in hello: the hosting app's own client.
bool isHost = false; bool isHost = false;
int bytesServed = 0; int bytesServed = 0;
DateTime lastSeen; DateTime lastSeen;
Future<void> Function(String reason)? onKick; 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(); : lastSeen = DateTime.now().toUtc();
bool isStale(Duration timeout, [DateTime? now]) => bool isStale(Duration timeout, [DateTime? now]) =>
@@ -53,18 +53,14 @@ class Session {
Session({required this.id, required this.secret}); Session({required this.id, required this.secret});
/// The avatar is only checked here; the engine keeps the picture.
String issueToken(String name, String avatar) { String issueToken(String name, String avatar) {
if (avatar.length > RelayConstants.maxAvatarBytes) { if (avatar.length > RelayConstants.maxAvatarBytes) {
throw const AuthException('avatar too large'); 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 bytes = List<int>.generate(16, (_) => _random.nextInt(256));
final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); 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; return token;
} }
+1
View File
@@ -64,6 +64,7 @@ class SyncServer {
}); });
mountSessionMediaRoute(_app, sessions); mountSessionMediaRoute(_app, sessions);
mountRelayRoutes(_app, sessions, () => hub.relay); mountRelayRoutes(_app, sessions, () => hub.relay);
mountAvatarRoute(_app, hub);
mountJellyfinRelayRoute( mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient()); _app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
// advertiseHost is only an explicit override; otherwise echo what the client dialled. // advertiseHost is only an explicit override; otherwise echo what the client dialled.
@@ -389,5 +389,93 @@ void main() {
await ch.sink.close(); await ch.sink.close();
} catch (_) {} } 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 s = Session(id: 'default', secret: 'shh');
final png = 'x' * (64 * 1024); final png = 'x' * (64 * 1024);
expect(s.issueToken('alice', png), isNotEmpty); 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 ?', () { test('names truncated to cap, blank becomes ?', () {
@@ -80,8 +80,21 @@ void main() {
}); });
group('core welcome + proto', () { group('core welcome + proto', () {
test('proto 8: relayed media', () { test('proto 9: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 8); 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)); 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())); final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
expect(back.relay!.source, 'm1'); expect(back.relay!.source, 'm1');