Restructure step 3: your server, Invite, version gate, AppGraph
- 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:
1 parent
629be0d9ed
commit
4d00b7455e
86 files changed
+2993
-2071
No files matched your search
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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!);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user