Restructure step 3: your server, Invite, version gate, AppGraph
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s

- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:52:52 +03:00
1 parent 629be0d9ed
commit 4d00b7455e
86 files changed
+2993 -2071

No files matched your search

+87 -118
View File
@@ -14,18 +14,18 @@ import '../jellyfin/browse_scope.dart' show BrowseStart;
import '../jellyfin/session_media.dart';
import '../screens/home/connect_flow.dart';
import '../session/app_events.dart' show ActionFailed, ScheduleResult;
import '../session/session_coordinator.dart' show ConnectParams;
import '../screens/home/health_probe.dart';
import '../screens/home/media_picker.dart';
import '../session/identity.dart';
import '../screens/home/update_indicator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../session/navigation.dart' show Place;
import '../state/settings.dart';
import '../update/update_controller.dart';
import 'chat_panel.dart';
import 'connect_cards.dart';
import 'handoff.dart';
import 'library_link.dart' show LinkProber, defaultLinkProber;
import 'library_link.dart' show LinkProber;
import 'library_tab.dart';
import 'motion.dart';
import 'party_strip.dart';
@@ -38,7 +38,8 @@ import 'welcome.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].
/// and the toasts. Where the app is (your server, a friend's) follows
/// [Navigation.place].
///
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
/// the body: full it fills the body (fullscreen: the window, strip
@@ -61,7 +62,7 @@ class AppShell extends StatefulWidget {
final VideoViewBuilder? videoBuilder;
/// What the Library's Link page asks about a link (tests: a fake, so no
/// network or yt-dlp runs).
final LinkProber linkProbe;
final LinkProber? linkProbe;
const AppShell({
super.key,
this.updates,
@@ -70,7 +71,7 @@ class AppShell extends StatefulWidget {
this.healthGet = defaultHealthGet,
this.jellyfin,
this.videoBuilder,
this.linkProbe = defaultLinkProber,
this.linkProbe,
});
@override
@@ -112,9 +113,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool _dragging = false;
bool _handoffOpen = false;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final AppGraph _graph = AppScope.of(context);
late final _flow = ConnectFlow(navigation: _graph.navigation, forms: _graph.forms);
late final _join = JoinFields(_graph.forms.form);
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late MediaPickerController _picker = _newPicker();
BrowseStart? _libraryStart;
@@ -126,22 +127,17 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool _dialogOpen = false;
final _stops = <void Function()>[];
// Rebuild the shell on these flips, not on every state broadcast.
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
/// Nobody else watches: offline, solo, or solo becoming a server.
final _alone = computed(() {
final s = SessionState.instance;
return s.route.value != AppRoute.session || s.solo.value || s.switching.value;
});
final _hasMedia = computed(() =>
SessionState.instance.route.value == AppRoute.session &&
SessionState.instance.coordinator.serverState.value?.media != null);
late final _online = computed(() => _graph.navigation.online.value);
/// Nobody else is in the server (D9): a plain player.
late final _alone = computed(() => !_graph.navigation.online.value || _graph.coordinator.alone.value);
late final _hasMedia =
computed(() => _graph.navigation.online.value && _graph.coordinator.serverState.value?.media != null);
/// A video is on in the session (not finished): the update bar stays
/// off the full player.
final _watching = computed(() {
final s = SessionState.instance;
final r = s.coordinator.serverState.value;
return s.route.value == AppRoute.session && r?.media != null && r?.transport != 'finished';
late final _watching = computed(() {
final r = _graph.coordinator.serverState.value;
return _graph.navigation.online.value && r?.media != null && r?.transport != 'finished';
});
PlayerMode get mode => _mode;
@@ -149,7 +145,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
bool get fullscreen => _fullscreen;
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
final f = SessionState.instance.lastForm;
final f = _graph.forms.form;
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;
@@ -163,14 +159,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
HardwareKeyboard.instance.addHandler(_onKey);
final session = SessionState.instance;
final coord = session.coordinator;
final nav = _graph.navigation;
final coord = _graph.coordinator;
_feedSeen = coord.feedLog.peek().length;
_stops.add(effect(() {
final online = session.route.value == AppRoute.session;
final place = nav.place.value;
final online = place != Place.none;
// Set now, not later: the media effect below may run first in this
// same flush, and its scheduled show reads these.
_autoFull = online;
// same flush, and its scheduled show reads these. A friend's video
// opens full; back home, yours waits in the mini player.
_autoFull = place == Place.friend;
if (!online) _lastMediaId = -1;
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
}));
@@ -178,7 +176,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// 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;
final online = nav.online.value;
if (!online || r == null || r.media == null) return;
if (r.mediaId == _lastMediaId) return;
_lastMediaId = r.mediaId;
@@ -218,31 +216,21 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
var wasOut = false;
_stops.add(effect(() {
final out = coord.selfAway.value;
if (wasOut && !out && session.route.peek() == AppRoute.session) {
if (wasOut && !out && nav.online.peek()) {
_later(() {
if (_mode == PlayerMode.none) _show(PlayerMode.mini);
});
}
wasOut = out;
}));
// Solo becoming a hosted server: the player closes while the media
// moves over and comes back the way it was once it is up again.
// Your server died (the navigation starts a new one): say so.
final yours = _graph.yourServer;
_stops.add(effect(() {
if (!session.switching.value) return;
_later(() {
_lastMediaId = -1;
_autoFull = _mode == PlayerMode.full;
if (_fullscreen) _toggleFullscreen();
_show(PlayerMode.none);
});
}));
// The hosted server died mid-session (SessionState leaves): say so.
_stops.add(effect(() {
final crash = session.serverCrash.value;
final crash = yours.crash.value;
if (crash == null) return;
_later(() {
session.serverCrash.value = null;
_alert('Server crashed', crash);
yours.crash.value = null;
_alert('Your server crashed', '$crash\n\nA new one starts; anyone who was in it joins again.');
});
}));
}
@@ -261,7 +249,6 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_watching.dispose();
_flow.dispose();
_join.dispose();
_host.dispose();
_picker.dispose();
_chat.dispose();
_chatFocus.dispose();
@@ -291,8 +278,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
void _wentOnline() {
_unread = 0;
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
// Hosting starts with nothing up: the library is where to pick it.
_feedSeen = _graph.coordinator.feedLog.peek().length;
// A new server: the library is where to pick something.
setState(() => _tab = ShellTab.library);
}
@@ -338,7 +325,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// The player's ×: sit out (stay in the server, nothing loaded; the
/// player shows the join-back card). Once out, × closes the player.
void _closePlayer() {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
if (coord.selfAway.peek()) {
_show(PlayerMode.none);
} else {
@@ -349,7 +336,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
Future<void> _setWindowFullscreen(bool on) async {
if (!widget.windowControls) return;
try {
await SessionState.instance.ports.shell.setFullScreen(on);
await _graph.ports.shell.setFullScreen(on);
} catch (_) {
// No window manager.
}
@@ -384,7 +371,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// "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;
final coord = _graph.coordinator;
coord.markInput();
final ref = coord.currentRef.peek();
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
@@ -401,10 +388,10 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
_selectTab(ShellTab.library);
}
/// Library → "Put it up for everyone"; the player opens full. Offline
/// it starts a solo session first (just you, on this computer).
/// Library → "Put it up for everyone" (alone: Play); the player opens
/// full. Before your server is up (starting) it waits for it.
Future<String?> _putUp(PickedMedia media) async {
final session = SessionState.instance;
final coord = _graph.coordinator;
if (media is JellyfinMedia) {
try {
putUpJellyfinMedia(_jellyfin, media);
@@ -414,47 +401,28 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
unawaited(_jellyfin.rememberPick(media));
}
if (!_online.peek()) {
final error = await _startSolo();
if (error != null || !mounted) return error;
if (!await _graph.navigation.goHome()) return "couldn't start your server";
if (!mounted) return null;
}
// Alone, a link plays direct: there is nobody to pass it on to.
if (media is LinkMedia && session.solo.peek()) {
if (media is LinkMedia && coord.alone.peek()) {
media = LinkMedia(media.url, relay: LinkRelay.direct, anyway: media.anyway);
}
final error = await session.coordinator.swapPicked(media);
final error = await coord.swapPicked(media);
if (error != null || !mounted) return error;
final f = session.lastForm;
final f = _graph.forms.form;
f
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
unawaited(session.saveForm().catchError((Object _) {}));
unawaited(_graph.forms.save().catchError((Object _) {}));
_autoFull = true;
_show(PlayerMode.full);
return null;
}
/// Starts a solo session; returns an error text, or null.
Future<String?> _startSolo() async {
final profile = Profile.instance;
try {
await profile.ensureLoaded();
final name = profile.name.peek().trim();
await SessionState.instance.startSolo(ConnectParams(
host: '127.0.0.1',
port: 0,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: profile.avatarBase64(),
));
return null;
} catch (e) {
return "couldn't start playing: $e";
}
}
/// Library's "Watch solo": somewhere to pick from (Jellyfin when linked,
/// else Files).
void _watchSolo() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
/// The welcome band's "Watch on your own": somewhere to pick from
/// (Jellyfin when linked, else Files).
void _watchAlone() => _openLibrary(_jellyfin.isEmpty ? MediaKind.file : MediaKind.jellyfin);
// --------------------------------------------------------------- handoff
@@ -464,7 +432,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Ctrl+V outside a text field (a focused field pastes on its own).
bool _onKey(KeyEvent e) {
// Any key is activity (auto sit-out).
SessionState.instance.coordinator.markInput();
_graph.coordinator.markInput();
if (e is! KeyDownEvent || e.logicalKey != LogicalKeyboardKey.keyV) return false;
final keys = HardwareKeyboard.instance;
if (!(keys.isControlPressed || keys.isMetaPressed) || keys.isShiftPressed || keys.isAltPressed) return false;
@@ -479,7 +447,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
/// Clipboard files first, then text (a link or absolute paths). A pasted
/// image has no taker yet, so the clipboard's image data is not read.
Future<void> _paste() async {
final clip = await SessionState.instance.ports.shell.readPaste();
final clip = await _graph.ports.shell.readPaste();
await _handOff(classifyHandoff(paths: clip.files, text: clip.text), _pointer);
}
@@ -493,7 +461,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
final HandoffAction? action;
try {
action = await showPopover<HandoffAction>(context,
at: align, child: HandoffCard(item: item, solo: _alone.peek(), queue: _online.peek()));
at: align, child: HandoffCard(item: item, alone: _alone.peek(), queue: _online.peek()));
} finally {
_handoffOpen = false;
}
@@ -502,7 +470,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
case (HandoffVideos(:final paths), HandoffAction.putUp):
final error = await _putUp(FileMedia(paths.first));
if (error != null) {
SessionState.instance.coordinator.notify(ActionFailed(error));
_graph.coordinator.notify(ActionFailed(error));
} else {
await FolderStore.instance.ensureLoaded();
FolderStore.instance.addRecent(paths.first);
@@ -511,15 +479,15 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// In order: with Play next, the last one added goes first.
final next = action == HandoffAction.playNext;
for (final p in next ? paths.reversed : paths) {
final error = await SessionState.instance.coordinator.queue.add(FileMedia(p), next: next);
final error = await _graph.coordinator.queue.add(FileMedia(p), next: next);
if (error != null) {
SessionState.instance.coordinator.notify(ActionFailed(error));
_graph.coordinator.notify(ActionFailed(error));
break;
}
}
case (HandoffLink(:final url), HandoffAction.queue || HandoffAction.playNext):
// In the Link page's mode, probed and refused like there.
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
final error = await coord.queue
.add(LinkMedia(url, relay: _picker.linkRelay), next: action == HandoffAction.playNext);
if (error != null) coord.notify(ActionFailed(error));
@@ -553,7 +521,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
// Off on Settings: its own drop targets (cookies) take the files there.
Widget _dropTarget(Widget child) => SessionState.instance.ports.shell.dropTarget(
Widget _dropTarget(Widget child) => _graph.ports.shell.dropTarget(
enable: _tab != ShellTab.settings,
onEnter: () => setState(() => _dragging = _onTop),
onExit: () => setState(() => _dragging = false),
@@ -581,18 +549,19 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
// --------------------------------------------------------------- dialogs
/// Leave asks first; a solo session just stops.
/// A friend's server: Leave asks first, then you are back on yours.
/// Yours has nothing to leave: the player closes ([_closePlayer]).
Future<void> _confirmLeave() async {
final session = SessionState.instance;
if (session.solo.peek()) {
await session.leave();
final nav = _graph.navigation;
if (nav.place.peek() != Place.friend) {
_closePlayer();
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Leave server?'),
content: const Text('You can rejoin right away.'),
content: const Text('You go back to your own server. You can rejoin right away.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(
@@ -603,7 +572,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
],
),
);
if (ok == true) await SessionState.instance.leave();
if (ok == true) await nav.leaveFriend();
}
Future<void> _alert(String title, String text) async {
@@ -623,15 +592,16 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
}
}
void _connect(ConnectMode mode) => showConnectCard(
void _joinFriend() => showJoinCard(
context,
mode: mode,
flow: _flow,
join: _join,
host: _host,
healthGet: widget.healthGet,
updates: widget.updates,
);
void _invite() => unawaited(inviteFriends(context));
// ----------------------------------------------------------------- build
@override
@@ -658,10 +628,9 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
chatOpen: _chatOpen,
unread: _unread,
onChat: _toggleChat,
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onJoin: _joinFriend,
onLeave: _confirmLeave,
onInvite: () => _connect(ConnectMode.host),
onInvite: _invite,
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
windowControls: widget.windowControls,
@@ -694,7 +663,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
),
);
// Any pointer use anywhere is activity (auto sit-out).
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
return Listener(
onPointerHover: (e) {
_pointer = e.localPosition;
@@ -709,41 +678,41 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin
);
}
/// No Jellyfin account, no server joined or hosted yet, band not
/// No Jellyfin account, no friend's server joined yet, band not
/// dismissed: the welcome band explains the three ways in.
bool get _newcomer =>
!AppSettings.instance.welcomeDismissed.value &&
_jellyfin.isEmpty &&
SessionState.instance.recents.value.isEmpty;
!AppSettings.instance.welcomeDismissed.value && _jellyfin.isEmpty && _graph.forms.recents.value.isEmpty;
Widget _body(bool online, bool alone) {
final coord = SessionState.instance.coordinator;
final coord = _graph.coordinator;
final friend = _graph.navigation.place.value == Place.friend;
final tabBody = switch (_tab) {
ShellTab.library => LibraryTab(
key: ValueKey('library-$_libraryGen'),
controller: _picker,
accounts: _jellyfin,
solo: alone,
// Offline, a put-up starts a solo session, which hosts.
isHost: !online || coord.hostFile != null,
// Only for newcomers: the strip has Join and Host for everyone else.
banner: online || !_newcomer
alone: alone,
// Your server serves your files itself; a friend's relays them.
isHost: !friend,
// Only for newcomers alone at home: the strip has Join and Invite
// for everyone else.
banner: !alone || friend || !_newcomer
? null
: WelcomeBand(
onJoin: () => _connect(ConnectMode.join),
onHost: () => _connect(ConnectMode.host),
onSolo: _watchSolo,
onJoin: _joinFriend,
onInvite: _invite,
onWatch: _watchAlone,
onDismiss: () => setState(AppSettings.instance.dismissWelcome),
),
jellyfinStart: _libraryStart,
onPutUp: _putUp,
// Up next needs a server (a solo session has one).
// Up next needs a server (yours, once it is up).
onAddToQueue: online ? coord.queue.add : null,
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
probe: widget.linkProbe,
),
ShellTab.schedule => SchedulePage(
solo: alone,
alone: alone,
onPutUp: _putUp,
onAddToQueue: online ? coord.queue.add : null,
onToast: (text) => coord.notify(ScheduleResult(text)),
+4 -6
View File
@@ -8,7 +8,7 @@ import '../session/identity.dart';
import '../session/delivery.dart';
import '../session/party.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'motion.dart';
/// What an avatar shows: who, their picture or initial on [color], and
@@ -144,8 +144,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator
}
/// Everyone in the server, from the latest state.
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
final c = coord ?? SessionState.instance.coordinator;
List<AvatarInfo> partyAvatars(SessionCoordinator c) {
final r = c.serverState.value;
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
}
@@ -158,7 +157,7 @@ class PartyWatch extends StatelessWidget {
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: SessionState.instance.coordinator.avatars,
listenable: AppScope.of(context).coordinator.avatars,
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
);
}
@@ -380,8 +379,7 @@ class Pill extends StatelessWidget {
/// Someone in a feed line or toast: by member [id] when it has one, else
/// by display [name]; from the latest state when still there, else their
/// initial on a stable colour.
AvatarInfo avatarForName(String name, [SessionCoordinator? coord, String id = '']) {
final c = coord ?? SessionState.instance.coordinator;
AvatarInfo avatarForName(String name, SessionCoordinator c, [String id = '']) {
final r = c.serverState.value;
final members = r?.members ?? const <MemberView>[];
// By id when the line has one (names change and repeat), else by name.
+10 -10
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../widgets/text_focus.dart';
@@ -55,7 +55,7 @@ class _ChatPanelState extends State<ChatPanel> {
}
void _send() {
SessionState.instance.coordinator.sendChat(widget.chat.text);
AppScope.of(context).coordinator.sendChat(widget.chat.text);
widget.chat.clear();
widget.chatFocus.requestFocus();
}
@@ -105,7 +105,7 @@ class _ChatPanelState extends State<ChatPanel> {
child: ValueListenableBuilder<bool>(
valueListenable: showActivity,
builder: (context, activity, _) => PartyWatch(builder: (context) {
final all = SessionState.instance.coordinator.feedLog.value;
final all = AppScope.of(context).coordinator.feedLog.value;
final log = activity ? all : [for (final f in all) if (f.action == 'chat') f];
if (log.isEmpty) {
return Center(
@@ -169,7 +169,7 @@ class ActivityRow extends StatelessWidget {
anchors: state.contextMenuAnchors,
buttonItems: [
...state.contextMenuButtonItems,
for (final (label, action) in _messageActions(item.reason, link))
for (final (label, action) in _messageActions(context, item.reason, link))
ContextMenuButtonItem(
label: label,
onPressed: () {
@@ -188,7 +188,7 @@ class ActivityRow extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusAvatar(info: avatarForName(item.actor, null, item.actorId), size: 34, badges: false),
StatusAvatar(info: avatarForName(item.actor, AppScope.of(context).coordinator, item.actorId), size: 34, badges: false),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -238,17 +238,17 @@ String? firstLinkIn(String text) => RegExp(r'https?://[^\s<>"]+', caseSensitive:
/// What a chat message offers: Copy message, and its link's Copy link and
/// Open in browser.
List<(String, VoidCallback)> _messageActions(String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(text, done: 'Message copied')),
List<(String, VoidCallback)> _messageActions(BuildContext context, String text, String? link) => [
(ReuseText.copyMessage, () => copyToClipboard(context, text, done: 'Message copied')),
if (link != null) ...[
(ReuseText.copyLink, () => copyToClipboard(link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(link)),
(ReuseText.copyLink, () => copyToClipboard(context, link, done: 'Link copied')),
(ReuseText.openInBrowser, () => openLink(context, link)),
],
];
/// Right-click beside a chat message: [_messageActions] in the app's menu.
Future<void> _messageMenu(BuildContext context, String text, String? link) async {
final actions = _messageActions(text, link);
final actions = _messageActions(context, text, link);
final pick = await showAppMenu<int>(context, items: [
for (final (i, (label, _)) in actions.indexed) AppMenuItem(value: i, label: label),
]);
+47 -167
View File
@@ -2,12 +2,15 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../app/app_graph.dart';
import '../screens/home/connect_flow.dart';
import '../screens/home/connect_status.dart';
import '../screens/home/health_probe.dart';
import '../state/session_state.dart';
import '../session/invite.dart' show addressIn;
import '../session/navigation.dart' show Place;
import '../state/connect_prefs.dart';
import '../update/update_controller.dart';
import 'motion.dart';
/// Join card text, owned by the shell so it survives the card closing.
@@ -27,60 +30,45 @@ class JoinFields {
}
}
/// 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 (and solo's Invite friends, the Host
/// card): a card over the window. It closes itself once the attempt got
/// in; a failed one shows its error inside.
Future<void> showConnectCard(
/// The strip's Join a friend: a card over the window. It closes itself
/// once the join got in; a failed one shows its error inside.
Future<void> showJoinCard(
BuildContext context, {
required ConnectMode mode,
required ConnectFlow flow,
required JoinFields join,
required HostFields host,
HealthGetter healthGet = defaultHealthGet,
UpdateController? updates,
}) =>
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),
child: _CloseInFriends(
child: JoinCard(flow: flow, fields: join, healthGet: healthGet, updates: updates),
),
);
class _CloseWhenOnline extends StatefulWidget {
class _CloseInFriends extends StatefulWidget {
final Widget child;
const _CloseWhenOnline({required this.child});
const _CloseInFriends({required this.child});
@override
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
State<_CloseInFriends> createState() => _CloseInFriendsState();
}
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
class _CloseInFriendsState extends State<_CloseInFriends> {
late final void Function() _stop;
@override
void initState() {
super.initState();
final navigation = AppScope.of(context).navigation;
var was = navigation.place.peek();
_stop = effect(() {
final s = SessionState.instance;
// Solo is in a session too: wait until it is a hosted server.
if (s.route.value != AppRoute.session || s.solo.value || s.switching.value) return;
final place = navigation.place.value;
final arrived = place == Place.friend && was != Place.friend;
was = place;
if (!arrived) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
@@ -97,7 +85,7 @@ class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
Widget build(BuildContext context) => widget.child;
}
/// Card frame shared by Join and Host.
/// The card frame.
class _Card extends StatelessWidget {
final String title;
final List<Widget> children;
@@ -128,14 +116,15 @@ class _Card extends StatelessWidget {
const _dense = InputDecoration(isDense: true);
/// Recent servers as one-click rows (with live status), then address and
/// password.
/// password. Pasting an invite into the address fills the port too.
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});
final UpdateController? updates;
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet, this.updates});
void _join() => flow.submit(ConnectMode.join, (f) {
void _join() => flow.submit((f) {
f
..host = fields.host.text
..port = fields.port.text.trim()
@@ -149,20 +138,27 @@ class JoinCard extends StatelessWidget {
_join();
}
/// An invite or "host:port" pasted into the address: split it.
void _onAddress(String v) {
flow.clearError();
final a = addressIn(v);
if (a == null || v.trim() == a.host) return;
fields.host.text = a.host;
fields.port.text = a.port;
}
@override
Widget build(BuildContext context) {
final forms = AppScope.of(context).forms;
return ListenableBuilder(
listenable: flow,
builder: (context, _) {
final busy = flow.busy == ConnectMode.join;
final busy = flow.busy;
return _Card(
title: 'Join a friend',
children: [
SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (!r.hosted) r
];
final list = forms.recents.value;
if (list.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -170,10 +166,10 @@ class JoinCard extends StatelessWidget {
const Text('Recent', style: TextStyle(color: Colors.grey)),
RecentServerRows(
servers: list,
enabled: flow.busy == null,
enabled: !busy,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
onForget: forms.forget,
),
const Divider(),
],
@@ -192,8 +188,8 @@ class JoinCard extends StatelessWidget {
key: const ValueKey('join-address'),
controller: fields.host,
autofocus: true,
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP, hostname or invite'),
onChanged: _onAddress,
onSubmitted: (_) => _join(),
),
),
@@ -222,12 +218,13 @@ class JoinCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.join),
ConnectButton(flow: flow, label: 'Join', onSubmit: _join, expand: true),
ConnectFeedback(flow: flow, updates: updates),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Your friend gives you the address and password. No account or Jellyfin needed.',
'Your friend gives you their invite (the address) and the password, if any. '
'No account or Jellyfin needed. Your own server stays as it is.',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
@@ -238,122 +235,6 @@ class JoinCard extends StatelessWidget {
}
}
/// 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) {
// From a solo session this is "Invite friends": same server settings,
// and what plays carries over.
final solo = SessionState.instance.solo.peek();
return ListenableBuilder(
listenable: flow,
builder: (context, _) => _Card(
title: solo ? 'Invite friends' : '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: solo ? 'Start hosting' : 'Host', onSubmit: _host, expand: true),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
solo
? 'Your video keeps playing from where it is; friends join with your address.'
: 'You pick what to watch afterwards, in the library.',
style: const 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 {
@@ -476,8 +357,7 @@ class _RecentRow extends StatelessWidget {
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',
message: versionGateText(h),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
+7 -7
View File
@@ -99,7 +99,7 @@ Handoff classifyHandoff({
/// What the card's buttons ask the shell to do.
enum HandoffAction {
/// Put the (first) video up (offline: watch it solo).
/// Put the (first) video up.
putUp,
/// Add the video's folder (or the folder) to Files and open it.
addFolder,
@@ -115,11 +115,11 @@ enum HandoffAction {
/// pops the [HandoffAction] chosen.
class HandoffCard extends StatelessWidget {
final Handoff item;
/// Nobody else watches (offline or solo): videos play, no relay talk.
final bool solo;
/// Nobody else is in the server: videos play, no relay talk.
final bool alone;
/// In a server: videos and links can go to Up next.
final bool queue;
const HandoffCard({super.key, required this.item, required this.solo, this.queue = false});
const HandoffCard({super.key, required this.item, required this.alone, this.queue = false});
@override
Widget build(BuildContext context) {
@@ -144,7 +144,7 @@ class HandoffCard extends StatelessWidget {
baseName(paths.first),
paths.length > 1 ? 'and ${paths.length - 1} more · the first one goes up' : 'A video on this computer',
[
button(HandoffAction.putUp, Icons.play_arrow, solo ? 'Play' : 'Put it up', primary: true),
button(HandoffAction.putUp, Icons.play_arrow, alone ? 'Play' : 'Put it up', primary: true),
...queueButtons,
button(HandoffAction.addFolder, Icons.create_new_folder_outlined, 'Add its folder to Files'),
],
@@ -158,9 +158,9 @@ class HandoffCard extends StatelessWidget {
HandoffLink(:final url) => (
Icons.link,
url,
solo ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
alone ? 'Opens it on the Link page' : 'Pick how the others get it on the Link page',
[
button(HandoffAction.openLink, Icons.link, solo ? 'Open in Link' : 'Put it up as a link…',
button(HandoffAction.openLink, Icons.link, alone ? 'Open in Link' : 'Put it up as a link…',
primary: true),
...queueButtons,
],
+32 -30
View File
@@ -10,7 +10,7 @@ import '../screens/home/media_picker.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'motion.dart';
// The Library's Link source: a big paste field, a preview of what the link
@@ -239,7 +239,6 @@ class LinkPreviewStore {
/// What a link is (see [LinkResolver.probe]).
typedef LinkProber = Future<LinkProbe> Function(String url);
Future<LinkProbe> defaultLinkProber(String url) => SessionState.instance.ports.links.probe(url);
String _host(Uri uri) => uri.host.startsWith('www.') ? uri.host.substring(4) : uri.host;
@@ -254,7 +253,7 @@ String clockText(double seconds) {
/// duration and thumbnail; a media file its file name and host; a link
/// that does not open, why ([LinkPreview.problem]). Null for text that is
/// not an http(s) link.
Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProber}) async {
Future<LinkPreview?> previewLink(String url, {required LinkProber probe}) async {
final uri = Uri.tryParse(url.trim());
if (uri == null || !(uri.scheme == 'http' || uri.scheme == 'https') || uri.host.isEmpty) return null;
final host = _host(uri);
@@ -290,7 +289,7 @@ Future<LinkPreview?> previewLink(String url, {LinkProber probe = defaultLinkProb
/// [url]'s preview at once when it was kept before ([LinkPreviewStore]),
/// else probed and kept (the Up next popover's paste; the Link page has
/// its own refresh rules).
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, LinkProber probe = defaultLinkProber}) async {
Future<LinkPreview?> cachedLinkPreview(String url, {RecentLinks? recents, required LinkProber probe}) async {
final store = (recents ?? RecentLinks.instance).previews;
await store.ensureLoaded();
final kept = store.lookup(url);
@@ -344,27 +343,28 @@ String linkRelayHelp(LinkRelay r) => switch (r) {
};
/// Under Play when nobody else watches.
const soloPlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
const alonePlayHint = 'Plays just for you. Invite friends any time; it keeps playing.';
/// The Link source. [solo]: nobody else watches (offline or solo), so the
/// The Link source. [alone]: nobody else is in the server, so the
/// button says Play and there are no relay modes (the link plays direct).
class LibraryLinkSource extends StatefulWidget {
final MediaPickerController controller;
final bool solo;
final bool alone;
/// Puts the link up; returns an error text, or null when sent.
final Future<String?> Function(LinkMedia media) onPutUp;
/// Adds the link to Up next in the chosen mode; null: no server.
final AddToQueue? onAddToQueue;
final RecentLinks recents;
final LinkProber probe;
/// What a link is; defaults to the app's [LinkResolver.probe].
final LinkProber? probe;
const LibraryLinkSource({
super.key,
required this.controller,
required this.onPutUp,
this.onAddToQueue,
required this.recents,
this.solo = false,
this.probe = defaultLinkProber,
this.alone = false,
this.probe,
});
@override
@@ -372,6 +372,8 @@ class LibraryLinkSource extends StatefulWidget {
}
class _LibraryLinkSourceState extends State<LibraryLinkSource> {
LinkProber get _probe => widget.probe ?? AppScope.of(context).ports.links.probe;
MediaPickerController get _c => widget.controller;
final _previews = <String, LinkPreview?>{};
String _previewUrl = '';
@@ -450,7 +452,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (DateTime.now().difference(known.at) > linkPreviewFreshFor) unawaited(_refresh(url));
return;
}
final p = await previewLink(url, probe: widget.probe);
final p = await previewLink(url, probe: _probe);
_previews[url] = p;
_keep(url, p);
if (mounted) setState(() {});
@@ -459,7 +461,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
/// Probes [url] again behind what is shown; a site out of reach keeps
/// what was found before. Returns the preview shown afterwards.
Future<LinkPreview?> _refresh(String url) async {
final p = await previewLink(url, probe: widget.probe);
final p = await previewLink(url, probe: _probe);
if (p == null || p.problem == unreachableText) return _previews[url];
_previews[url] = p;
_keep(url, p);
@@ -491,12 +493,12 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
// What the probe found refuses it here, with "Put it up anyway": the
// server or a friend (with cookies) may still open it.
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: widget.probe))?.problem;
final problem = anyway ? null : (_previews[url] ?? await previewLink(url, probe: _probe))?.problem;
String? error = problem;
if (problem == null) {
try {
error = await widget.onPutUp(
LinkMedia(url, relay: widget.solo ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
LinkMedia(url, relay: widget.alone ? LinkRelay.direct : _c.linkRelay, anyway: anyway));
} catch (e) {
error = '$e';
}
@@ -510,7 +512,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (error == null) {
var p = _previews[url];
if (p == null) {
p = _previews[url] = await previewLink(url, probe: widget.probe);
p = _previews[url] = await previewLink(url, probe: _probe);
_keep(url, p);
}
unawaited(widget.recents.add(RecentLink(
@@ -542,7 +544,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
});
String? error;
try {
error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next);
error = await add(LinkMedia(link, relay: widget.alone ? LinkRelay.direct : _c.linkRelay), next: next);
} catch (e) {
error = '$e';
}
@@ -556,7 +558,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
/// Right-click on a recent link: put it up or queue it, copy or open it.
Future<void> _recentMenu(BuildContext context, RecentLink l) async {
final pick = await showAppMenu<String>(context, items: [
AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'),
AppMenuItem(value: 'up', label: widget.alone ? 'Play' : 'Put it up'),
if (widget.onAddToQueue != null) ...const [
AppMenuItem(value: 'queue', label: 'Add to queue'),
AppMenuItem(value: 'next', label: 'Play next'),
@@ -565,14 +567,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
const AppMenuItem(value: 'title', label: ReuseText.copyTitle),
const AppMenuItem(value: 'open', label: ReuseText.openInBrowser),
]);
if (pick == null || !mounted) return;
if (pick == null || !mounted || !context.mounted) return;
switch (pick) {
case 'copy':
return copyToClipboard(l.url, done: 'Link copied');
return copyToClipboard(context, l.url, done: 'Link copied');
case 'title':
return copyToClipboard(l.title, done: 'Title copied');
return copyToClipboard(context, l.title, done: 'Title copied');
case 'open':
return openLink(l.url);
return openLink(context, l.url);
}
_c.link.text = l.url;
switch (pick) {
@@ -592,11 +594,11 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
]);
switch (pick) {
case 'copy':
await copyToClipboard(url, done: 'Link copied');
if (context.mounted) await copyToClipboard(context, url, done: 'Link copied');
case 'title':
await copyToClipboard(p!.title, done: 'Title copied');
if (context.mounted) await copyToClipboard(context, p!.title, done: 'Title copied');
case 'open':
await openLink(url);
if (context.mounted) await openLink(context, url);
}
}
@@ -614,7 +616,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final solo = widget.solo;
final alone = widget.alone;
final url = _c.link.text.trim();
final preview = url.isEmpty ? null : _previews[url];
final loading = url.isNotEmpty && !_previews.containsKey(url);
@@ -638,7 +640,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(solo ? 'Play' : 'Put it up'),
label: Text(alone ? 'Play' : 'Put it up'),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
@@ -705,10 +707,10 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
if (queue != null) ...[const SizedBox(width: 8), queue],
]),
),
if (solo || _error != null) ...[
if (alone || _error != null) ...[
const SizedBox(height: 8),
Text(
_error ?? soloPlayHint,
_error ?? alonePlayHint,
key: const ValueKey('link-hint'),
textAlign: TextAlign.center,
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
@@ -718,7 +720,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
child: TextButton(
key: const ValueKey('link-put-up-anyway'),
onPressed: _busy ? null : () => _go(anyway: true),
child: Text(solo ? 'Play it anyway' : 'Put it up anyway'),
child: Text(alone ? 'Play it anyway' : 'Put it up anyway'),
),
),
],
@@ -737,7 +739,7 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> {
),
),
),
if (!solo) ...[
if (!alone) ...[
const SizedBox(height: 28),
Text('How the others get it', style: t.titleSmall),
const SizedBox(height: 10),
+17 -17
View File
@@ -14,7 +14,7 @@ import '../jellyfin/picker_source.dart';
import '../jellyfin/session_media.dart';
import '../screens/home/media_picker.dart';
import '../session/app_events.dart' show ActionFailed;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'library_link.dart';
import 'motion.dart';
import 'welcome.dart';
@@ -68,12 +68,11 @@ const detailsDockFrom = 1000.0;
/// Jellyfin cards select on click and open their details in a panel at
/// the right (docked from [detailsDockFrom], floating over the library
/// below it); a double-click or the panel's button puts it up. With
/// nobody else watching ([solo]: offline or a solo session) the buttons
/// say Play and putting something up starts (or uses) a solo session.
/// nobody else in the server ([alone], D9) the buttons say Play.
class LibraryTab extends StatefulWidget {
final MediaPickerController controller;
final JellyfinAccounts accounts;
final bool solo;
final bool alone;
/// This app hosts the server: its files stream from here directly.
final bool isHost;
final BrowseStart? jellyfinStart;
@@ -85,9 +84,10 @@ class LibraryTab extends StatefulWidget {
final VoidCallback? onOpenIntegrations;
/// Defaults to [RecentLinks.instance].
final RecentLinks? recentLinks;
final LinkProber probe;
/// What a link is; defaults to the app's.
final LinkProber? probe;
/// The Files source (tests inject one); defaults to [FilesPage].
final Widget Function(BuildContext context, {required bool solo, required bool relayed,
final Widget Function(BuildContext context, {required bool alone, required bool relayed,
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
/// Clock for the Jellyfin Continue row (tests pin it).
final DateTime Function() now;
@@ -97,7 +97,7 @@ class LibraryTab extends StatefulWidget {
super.key,
required this.controller,
required this.accounts,
required this.solo,
required this.alone,
required this.isHost,
required this.onPutUp,
this.onAddToQueue,
@@ -105,7 +105,7 @@ class LibraryTab extends StatefulWidget {
this.jellyfinStart,
this.onOpenIntegrations,
this.recentLinks,
this.probe = defaultLinkProber,
this.probe,
this.filesBuilder,
this.now = DateTime.now,
this.banner,
@@ -200,14 +200,14 @@ class _LibraryTabState extends State<LibraryTab> {
/// A card's menu: errors as a toast (the Notifier's).
Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async {
final error = await _queueJellyfin(sel, next: next);
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
}
/// Double-click on a card: put it up, errors as a toast.
Future<void> _doublePutUp(JellyfinSelection sel) async {
_c.select(sel);
final error = await _putUpJellyfin(sel);
if (error != null) SessionState.instance.notifier.notify(ActionFailed(error));
if (error != null && mounted) AppScope.of(context).notifier.notify(ActionFailed(error));
}
// -------------------------------------------------------------- build
@@ -271,7 +271,7 @@ class _LibraryTabState extends State<LibraryTab> {
),
MediaKind.link => LibraryLinkSource(
controller: _c,
solo: widget.solo,
alone: widget.alone,
recents: widget.recentLinks ?? RecentLinks.instance,
probe: widget.probe,
onPutUp: _put,
@@ -305,7 +305,7 @@ class _LibraryTabState extends State<LibraryTab> {
child: JellyfinDetailsPanel(
controller: _browse,
selection: shown,
solo: widget.solo,
alone: widget.alone,
onPutUp: _putUpJellyfin,
onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin,
onClose: () => _c.select(null),
@@ -366,17 +366,17 @@ class _LibraryTabState extends State<LibraryTab> {
final relayed = !widget.isHost;
Future<String?> play(String path) => _put(FileMedia(path));
return SignalBuilder(builder: (context) {
final session = SessionState.instance;
final now = session.route.value == AppRoute.session
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
final graph = AppScope.of(context);
final now = graph.navigation.online.value
? nowPlayingFile(graph.coordinator.serverState.value?.media, graph.forms.form.mediaPath)
: null;
final b = widget.filesBuilder;
if (b != null) {
return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now);
return b(context, alone: widget.alone, relayed: relayed, onPlay: play, nowPlayingPath: now);
}
final add = widget.onAddToQueue;
return FilesPage(
solo: widget.solo,
alone: widget.alone,
relayed: relayed,
onPlay: play,
onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next),
+3 -3
View File
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart' show RelayConstants;
import '../session/app_events.dart' show ProblemMarked;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'motion.dart';
@@ -17,8 +17,8 @@ const markProblemKey = SingleActivator(LogicalKeyboardKey.keyB, control: true, s
/// Asks for the note, then marks; the toast says where it went.
Future<void> showMarkProblem(BuildContext context) async {
final note = await showPopover<String>(context, at: Alignment.center, child: const MarkProblemCard());
if (note == null) return;
final coord = SessionState.instance.coordinator;
if (note == null || !context.mounted) return;
final coord = AppScope.of(context).coordinator;
final inSession = await coord.markProblem(note);
coord.notify(ProblemMarked(inSession: inSession));
}
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
import 'motion.dart';
@@ -15,22 +15,22 @@ Uint8List? Function(String hash)? debugArtBytes;
/// The one lookup for media art (cover, banner) by content hash: null
/// when there is none or it has not arrived yet (listeners of
/// [artChanges] hear when it lands).
Uint8List? artBytes(String hash) {
Uint8List? artBytes(BuildContext context, String hash) {
if (hash.isEmpty) return null;
final debug = debugArtBytes;
if (debug != null) return debug(hash);
return SessionState.instance.coordinator.art.bytesFor(hash);
return AppScope.of(context).coordinator.art.bytesFor(hash);
}
/// Notifies when art arrives.
Listenable artChanges() => SessionState.instance.coordinator.art;
Listenable artChanges(BuildContext context) => AppScope.of(context).coordinator.art;
final _images = <String, MemoryImage>{};
/// [hash]'s art as an image, the same provider while the bytes are the
/// same (so a rebuild never decodes again).
ImageProvider? artImage(String hash) {
final bytes = artBytes(hash);
ImageProvider? artImage(BuildContext context, String hash) {
final bytes = artBytes(context, hash);
if (bytes == null) return null;
final hit = _images[hash];
if (hit != null && identical(hit.bytes, bytes)) return hit;
@@ -59,7 +59,7 @@ class ArtCover extends StatelessWidget {
@override
Widget build(BuildContext context) {
final image = artImage(hash);
final image = artImage(context, hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container(
width: wide ? width * 1.5 * 16 / 9 : width,
@@ -166,10 +166,10 @@ class NowPlayingTitle extends StatelessWidget {
Widget build(BuildContext context) => GestureDetector(
onSecondaryTap: () => nowPlayingMenu(context),
child: ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
now: AppScope.of(context).coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
@@ -182,7 +182,7 @@ class NowPlayingTitle extends StatelessWidget {
/// Copy link and Open in browser; a Jellyfin item on a server linked here,
/// Open in Jellyfin. [context]: where the menu opens.
Future<void> nowPlayingMenu(BuildContext context) async {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final now = coord.nowPlaying.peek();
if (now == null) return;
final link = coord.currentLink();
@@ -200,12 +200,12 @@ Future<void> nowPlayingMenu(BuildContext context) async {
]);
switch (pick) {
case 'title':
await copyToClipboard(title, done: 'Title copied');
if (context.mounted) await copyToClipboard(context, title, done: 'Title copied');
case 'link':
await copyToClipboard(link!, done: 'Link copied');
if (context.mounted) await copyToClipboard(context, link!, done: 'Link copied');
case 'open':
await openLink(link!);
if (context.mounted) await openLink(context, link!);
case 'jellyfin':
await openLink(page!);
if (context.mounted) await openLink(context, page!);
}
}
+193 -216
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
@@ -7,7 +5,10 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion, shortVersion;
import '../session/delivery.dart';
import '../session/party.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../session/app_events.dart' show ActionFailed;
import '../session/invite.dart';
import '../session/navigation.dart' show Place;
import '../widgets/app_keys.dart';
import '../widgets/app_menu.dart';
import '../widgets/copy_open.dart';
@@ -36,9 +37,11 @@ const stripAvatarSize = 42.0;
const stripCompactBelow = 1100.0;
/// The 60 px strip over everything: your avatar (profile), the tabs, and
/// the party: offline Join a friend / Host, solo a "Solo" chip, Invite
/// friends and Stop, online the avatars (members card), server and
/// readiness, chat and Leave. Empty space drags the window.
/// the party. Alone on your own server it is a plain player's: Join a
/// friend and Invite (a "Sharing" chip while your server is open to
/// friends); with others there the avatars (members card), server and
/// readiness, chat, and Invite (yours) or Leave (a friend's). Empty space
/// drags the window.
class PartyStrip extends StatelessWidget {
final ShellTab tab;
final ValueChanged<ShellTab> onTab;
@@ -46,11 +49,11 @@ class PartyStrip extends StatelessWidget {
final int unread;
final VoidCallback onChat;
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onLeave;
final VoidCallback onProfile;
/// Solo: host from here, keeping what plays.
final VoidCallback? onInvite;
/// Opens your server to friends (if it is not yet) and copies the
/// invite.
final VoidCallback onInvite;
/// Online with something up but the player closed: brings it back.
final VoidCallback? onShowPlayer;
@@ -65,10 +68,9 @@ class PartyStrip extends StatelessWidget {
required this.unread,
required this.onChat,
required this.onJoin,
required this.onHost,
required this.onLeave,
required this.onProfile,
this.onInvite,
required this.onInvite,
this.onShowPlayer,
this.windowControls = true,
});
@@ -79,11 +81,18 @@ class PartyStrip extends StatelessWidget {
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 solo = online && (session.solo.value || session.switching.value);
final self = online ? coord.selfMember.value : null;
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final online = nav.online.value;
final home = nav.home.value;
final friend = nav.place.value == Place.friend;
// Others there (or a friend's server): the party; else a plain
// player (D9).
final social = friend || (online && !coord.alone.value);
final shared = graph.yourServer.shared.value;
final canInvite = graph.yourServer.port.value != null && !graph.yourServer.busy.value;
final self = social ? coord.selfMember.value : null;
return Row(
children: [
const SizedBox(width: 16),
@@ -97,11 +106,11 @@ class PartyStrip extends StatelessWidget {
child: StatusAvatar(
info: selfAvatarInfo(
ready: self?.ready ?? false,
host: online && !solo && coord.hostFile != null,
host: social && home,
error: online ? coord.playerError.value : '',
),
size: stripAvatarSize,
ring: online,
ring: social,
),
),
),
@@ -140,29 +149,7 @@ class PartyStrip extends StatelessWidget {
onPressed: onShowPlayer,
icon: const Icon(Icons.smart_display_outlined, size: 20),
),
if (solo) ...[
_SoloChip(compact: compact, busy: session.switching.value),
const SizedBox(width: 10),
if (compact)
FilledButton(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
child: const Text('Invite'),
)
else
FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: session.switching.value ? null : onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite friends'),
),
const SizedBox(width: 8),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: session.switching.value ? null : onLeave,
child: const Text('Stop'),
),
] else if (online) ...[
if (social) ...[
PartyWatch(
builder: (context) => AvatarStack(
key: const ValueKey('party-avatars'),
@@ -196,41 +183,30 @@ class PartyStrip extends StatelessWidget {
),
),
const SizedBox(width: 4),
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
),
if (friend)
OutlinedButton(
key: const ValueKey('strip-leave'),
onPressed: onLeave,
child: const Text('Leave'),
)
else
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
] else ...[
if (!compact) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: scheme.outline, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(width: 12),
],
if (compact) ...[
FilledButton(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join')),
if (shared != null) ...[
_SharingChip(compact: compact, port: shared.port),
const SizedBox(width: 8),
FilledButton.tonal(key: const ValueKey('strip-host'), onPressed: onHost, child: const Text('Host')),
] else ...[
FilledButton.icon(
],
if (compact)
FilledButton.tonal(key: const ValueKey('strip-join'), onPressed: onJoin, child: const Text('Join'))
else
FilledButton.tonalIcon(
key: const ValueKey('strip-join'),
onPressed: onJoin,
icon: const Icon(Icons.login_rounded, size: 18),
label: const Text('Join a friend'),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
key: const ValueKey('strip-host'),
onPressed: onHost,
icon: const Icon(Icons.podcasts_rounded, size: 18),
label: const Text('Host'),
),
],
const SizedBox(width: 8),
_InviteButton(compact: compact, onInvite: canInvite ? onInvite : null),
],
const SizedBox(width: 8),
if (windowControls) ...[
@@ -282,14 +258,15 @@ class _ServerLabel extends StatelessWidget {
builder: (context) {
// "connecting…", "reconnecting…", "ready 2/3" (the ready counter
// counts who the presence policy counts), from the one status.
final line = SessionState.instance.coordinator.status.value.party;
final graph = AppScope.of(context);
final line = graph.coordinator.status.value.party;
return Column(
key: const ValueKey('server-label'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
serverName(),
serverName(graph),
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
),
Text(
@@ -306,50 +283,74 @@ class _ServerLabel extends StatelessWidget {
);
}
/// "Solo" alone, "Your server" when hosting, else the joined server's
/// name (its `--name`), or its address while unknown.
String serverName() {
final session = SessionState.instance;
if (session.solo.value) return 'Solo';
if (session.coordinator.hostFile != null) return 'Your server';
final label = session.serverLabel.value;
return label.isNotEmpty ? label : session.lastForm.host;
/// "Your server" at home, else the joined server's name (its `--name`),
/// or its address while unknown.
String serverName(AppGraph graph) {
final nav = graph.navigation;
if (nav.home.value) return 'Your server';
final label = nav.serverLabel.value;
return label.isNotEmpty ? label : nav.address.value;
}
/// Solo: just you, on your own; a spinner while it becomes a server.
class _SoloChip extends StatelessWidget {
/// Invite: opens your server to friends (if it is not yet) and copies
/// the invite.
class _InviteButton extends StatelessWidget {
final bool compact;
final bool busy;
const _SoloChip({required this.compact, required this.busy});
final VoidCallback? onInvite;
const _InviteButton({required this.compact, required this.onInvite});
@override
Widget build(BuildContext context) {
const tip = 'Copy an invite friends can join with (opens your server to them)';
if (compact) {
return Tooltip(
message: tip,
child: FilledButton(key: const ValueKey('strip-invite'), onPressed: onInvite, child: const Text('Invite')),
);
}
return Tooltip(
message: tip,
child: FilledButton.icon(
key: const ValueKey('strip-invite'),
onPressed: onInvite,
icon: const Icon(Icons.group_add_outlined, size: 18),
label: const Text('Invite'),
),
);
}
}
/// Your server is open to friends and nobody came yet: the port; a click
/// opens the members card (the invite, Stop sharing).
class _SharingChip extends StatelessWidget {
final bool compact;
final int port;
const _SharingChip({required this.compact, required this.port});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: busy
? 'Starting your server…'
: 'Watching on your own. Nobody can join until you invite friends.',
child: Container(
key: const ValueKey('solo-chip'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (busy)
const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
else
Icon(Icons.person_outline_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text(busy ? 'Inviting…' : 'Solo', style: const TextStyle(fontWeight: FontWeight.w600)),
if (!compact && !busy) ...[
message: 'Friends can join with your invite. Click for the invite or to stop sharing.',
child: InkWell(
key: const ValueKey('sharing-chip'),
borderRadius: BorderRadius.circular(20),
onTap: () => showMembersCard(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: scheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.podcasts_rounded, size: 16, color: scheme.primary),
const SizedBox(width: 6),
Text('only you', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
const Text('Sharing', style: TextStyle(fontWeight: FontWeight.w600)),
if (!compact) ...[
const SizedBox(width: 6),
Text('port $port', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
],
],
],
),
),
),
);
@@ -425,50 +426,16 @@ class StripTab extends StatelessWidget {
}
/// Who is in the server, from the avatars: status, host / relay pills,
/// per member force ready, nudge and sit out, and the address to invite
/// friends.
/// per member force ready, nudge and sit out, and the invite (yours) or
/// the address (a friend's).
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
context,
at: const Alignment(0.7, -0.84),
child: const MembersCard(),
);
/// "ip:port" for each of [addresses] friends could dial: IPv4, neither
/// loopback nor link-local, private (LAN) ranges first, no repeats.
List<String> inviteAddresses(Iterable<InternetAddress> addresses, String port) {
bool private(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
final usable = [
for (final a in addresses)
if (a.type == InternetAddressType.IPv4 &&
!a.isLoopback &&
!a.isLinkLocal &&
!(a.rawAddress[0] == 169 && a.rawAddress[1] == 254))
a,
];
final ordered = [
...usable.where((a) => private(a.rawAddress)),
...usable.where((a) => !private(a.rawAddress)),
];
return {for (final a in ordered) '${a.address}:$port'}.toList();
}
/// This machine's [inviteAddresses] (empty when the interfaces can't be
/// listed).
Future<List<String>> lanInviteAddresses(String port) async {
try {
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
return inviteAddresses([for (final i in interfaces) ...i.addresses], port);
} catch (e) {
debugPrint('list network interfaces failed: $e');
return const [];
}
}
class MembersCard extends StatelessWidget {
/// This machine's invite addresses for a port (tests pass fixed ones).
final Future<List<String>> Function(String port) lanAddresses;
const MembersCard({super.key, this.lanAddresses = lanInviteAddresses});
const MembersCard({super.key});
@override
Widget build(BuildContext context) {
@@ -481,15 +448,14 @@ class MembersCard extends StatelessWidget {
width: 340,
child: PartyWatch(
builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final graph = AppScope.of(context);
final coord = graph.coordinator;
final nav = graph.navigation;
final r = coord.serverState.value;
final hosting = coord.hostFile != null;
final address = hosting
? null
: '${session.lastForm.host}:${session.lastForm.port}';
final home = nav.home.value;
final address = home ? null : nav.address.value;
final delivery = coord.status.value.delivery;
final server = session.serverVersion.value;
final server = nav.serverVersion.value;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -500,7 +466,7 @@ class MembersCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
['In the server', ?address].join(' · '),
home ? 'Your server' : ['In the server', ?address].join(' · '),
style: const TextStyle(fontWeight: FontWeight.w600),
),
if (server.isNotEmpty)
@@ -531,23 +497,20 @@ class MembersCard extends StatelessWidget {
),
),
const Divider(),
if (address != null)
if (home)
const YourInvite()
else if (address != null && address.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(address, done: 'Invite address copied'),
onPressed: () => copyToClipboard(context, address, done: 'Invite address copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
),
),
)
else
_HostInvite(
port: session.lastForm.hostPort,
lanAddresses: lanAddresses,
),
],
);
@@ -571,7 +534,7 @@ class _MemberTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final m = member;
final since = m.goneSince;
final status = m.presence == Presence.gone
@@ -660,76 +623,90 @@ class VersionText extends StatelessWidget {
bool versionDiffers(String version, {String own = appVersion}) =>
version.isNotEmpty && own != '0.0.0-dev' && version != '0.0.0-dev' && version != own;
/// Hosting: the addresses friends on this network can join, the first
/// one behind "Copy invite address", and what friends elsewhere need.
class _HostInvite extends StatefulWidget {
final String port;
final Future<List<String>> Function(String port) lanAddresses;
const _HostInvite({required this.port, required this.lanAddresses});
@override
State<_HostInvite> createState() => _HostInviteState();
}
class _HostInviteState extends State<_HostInvite> {
late final Future<List<String>> _addresses = widget.lanAddresses(widget.port);
/// Your server in the members card: shared, the invite's address
/// (Copy invite), a hint for friends outside the network and Stop
/// sharing; not shared, Invite (opens it and copies the invite).
class YourInvite extends StatelessWidget {
const YourInvite({super.key});
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return FutureBuilder<List<String>>(
future: _addresses,
builder: (context, snap) {
final list = snap.data ?? const <String>[];
void copy(String a) => copyToClipboard(a, done: 'Invite address copied');
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('host-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
final graph = AppScope.of(context);
final yours = graph.yourServer;
return SignalBuilder(builder: (context) {
final invite = yours.shared.value;
final busy = yours.busy.value || yours.port.value == null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Column(
key: const ValueKey('your-invite'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (invite == null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 6),
child: Text('Only you can reach it. Invite opens it to friends; nothing you watch stops.',
style: TextStyle(color: muted, fontSize: 12)),
),
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: list.isEmpty ? null : () => copy(list.first),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite address'),
onPressed: busy ? null : () => inviteFriends(context),
icon: const Icon(Icons.group_add_outlined, size: 16),
label: const Text('Invite'),
),
if (snap.connectionState != ConnectionState.done)
const SizedBox.shrink()
else if (list.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text('No network address found', style: TextStyle(color: muted)),
)
else
for (final a in list)
InkWell(
key: ValueKey('invite-$a'),
borderRadius: BorderRadius.circular(8),
onTap: () => copy(a),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Expanded(child: Text(a, style: const TextStyle(fontFamily: 'monospace'))),
Icon(Icons.copy, size: 14, color: muted),
],
),
),
),
] else ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(invite.address,
key: const ValueKey('invite-address'), style: const TextStyle(fontFamily: 'monospace')),
),
Wrap(
spacing: 4,
children: [
TextButton.icon(
key: const ValueKey('copy-invite'),
onPressed: () => copyToClipboard(context, invite.text, done: 'Invite copied'),
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy invite'),
),
TextButton(
key: const ValueKey('stop-sharing'),
onPressed: busy ? null : () => yours.stopSharing(),
child: const Text('Stop sharing'),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
child: Text(
'Works for friends on your network. Friends elsewhere need port '
'${widget.port} forwarded on your router, or a server they can '
'all reach (like a VPS).',
'Works for friends on your network. Friends elsewhere need port ${invite.port} '
'forwarded on your router, or a server they can all reach (like a VPS). '
'Stop sharing sends everyone else out; you and Up next stay.',
style: TextStyle(color: muted, fontSize: 12),
),
),
],
),
);
},
);
],
),
);
});
}
}
/// Invite: opens your server to friends if it is not yet and copies the
/// invite ("Invite copied"); a port that can't be opened is a notice.
Future<void> inviteFriends(BuildContext context) async {
final graph = AppScope.of(context);
final Invite invite;
try {
invite = await graph.yourServer.invite();
} catch (e) {
final port = graph.forms.form.hostPort;
graph.notifier.notify(ActionFailed(
"couldn't open your server on port $port: $e — pick another port in Settings → Party & network"));
return;
}
if (!context.mounted) return;
await copyToClipboard(context, invite.text, done: 'Invite copied · ${invite.address}');
}
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../widgets/app_menu.dart' show AppGlass;
import 'avatar.dart';
import 'motion.dart';
@@ -13,8 +13,8 @@ export '../session/status.dart' show StatusAction, StatusKind, StatusLevel, Stat
/// The pill's line from the session's [SessionCoordinator.status]
/// (tracked: call inside a builder); "Connecting…" only once it has taken
/// [PlayerStatus.connectGrace] ([slowConnect]).
StatusLine? playerStatusNow({bool slowConnect = false}) {
final line = SessionState.instance.coordinator.status.value.line;
StatusLine? playerStatusNow(SessionCoordinator coord, {bool slowConnect = false}) {
final line = coord.status.value.line;
if (line?.kind == StatusKind.connecting && !slowConnect) return null;
return line;
}
@@ -73,9 +73,9 @@ class _PlayerStatusState extends State<PlayerStatus> {
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
_trackConnecting(coord.status.value.line);
final view = playerStatusNow(slowConnect: _slow);
final view = playerStatusNow(coord, slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
final ready = view?.action == StatusAction.ready;
+9 -8
View File
@@ -10,7 +10,7 @@ import 'package:sync_relay_core/core.dart' show Presence;
import '../jellyfin/accounts.dart';
import '../session/identity.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../update/update_controller.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -74,11 +74,12 @@ class _ProfileCardState extends State<ProfileCard> {
child: SizedBox(
width: 360,
child: SignalBuilder(builder: (context) {
final session = SessionState.instance;
final coord = session.coordinator;
final online = session.route.value == AppRoute.session;
final graph = AppScope.of(context);
final coord = graph.coordinator;
// Alone it is a plain player: no ring, no crown.
final online = graph.navigation.online.value && !coord.alone.value;
final self = online ? coord.selfMember.value : null;
final hosting = online && !session.solo.value && coord.hostFile != null;
final hosting = online && graph.navigation.home.value;
final color = _p.color.value;
final png = _p.picture.value;
final out = self?.presence == Presence.away;
@@ -164,7 +165,7 @@ class _ProfileCardState extends State<ProfileCard> {
runSpacing: 4,
children: [
if (hosting) const Pill('host', Colors.amber),
Pill(online ? 'in a server' : 'offline',
Pill(online ? 'with others' : 'just you',
online ? Colors.greenAccent : Colors.grey),
if (online)
Pill(out ? 'sitting out' : (self?.ready ?? false) ? 'ready' : 'not ready',
@@ -231,7 +232,7 @@ class _ProfileCardState extends State<ProfileCard> {
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.',
: 'Others see your name and picture when they join you or you join them. Colour is yours only.',
style: small,
),
),
@@ -331,7 +332,7 @@ class _CropPickerState extends State<CropPicker> {
Future<void> _pick() async {
final List<String> files;
try {
files = await SessionState.instance.ports.folders.pickFiles(
files = await AppScope.of(context).ports.folders.pickFiles(
dialogTitle: 'Pick a picture',
extensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
);
+10 -7
View File
@@ -18,7 +18,7 @@ import '../screens/session/player_controls.dart';
import '../session/app_events.dart' show ShaderMessage;
import '../session/session_coordinator.dart';
import '../shaders/shader_presets.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../state/settings.dart';
import '../widgets/app_keys.dart';
import '../widgets/text_focus.dart';
@@ -122,12 +122,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
onLeave: () => widget.onLeave(),
);
SessionCoordinator get _coord => SessionState.instance.coordinator;
late final SessionCoordinator _coord = AppScope.of(context).coordinator;
bool get _full => widget.mode == PlayerMode.full;
@override
void initState() {
super.initState();
// The model reads it in dispose too, when the tree is no longer there.
_model;
_coord.jellyfin.ensureLoaded();
HardwareKeyboard.instance.addHandler(_globalKey);
// Tab (AppKeys) reaches the chat through here in fullscreen.
@@ -173,8 +175,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
/// app-wide ([AppKeys]).
bool _globalKey(KeyEvent event) {
if (event is! KeyDownEvent || !mounted) return false;
if (SessionState.instance.route.peek() != AppRoute.session) return false;
// Mini: Esc is the last step out, asking to leave the server.
if (!AppScope.of(context).navigation.online.peek()) return false;
// Mini: Esc is the last step out (a friend's server: asking to leave
// it; yours: the player closes).
if (!_full) {
if (event.logicalKey != LogicalKeyboardKey.escape) return false;
if (ModalRoute.of(context)?.isCurrent == false || textInputFocused()) return false;
@@ -301,7 +304,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
if (_full) ...[
_blockedCard(),
_actionArea(),
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
if (widget.fullscreen) ...[if (!_coord.alone.value) _pausedParty(), _unreadChip()],
_volumeReadout(),
] else
_miniOverlay(),
@@ -641,7 +644,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(playerStatusNow()?.short ?? '',
child: Text(playerStatusNow(_coord)?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
@@ -786,7 +789,7 @@ class _MiniSeekBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final playback = SessionState.instance.coordinator.playback;
final playback = AppScope.of(context).coordinator.playback;
return SignalBuilder(builder: (context) {
final d = playback.duration.value;
final p = playback.position.value;
+32 -28
View File
@@ -17,7 +17,7 @@ import '../screens/home/ui.dart' show appName, displayVersion;
import '../session/auto_sit_out.dart' show AutoSitOutTimes;
import '../sfx/sfx.dart';
import '../sfx/sound_bank.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../state/settings.dart';
import '../update/update_controller.dart';
import '../widgets/app_menu.dart';
@@ -86,7 +86,7 @@ class SettingsTab extends StatelessWidget {
];
case SettingsSection.network:
return [
const HostDefaultsPanel(),
const YourServerPanel(),
const RecentServersPanel(),
SettingsPanel(
title: 'Watching',
@@ -258,16 +258,18 @@ class SettingsTab extends StatelessWidget {
}
}
/// Party & network: what the Host card starts with.
class HostDefaultsPanel extends StatefulWidget {
const HostDefaultsPanel({super.key});
/// Party & network: your server's port and password, used when you
/// Invite (the next time sharing starts).
class YourServerPanel extends StatefulWidget {
const YourServerPanel({super.key});
@override
State<HostDefaultsPanel> createState() => _HostDefaultsPanelState();
State<YourServerPanel> createState() => _YourServerPanelState();
}
class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
final _form = SessionState.instance.lastForm;
class _YourServerPanelState extends State<YourServerPanel> {
late final _forms = AppScope.of(context).forms;
late final _form = _forms.form;
late final _port = TextEditingController(text: _form.hostPort);
late final _secret = TextEditingController(text: _form.hostSecret);
bool _show = false;
@@ -286,22 +288,22 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
await _forms.save();
} catch (e) {
debugPrint('save host defaults failed: $e');
debugPrint('save your server settings failed: $e');
}
}
@override
Widget build(BuildContext context) {
return SettingsPanel(
title: 'Hosting',
title: 'Your server',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingRow(
label: 'Default port',
help: 'Where your server listens when you host; friends dial it',
label: 'Port',
help: 'Where friends reach your server once you Invite (the next time you start sharing)',
control: SizedBox(
width: 120,
child: TextField(
@@ -320,7 +322,8 @@ class _HostDefaultsPanelState extends State<HostDefaultsPanel> {
),
SettingRow(
label: 'Password',
help: 'Asked of everyone joining your server; blank lets in anyone who reaches the port',
help: 'Asked of every friend joining your server; blank lets in anyone who reaches the port '
'(the next time you start sharing)',
control: SizedBox(
width: 220,
child: TextField(
@@ -355,16 +358,16 @@ class RecentServersPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final session = SessionState.instance;
final forms = AppScope.of(context).forms;
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return SettingsPanel(
title: 'Recent servers',
child: SignalBuilder(builder: (context) {
final list = session.recents.value;
final list = forms.recents.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join and Host offer these in one click; remove the ones you no longer use',
Text('Join offers these in one click; remove the ones you no longer use',
style: TextStyle(color: muted, fontSize: 12)),
if (list.isEmpty)
Padding(
@@ -375,13 +378,13 @@ class RecentServersPanel extends StatelessWidget {
ListTile(
key: ValueKey('recent-${r.key}'),
contentPadding: EdgeInsets.zero,
leading: Icon(r.hosted ? Icons.cast_outlined : Icons.dns_outlined),
title: Text(r.hosted ? 'Hosted on port ${r.port}' : '${r.host}:${r.port}'),
leading: const Icon(Icons.dns_outlined),
title: Text('${r.host}:${r.port}'),
subtitle: r.secret.isEmpty ? null : const Text('password saved'),
trailing: IconButton(
tooltip: 'Remove',
icon: const Icon(Icons.close, size: 18),
onPressed: () => session.forgetServer(r),
onPressed: () => forms.forget(r),
),
),
],
@@ -403,7 +406,7 @@ class YtDlpSettings extends StatefulWidget {
}
class _YtDlpSettingsState extends State<YtDlpSettings> {
late final Future<YtDlp> _y = widget.ytDlp ?? SessionState.instance.ports.links.ytDlp;
late final Future<YtDlp> _y = widget.ytDlp ?? AppScope.of(context).ports.links.ytDlp;
@override
Widget build(BuildContext context) {
@@ -488,7 +491,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
Future<void> _pickFile() async {
final List<String> files;
try {
files = await SessionState.instance.ports.folders.pickFiles(
files = await AppScope.of(context).ports.folders.pickFiles(
dialogTitle: 'Pick a cookies.txt or a JSON cookie export',
extensions: const ['txt', 'json'],
);
@@ -548,7 +551,7 @@ class _YtDlpCookieSettingsState extends State<YtDlpCookieSettings> {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SessionState.instance.ports.shell.dropTarget(
return AppScope.of(context).ports.shell.dropTarget(
onEnter: () => setState(() => _dragging = true),
onExit: () => setState(() => _dragging = false),
onDrop: (paths, _) {
@@ -835,7 +838,8 @@ class NotificationsInfo extends StatelessWidget {
class SoundSettings extends StatelessWidget {
const SoundSettings({super.key});
void _preview(SfxKind kind) => unawaited(SessionState.instance.sfx.preview(kind, readyIndex: 2));
void _preview(BuildContext context, SfxKind kind) =>
unawaited(AppScope.of(context).sfx.preview(kind, readyIndex: 2));
@override
Widget build(BuildContext context) {
@@ -859,7 +863,7 @@ class SoundSettings extends StatelessWidget {
key: const ValueKey('sound-volume'),
value: volume,
onChanged: s.setSoundVolume,
onChangeEnd: (_) => _preview(SfxKind.ready),
onChangeEnd: (_) => _preview(context, SfxKind.ready),
),
),
SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)),
@@ -876,7 +880,7 @@ class SoundSettings extends StatelessWidget {
value: overall,
onChanged: (v) {
s.setSoundPreset(v);
_preview(SfxKind.ready);
_preview(context, SfxKind.ready);
},
items: [
for (final MapEntry(key: p, value: (name, _)) in presetInfo.entries) (p.name, name),
@@ -894,7 +898,7 @@ class SoundSettings extends StatelessWidget {
IconButton(
key: ValueKey('sound-play-${kind.name}'),
tooltip: 'Play',
onPressed: volume == 0 ? null : () => _preview(kind),
onPressed: volume == 0 ? null : () => _preview(context, kind),
icon: const Icon(Icons.play_arrow_rounded),
),
const SizedBox(width: 4),
@@ -910,7 +914,7 @@ class SoundSettings extends StatelessWidget {
style: muted,
onChanged: (v) {
s.setSoundPresetFor(kind.name, v);
if (volume > 0) _preview(kind);
if (volume > 0) _preview(context, kind);
},
items: [
(null, 'Same as set'),
+3 -3
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../playback/media_marks.dart' show formatClock;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import '../sync/state_diff.dart' show serverPositionAt;
import 'now_playing_title.dart';
import 'start_banner.dart';
@@ -41,10 +41,10 @@ class _SitOutCardState extends State<SitOutCard> {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
final small = widget.compact;
return ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
+5 -5
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'motion.dart';
import 'now_playing_title.dart';
@@ -21,8 +21,8 @@ class StartCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final banner = artImage(now.bannerHash);
final cover = banner == null ? artImage(now.posterHash) : null;
final banner = artImage(context, now.bannerHash);
final cover = banner == null ? artImage(context, now.posterHash) : null;
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
@@ -145,10 +145,10 @@ class _StartBannerState extends State<StartBanner> {
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
listenable: artChanges(context),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
+4 -4
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/session_coordinator.dart' show ToastItem;
import '../state/session_state.dart';
import '../app/app_graph.dart';
import 'avatar.dart';
import 'motion.dart';
@@ -73,7 +73,7 @@ class _PartyToastsState extends State<PartyToasts> {
@override
void initState() {
super.initState();
final coord = SessionState.instance.coordinator;
final coord = AppScope.of(context).coordinator;
// What was there before this mounted is old news.
_seen = {for (final t in coord.toasts.peek()) t.id};
_stop = effect(() {
@@ -127,7 +127,7 @@ class _PartyToastsState extends State<PartyToasts> {
void _open(ShellToast t) {
widget.onOpenChat?.call();
SessionState.instance.notifier.dismiss(t.id);
AppScope.of(context).notifier.dismiss(t.id);
}
@override
@@ -282,7 +282,7 @@ class _Thumb extends StatelessWidget {
@override
Widget build(BuildContext context) {
final thumbs = SessionState.instance.coordinator.queue.thumbs;
final thumbs = AppScope.of(context).coordinator.queue.thumbs;
return ClipRRect(
key: const ValueKey('toast-thumb'),
borderRadius: BorderRadius.circular(6),
+16 -15
View File
@@ -12,15 +12,16 @@ const appTagline = 'Watch together, in sync';
/// the app name (else under it).
const welcomeWideFrom = 980.0;
/// Top of the Library while not in a server: the app's name and the three
/// ways in, Join a friend first (what most people came for), then Host and
/// Watch solo. None of them needs Jellyfin.
/// Top of the Library for newcomers alone on their own server: the app's
/// name and the three ways in, Join a friend first (what most people came
/// for), then Invite friends and Watch on your own. None of them needs
/// Jellyfin.
class WelcomeBand extends StatelessWidget {
final VoidCallback onJoin;
final VoidCallback onHost;
final VoidCallback onSolo;
final VoidCallback onInvite;
final VoidCallback onWatch;
final VoidCallback? onDismiss;
const WelcomeBand({super.key, required this.onJoin, required this.onHost, required this.onSolo, this.onDismiss});
const WelcomeBand({super.key, required this.onJoin, required this.onInvite, required this.onWatch, this.onDismiss});
@override
Widget build(BuildContext context) {
@@ -63,23 +64,23 @@ class WelcomeBand extends StatelessWidget {
key: const ValueKey('way-join'),
icon: Icons.login_rounded,
title: 'Join a friend',
caption: 'Got an address from a friend? Join with it. Nothing to set up.',
caption: 'Got an invite from a friend? Join with it. Nothing to set up.',
primary: true,
onTap: onJoin,
),
WayCard(
key: const ValueKey('way-host'),
icon: Icons.podcasts_rounded,
title: 'Host',
caption: 'You have the video. Friends join you and watch it with you.',
onTap: onHost,
key: const ValueKey('way-invite'),
icon: Icons.group_add_outlined,
title: 'Invite friends',
caption: 'You have the video. Copy an invite; friends join you and watch it with you.',
onTap: onInvite,
),
WayCard(
key: const ValueKey('way-solo'),
key: const ValueKey('way-watch'),
icon: Icons.person_outline_rounded,
title: 'Watch solo',
title: 'Watch on your own',
caption: 'Just you: play anything below. Invite friends any time.',
onTap: onSolo,
onTap: onWatch,
),
];
Widget row(List<Widget> cards) => IntrinsicHeight(