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:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5728
-3523
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
files = await FilePicker.pickFiles(
|
|
||||||
dialogTitle: 'Pick avatar image',
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
error.value = 'file dialog unavailable — try again';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final path = files.isEmpty ? null : files.first.path;
|
|
||||||
if (path == null) return;
|
|
||||||
final String b64;
|
|
||||||
try {
|
|
||||||
b64 = await encodeAvatar(path);
|
|
||||||
} on FormatException catch (e) {
|
} on FormatException catch (e) {
|
||||||
error.value = e.message;
|
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,30 +60,19 @@ 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),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 12, 16),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: Text('Change video', style: t.titleLarge)),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Close (Esc)', icon: const Icon(Icons.close), onPressed: widget.onClose),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
@@ -104,20 +99,32 @@ class _SwapPanelState extends State<SwapPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_error ?? (sel == null ? 'Pick something to watch' : 'Put up: ${sel.label}'),
|
_error ??
|
||||||
|
hint ??
|
||||||
|
(sel == null
|
||||||
|
? 'Pick something to watch'
|
||||||
|
: 'Put up: ${sel.label}'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: t.bodyMedium?.copyWith(
|
style: t.bodyMedium?.copyWith(
|
||||||
color: _error != null ? Theme.of(context).colorScheme.error : null),
|
color: _error != null
|
||||||
|
? Theme.of(context).colorScheme.error
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
key: const ValueKey('swap-go'),
|
key: const ValueKey('swap-go'),
|
||||||
onPressed: sel == null || _busy ? null : _swap,
|
onPressed: sel == null || _busy || hint != null
|
||||||
|
? null
|
||||||
|
: _swap,
|
||||||
icon: _busy
|
icon: _busy
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
: const Icon(Icons.swap_horiz),
|
: const Icon(Icons.swap_horiz),
|
||||||
label: const Text('Put it up for everyone'),
|
label: const Text('Put it up for everyone'),
|
||||||
),
|
),
|
||||||
@@ -127,7 +134,6 @@ class _SwapPanelState extends State<SwapPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,7 @@ import '../playback/playback_controller.dart';
|
|||||||
import '../screens/home/picked_media.dart';
|
import '../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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user