1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5777
-3572
No files matched your search
@@ -0,0 +1,620 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
import 'profile_card.dart';
|
||||
import '../schedule/ui/schedule_page.dart';
|
||||
import 'session_player.dart';
|
||||
import 'settings_tab.dart';
|
||||
import 'toasts.dart';
|
||||
|
||||
/// The one window: the party strip over a body that fades through between
|
||||
/// the tabs and the full player, the floating mini player, the chat panel
|
||||
/// and the toasts. Online/offline follows [SessionState.route].
|
||||
///
|
||||
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
|
||||
/// the body: full it fills the body (fullscreen: the window, strip
|
||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||
/// the video is never mounted twice nor remounted.
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||
/// window_manager.
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Stand-in for media_kit's `Video` (tests).
|
||||
final VideoViewBuilder? videoBuilder;
|
||||
const AppShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = true,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
this.videoBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => AppShellState();
|
||||
}
|
||||
|
||||
class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
|
||||
static const miniSize = Size(384, 216);
|
||||
static const _miniMargin = 16.0;
|
||||
|
||||
ShellTab _tab = ShellTab.library;
|
||||
SettingsSection _section = SettingsSection.profile;
|
||||
|
||||
/// Where the player is going ([_mode]) and where it is drawn ([_shown]):
|
||||
/// they differ for the first part of a change, while it fades out.
|
||||
PlayerMode _mode = PlayerMode.none;
|
||||
PlayerMode _shown = PlayerMode.none;
|
||||
PlayerMode _from = PlayerMode.none;
|
||||
int _modeSeq = 0;
|
||||
late final AnimationController _fade = AnimationController(vsync: this, value: 1);
|
||||
|
||||
bool _fullscreen = false;
|
||||
bool _chatOpen = false;
|
||||
int _unread = 0;
|
||||
int _feedSeen = 0;
|
||||
final _chat = TextEditingController();
|
||||
final _chatFocus = FocusNode();
|
||||
final _playerKey = GlobalKey();
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
bool _snapping = false;
|
||||
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||
late MediaPickerController _picker = _newPicker();
|
||||
BrowseStart? _libraryStart;
|
||||
int _libraryGen = 0;
|
||||
|
||||
/// The next media to come up opens full (just joined, or put up here).
|
||||
bool _autoFull = false;
|
||||
int _lastMediaId = -1;
|
||||
double? _mutedVolume;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
final _stops = <void Function()>[];
|
||||
// Rebuild the shell on these flips, not on every state broadcast.
|
||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||
final _hasMedia = computed(() =>
|
||||
SessionState.instance.route.value == AppRoute.session &&
|
||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||
|
||||
PlayerMode get mode => _mode;
|
||||
PlayerMode get shownMode => _shown;
|
||||
bool get fullscreen => _fullscreen;
|
||||
|
||||
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
|
||||
final f = SessionState.instance.lastForm;
|
||||
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
|
||||
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Profile.instance.ensureLoaded());
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKicked;
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
_feedSeen = coord.feedLog.peek().length;
|
||||
_stops.add(effect(() {
|
||||
final online = session.route.value == AppRoute.session;
|
||||
// Set now, not later: the media effect below may run first in this
|
||||
// same flush, and its scheduled show reads these.
|
||||
_autoFull = online;
|
||||
if (!online) _lastMediaId = -1;
|
||||
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
|
||||
}));
|
||||
// Something comes up while the player is closed: show it (full right
|
||||
// after joining or a put-up of ours, else as the mini player).
|
||||
_stops.add(effect(() {
|
||||
final r = coord.serverState.value;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
if (!online || r == null || r.media == null) return;
|
||||
if (r.mediaId == _lastMediaId) return;
|
||||
_lastMediaId = r.mediaId;
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) {
|
||||
_show(_autoFull ? PlayerMode.full : PlayerMode.mini);
|
||||
} else if (_autoFull) {
|
||||
_show(PlayerMode.full);
|
||||
}
|
||||
_autoFull = false;
|
||||
});
|
||||
}));
|
||||
// Chat that arrives while the panel is closed.
|
||||
_stops.add(effect(() {
|
||||
final log = coord.feedLog.value;
|
||||
final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen);
|
||||
_feedSeen = log.length;
|
||||
final chats = fresh.where((f) => f.action == 'chat').length;
|
||||
if (chats == 0) return;
|
||||
untracked(() => _later(() {
|
||||
if (!_chatOpen) setState(() => _unread += chats);
|
||||
}));
|
||||
}));
|
||||
// Kicked: back offline, and say so.
|
||||
_stops.add(effect(() {
|
||||
final kick = coord.kickReason.value;
|
||||
if (kick == null) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
});
|
||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
final crash = session.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
_later(() {
|
||||
session.serverCrash.value = null;
|
||||
_alert('Server crashed', crash);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_hasMedia.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
_picker.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
if (_fullscreen) _setWindowFullscreen(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Runs [f] after the current signal notification / build.
|
||||
void _later(VoidCallback f) => scheduleMicrotask(() {
|
||||
if (mounted) f();
|
||||
});
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
void _wentOnline() {
|
||||
_unread = 0;
|
||||
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
|
||||
// Hosting starts with nothing up: the library is where to pick it.
|
||||
setState(() => _tab = ShellTab.library);
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
_mutedVolume = null;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
_unread = 0;
|
||||
});
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- player mode
|
||||
|
||||
/// Fade through to [m]: the player layer (and, when full is involved,
|
||||
/// the tabs) fades out, moves while invisible, then fades in.
|
||||
Future<void> _show(PlayerMode m) async {
|
||||
if (m == _mode) return;
|
||||
final seq = ++_modeSeq;
|
||||
if (m != PlayerMode.none) _unmute();
|
||||
setState(() {
|
||||
_from = _shown;
|
||||
_mode = m;
|
||||
});
|
||||
if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (_shown != PlayerMode.none || m == PlayerMode.full) {
|
||||
await _fade.animateTo(0,
|
||||
duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn);
|
||||
} else {
|
||||
_fade.value = 0;
|
||||
}
|
||||
if (!mounted || seq != _modeSeq) return;
|
||||
setState(() => _shown = m);
|
||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||
}
|
||||
|
||||
void _expand() => _show(PlayerMode.full);
|
||||
|
||||
void _collapse() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
||||
void _closePlayer() {
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.alive.peek()) {
|
||||
try {
|
||||
_mutedVolume = playback.player.state.volume;
|
||||
playback.player.setVolume(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
void _unmute() {
|
||||
final v = _mutedVolume;
|
||||
if (v == null) return;
|
||||
_mutedVolume = null;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
playback.player.setVolume(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
if (!widget.windowControls) return;
|
||||
try {
|
||||
await windowManager.setFullScreen(on);
|
||||
} catch (_) {
|
||||
// No window manager.
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
final on = !_fullscreen;
|
||||
setState(() => _fullscreen = on);
|
||||
if (on && _mode != PlayerMode.full) _show(PlayerMode.full);
|
||||
_setWindowFullscreen(on);
|
||||
}
|
||||
|
||||
void _selectTab(ShellTab t) {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() => _tab = t);
|
||||
if (_mode == PlayerMode.full) _show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
void _openSettings(SettingsSection s) {
|
||||
setState(() => _section = s);
|
||||
_selectTab(ShellTab.settings);
|
||||
}
|
||||
|
||||
void _toggleChat() => setState(() {
|
||||
_chatOpen = !_chatOpen;
|
||||
_unread = 0;
|
||||
if (!_chatOpen) _chatFocus.unfocus();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- library
|
||||
|
||||
/// "Change video": the library, opened on the series of the episode
|
||||
/// playing (season selected, episode highlighted), else where it was.
|
||||
void _changeVideo() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
coord.markInput();
|
||||
final ref = coord.currentRef.peek();
|
||||
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
|
||||
final plan = coord.jellyfinPlan.peek();
|
||||
final old = _picker;
|
||||
_picker = pick == null
|
||||
? _newPicker()
|
||||
: _newPicker(jellyfin: pick, source: MediaKind.jellyfin);
|
||||
old.dispose();
|
||||
_libraryStart = ref != null && plan is OwnPlay && plan.item != null
|
||||
? BrowseStart.seriesOf(ref.serverId, plan.item!)
|
||||
: null;
|
||||
_libraryGen++;
|
||||
_selectTab(ShellTab.library);
|
||||
}
|
||||
|
||||
/// Library → "Put it up for everyone"; the player opens full.
|
||||
Future<String?> _putUp(PickedMedia media) async {
|
||||
final session = SessionState.instance;
|
||||
if (media is JellyfinMedia) {
|
||||
try {
|
||||
putUpJellyfinMedia(_jellyfin, media);
|
||||
} on FormatException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
unawaited(_jellyfin.rememberPick(media));
|
||||
}
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
f
|
||||
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
|
||||
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
|
||||
unawaited(session.saveForm().catchError((Object _) {}));
|
||||
_autoFull = true;
|
||||
_show(PlayerMode.full);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- dialogs
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Leave server?'),
|
||||
content: const Text('You can rejoin right away.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
key: const ValueKey('confirm-leave'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await SessionState.instance.leave();
|
||||
}
|
||||
|
||||
Future<void> _showKicked(String reason) async {
|
||||
if (reason == _lastKickShown) return;
|
||||
_lastKickShown = reason;
|
||||
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
|
||||
}
|
||||
|
||||
Future<void> _alert(String title, String text) async {
|
||||
if (!mounted || _dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(text),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _connect(ConnectMode mode) => showConnectCard(
|
||||
context,
|
||||
mode: mode,
|
||||
flow: _flow,
|
||||
join: _join,
|
||||
host: _host,
|
||||
healthGet: widget.healthGet,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
|
||||
body: SignalBuilder(builder: (context) {
|
||||
final online = _online.value;
|
||||
final hasMedia = _hasMedia.value;
|
||||
return Column(
|
||||
children: [
|
||||
if (!_fullscreen)
|
||||
PartyStrip(
|
||||
key: const ValueKey('strip'),
|
||||
tab: _tab,
|
||||
onTab: _selectTab,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onChat: _toggleChat,
|
||||
onJoin: () => _connect(ConnectMode.join),
|
||||
onHost: () => _connect(ConnectMode.host),
|
||||
onLeave: _confirmLeave,
|
||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
Expanded(
|
||||
key: const ValueKey('body'),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _body(online)),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _chatOpen && online
|
||||
? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat)
|
||||
: const SizedBox(height: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(bool online) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final tabBody = switch (_tab) {
|
||||
ShellTab.library => LibraryTab(
|
||||
key: ValueKey('library-$_libraryGen'),
|
||||
controller: _picker,
|
||||
accounts: _jellyfin,
|
||||
online: online,
|
||||
isHost: coord.hostFile != null,
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
online: online,
|
||||
onPutUp: _putUp,
|
||||
onToast: coord.showToast,
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
section: _section,
|
||||
onSection: (s) => setState(() => _section = s),
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final area = box.biggest;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
// The tabs take part in the fade only when the full player
|
||||
// comes or goes; a mini player appearing leaves them be.
|
||||
final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0);
|
||||
return Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: 0.985 + 0.015 * v, child: child),
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
ignoring: full,
|
||||
child: ExcludeFocus(
|
||||
excluding: full,
|
||||
child: TickerMode(
|
||||
enabled: !full,
|
||||
child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
||||
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
||||
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
||||
);
|
||||
|
||||
Widget _playerLayer(Size area) {
|
||||
final mini = _shown == PlayerMode.mini;
|
||||
final size = mini
|
||||
? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height))
|
||||
: area;
|
||||
final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero;
|
||||
return AnimatedPositioned(
|
||||
key: const ValueKey('player-layer'),
|
||||
duration: _snapping ? easeTime : Duration.zero,
|
||||
curve: ease,
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
onEnd: () => _snapping = false,
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
final v = _fade.value;
|
||||
return IgnorePointer(
|
||||
ignoring: v < 0.5 || _shown != _mode,
|
||||
child: Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Same widgets in both modes (only their settings change), so the
|
||||
// player below never moves in the element tree.
|
||||
child: GestureDetector(
|
||||
onPanStart: mini ? (_) => setState(() => _drag = pos) : null,
|
||||
onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null,
|
||||
onPanEnd: mini
|
||||
? (_) => setState(() {
|
||||
final c = _drag! + Offset(size.width / 2, size.height / 2);
|
||||
_corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1);
|
||||
_drag = null;
|
||||
_snapping = true;
|
||||
})
|
||||
: null,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: mini ? _expand : () {},
|
||||
skipTop: 40,
|
||||
skipBottom: 60,
|
||||
child: Material(
|
||||
elevation: mini ? 16 : 0,
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(mini ? 14 : 0),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: KeyedSubtree(
|
||||
key: _playerKey,
|
||||
child: SessionPlayer(
|
||||
mode: _shown,
|
||||
fullscreen: _fullscreen,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onExpand: _expand,
|
||||
onCollapse: _collapse,
|
||||
onClose: _closePlayer,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onToggleChat: _toggleChat,
|
||||
onChangeVideo: _changeVideo,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
onLeave: _confirmLeave,
|
||||
videoBuilder: widget.videoBuilder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/profile.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What an avatar shows: who, their picture or initial on [color], and
|
||||
/// the status the ring and badges carry.
|
||||
class AvatarInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final Color color;
|
||||
final ImageProvider? image;
|
||||
final bool ready, buffering, relaying, stale, host, self;
|
||||
const AvatarInfo({
|
||||
this.id = '',
|
||||
required this.name,
|
||||
required this.color,
|
||||
this.image,
|
||||
this.ready = false,
|
||||
this.buffering = false,
|
||||
this.relaying = false,
|
||||
this.stale = false,
|
||||
this.host = false,
|
||||
this.self = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Stable colour for someone else (colours are not on the wire).
|
||||
Color colorForName(String name) {
|
||||
const palette = [
|
||||
Colors.teal,
|
||||
Colors.orange,
|
||||
Colors.pink,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.indigo,
|
||||
Colors.amber,
|
||||
Colors.cyan,
|
||||
Colors.red,
|
||||
Colors.deepPurple,
|
||||
];
|
||||
var h = 0;
|
||||
for (final c in name.codeUnits) {
|
||||
h = (h * 31 + c) & 0x7fffffff;
|
||||
}
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
final _images = <Object, MemoryImage>{};
|
||||
|
||||
MemoryImage _memo(Object key, Uint8List bytes) {
|
||||
final hit = _images[key];
|
||||
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
||||
if (_images.length > 64) _images.clear();
|
||||
return _images[key] = MemoryImage(bytes);
|
||||
}
|
||||
|
||||
/// The one lookup for a member's picture: your own from the profile (no
|
||||
/// round trip), anyone else's from the session's avatar cache by hash.
|
||||
/// Callers rebuild on [SessionCoordinator.avatars] and [Profile.picture]
|
||||
/// (see [PartyWatch]).
|
||||
ImageProvider? memberAvatarImage(MemberView m, SessionCoordinator coord) {
|
||||
if (m.id.isNotEmpty && m.id == coord.sync.memberId) {
|
||||
final own = Profile.instance.picture.value;
|
||||
if (own != null) return _memo('self', own);
|
||||
}
|
||||
final bytes = coord.avatars.bytesFor(m.avatarHash);
|
||||
return bytes == null ? null : _memo(m.avatarHash, bytes);
|
||||
}
|
||||
|
||||
/// Your own avatar from the profile (also offline).
|
||||
AvatarInfo selfAvatarInfo({bool ready = false, bool host = false}) {
|
||||
final p = Profile.instance;
|
||||
final png = p.picture.value;
|
||||
final name = p.name.value;
|
||||
return AvatarInfo(
|
||||
name: name.isEmpty ? '?' : name,
|
||||
color: p.color.value,
|
||||
image: png == null ? null : _memo('self', png),
|
||||
ready: ready,
|
||||
host: host,
|
||||
self: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// [m] as the party strip, toasts and the chat show it.
|
||||
AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator coord) {
|
||||
final self = m.id.isNotEmpty && m.id == coord.sync.memberId;
|
||||
final relay = r == null ? null : stateMediaRelay(r);
|
||||
final playing = r?.media != null;
|
||||
return AvatarInfo(
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
color: self ? Profile.instance.color.value : colorForName(m.name),
|
||||
image: memberAvatarImage(m, coord),
|
||||
ready: m.ready,
|
||||
buffering: !m.stale && m.ready && playing && m.buffered < RelayConstants.minBufferSecs,
|
||||
relaying: relay != null && relay.source.isNotEmpty && relay.source == m.id,
|
||||
stale: m.stale,
|
||||
// Only your own app knows it hosts: the state carries no host flag.
|
||||
host: self && coord.hostFile != null,
|
||||
self: self,
|
||||
);
|
||||
}
|
||||
|
||||
/// Everyone in the server, from the latest state.
|
||||
List<AvatarInfo> partyAvatars([SessionCoordinator? coord]) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
return [for (final m in r?.members ?? const <MemberView>[]) memberAvatarInfo(m, r, c)];
|
||||
}
|
||||
|
||||
/// Rebuilds [builder] when the party state, your profile or a member's
|
||||
/// picture changes.
|
||||
class PartyWatch extends StatelessWidget {
|
||||
final WidgetBuilder builder;
|
||||
const PartyWatch({super.key, required this.builder});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: SessionState.instance.coordinator.avatars,
|
||||
builder: (context, _) => SignalBuilder(builder: (context) => builder(context)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Circle avatar with a status ring outside it and a gap in the background
|
||||
/// colour, so overlapping neighbours never hide the status. Crown = host,
|
||||
/// tower = passing the video on, spinner = buffering, dimmed = stale.
|
||||
class StatusAvatar extends StatelessWidget {
|
||||
final AvatarInfo info;
|
||||
final double size;
|
||||
|
||||
/// Host crown and relay tower; off where the context already says it.
|
||||
final bool badges;
|
||||
|
||||
/// Ring colour when not ready (the profile shows none at all).
|
||||
final bool ring;
|
||||
const StatusAvatar({super.key, required this.info, required this.size, this.badges = true, this.ring = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = Theme.of(context).colorScheme.surfaceContainerLowest;
|
||||
final ringColor = !ring
|
||||
? bg
|
||||
: info.stale
|
||||
? Colors.grey.shade700
|
||||
: info.buffering
|
||||
? Colors.amber
|
||||
: info.ready
|
||||
? Colors.greenAccent.shade400
|
||||
: Colors.grey;
|
||||
final badge = size * 0.42;
|
||||
// Ring and gap scale with the avatar (2 px at strip size).
|
||||
final stroke = (size * 0.055).clamp(2.0, 4.0);
|
||||
final initial = info.name.isEmpty ? '?' : info.name.characters.first.toUpperCase();
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Fills the box: a non-positioned Stack child only gets loose
|
||||
// constraints, and CircleAvatar then falls back to 40 px.
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: ringColor),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
padding: EdgeInsets.all(stroke),
|
||||
child: Opacity(
|
||||
opacity: info.stale ? 0.4 : 1,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: info.color,
|
||||
backgroundImage: info.image,
|
||||
child: info.image != null
|
||||
? null
|
||||
: Text(initial, style: TextStyle(fontSize: size * 0.38, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.buffering)
|
||||
Positioned.fill(child: CircularProgressIndicator(strokeWidth: stroke, color: ringColor)),
|
||||
if (badges && info.host)
|
||||
Positioned(
|
||||
left: -4,
|
||||
top: -6,
|
||||
child: Transform.rotate(
|
||||
angle: -0.4,
|
||||
child: Icon(
|
||||
Icons.workspace_premium,
|
||||
size: badge,
|
||||
color: Colors.amber,
|
||||
shadows: const [Shadow(blurRadius: 3, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (badges && info.relaying)
|
||||
Positioned(
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bg),
|
||||
child: Icon(Icons.cell_tower, size: badge * 0.8, color: Colors.lightBlueAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping avatars, host first then ready (the stack itself reads as
|
||||
/// progress); they spread apart on hover. "+N" past [shown].
|
||||
class AvatarStack extends StatefulWidget {
|
||||
final List<AvatarInfo> members;
|
||||
final VoidCallback? onTap;
|
||||
final int shown;
|
||||
final double size;
|
||||
const AvatarStack({super.key, required this.members, this.onTap, this.shown = 5, this.size = 42});
|
||||
|
||||
@override
|
||||
State<AvatarStack> createState() => _AvatarStackState();
|
||||
}
|
||||
|
||||
class _AvatarStackState extends State<AvatarStack> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size;
|
||||
int rank(AvatarInfo m) => m.host ? 0 : (m.ready ? 1 : 2);
|
||||
final sorted = [...widget.members]..sort((a, b) => rank(a) - rank(b));
|
||||
final visible = sorted.take(widget.shown).toList();
|
||||
final extra = sorted.length - visible.length;
|
||||
final step = _hover ? size + 6 : size * 0.62;
|
||||
final count = visible.length + (extra > 0 ? 1 : 0);
|
||||
final width = count == 0 ? 0.0 : step * (count - 1) + size;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hover = true),
|
||||
onExit: (_) => setState(() => _hover = false),
|
||||
cursor: widget.onTap == null ? MouseCursor.defer : SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
key: const ValueKey('avatar-stack'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: width,
|
||||
height: size,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (var i = visible.length - 1; i >= 0; i--)
|
||||
AnimatedPositioned(
|
||||
key: ValueKey('stack-${visible[i].id}-${visible[i].name}'),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: i * step,
|
||||
top: 0,
|
||||
child: Tooltip(
|
||||
message: visible[i].name,
|
||||
child: StatusAvatar(info: visible[i], size: size),
|
||||
),
|
||||
),
|
||||
if (extra > 0)
|
||||
AnimatedPositioned(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
left: visible.length * step,
|
||||
top: 0,
|
||||
child: CircleAvatar(
|
||||
radius: size / 2,
|
||||
child: Text('+$extra', style: const TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small coloured label ("host", "passing the video on").
|
||||
class Pill extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const Pill(this.text, this.color, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(text, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Someone by display name (feed lines carry names, not ids): from the
|
||||
/// latest state when still there, else their initial on a stable colour.
|
||||
AvatarInfo avatarForName(String name, [SessionCoordinator? coord]) {
|
||||
final c = coord ?? SessionState.instance.coordinator;
|
||||
final r = c.serverState.value;
|
||||
for (final m in r?.members ?? const <MemberView>[]) {
|
||||
if (m.name == name) return memberAvatarInfo(m, r, c);
|
||||
}
|
||||
return AvatarInfo(name: name, color: colorForName(name));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import 'avatar.dart';
|
||||
|
||||
/// Side panel: chat with avatars, other activity as quiet lines, and the
|
||||
/// message field. Width is fixed; the shell slides it in and out.
|
||||
class ChatPanel extends StatelessWidget {
|
||||
static const width = 340.0;
|
||||
final TextEditingController chat;
|
||||
final FocusNode chatFocus;
|
||||
final VoidCallback onClose;
|
||||
const ChatPanel({super.key, required this.chat, required this.chatFocus, required this.onClose});
|
||||
|
||||
void _send() {
|
||||
SessionState.instance.coordinator.sendChat(chat.text);
|
||||
chat.clear();
|
||||
chatFocus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
key: const ValueKey('chat-panel'),
|
||||
width: width,
|
||||
color: scheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('Chat & activity', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(tooltip: 'Close (Tab)', onPressed: onClose, icon: const Icon(Icons.close, size: 18)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PartyWatch(builder: (context) {
|
||||
final log = SessionState.instance.coordinator.feedLog.value;
|
||||
if (log.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing yet', style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: log.length,
|
||||
itemBuilder: (context, i) => ActivityRow(item: log[log.length - 1 - i]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
key: const ValueKey('chat-field'),
|
||||
controller: chat,
|
||||
focusNode: chatFocus,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message (Enter)',
|
||||
isDense: true,
|
||||
filled: true,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
suffixIcon: IconButton(tooltip: 'Send', onPressed: _send, icon: const Icon(Icons.send, size: 18)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One feed line: a chat message with the sender's avatar, anything else
|
||||
/// as an icon and "who did what".
|
||||
class ActivityRow extends StatelessWidget {
|
||||
final FeedMessage item;
|
||||
const ActivityRow({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final time = _clock(item.at);
|
||||
if (item.action == 'chat') {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusAvatar(info: avatarForName(item.actor), size: 34, badges: false),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(item.actor,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
SelectableText(item.reason),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final (icon, color, text) = item.actor == 'server' ? serverFeedStyle(item) : feedStyle(item, scheme);
|
||||
final line = item.actor == 'server' ? text : '${item.actor} $text';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(line, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12))),
|
||||
Text(time, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Icon, colour and wording of a member's feed action.
|
||||
(IconData, Color, String) feedStyle(FeedMessage f, ColorScheme scheme) {
|
||||
switch (f.action) {
|
||||
case 'join':
|
||||
return (Icons.login, Colors.green, 'joined');
|
||||
case 'leave':
|
||||
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
||||
return (Icons.logout, Colors.grey, 'left$reason');
|
||||
case 'ready':
|
||||
return (Icons.check_circle, Colors.green, 'is ready');
|
||||
case 'unready':
|
||||
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
|
||||
case 'force-ready':
|
||||
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
|
||||
case 'force-unready':
|
||||
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
|
||||
case 'force-play':
|
||||
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
|
||||
case 'force-hold':
|
||||
return (Icons.pause, Colors.orange, 'held playback for everyone');
|
||||
case 'seek':
|
||||
return (Icons.fast_forward, scheme.primary, 'seeked');
|
||||
case 'swap':
|
||||
return (Icons.swap_horiz, scheme.primary, 'changed the media');
|
||||
case 'stale':
|
||||
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
||||
case 'kick':
|
||||
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
||||
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
||||
default:
|
||||
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
||||
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's own lines (pause / resume).
|
||||
(IconData, Color, String) serverFeedStyle(FeedMessage f) => switch (f.action) {
|
||||
'resume' => (Icons.play_arrow, Colors.green, 'Playback resumed'),
|
||||
_ => (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'),
|
||||
};
|
||||
|
||||
String _clock(DateTime at) {
|
||||
final l = at.toLocal();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${two(l.hour)}:${two(l.minute)}';
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show RelayConstants;
|
||||
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/connect_status.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// Join card text, owned by the shell so it survives the card closing.
|
||||
class JoinFields {
|
||||
JoinFields(ConnectForm f)
|
||||
: host = TextEditingController(text: f.host),
|
||||
port = TextEditingController(text: f.port),
|
||||
secret = TextEditingController(text: f.secret);
|
||||
final TextEditingController host;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
host.dispose();
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Host card text, owned by the shell so it survives the card closing.
|
||||
class HostFields {
|
||||
HostFields(ConnectForm f)
|
||||
: port = TextEditingController(text: f.hostPort),
|
||||
secret = TextEditingController(text: f.hostSecret);
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
|
||||
void dispose() {
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip's Join / Host buttons: a card over the window. It closes
|
||||
/// itself once the attempt got in; a failed one shows its error inside.
|
||||
Future<void> showConnectCard(
|
||||
BuildContext context, {
|
||||
required ConnectMode mode,
|
||||
required ConnectFlow flow,
|
||||
required JoinFields join,
|
||||
required HostFields host,
|
||||
HealthGetter healthGet = defaultHealthGet,
|
||||
}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
dim: true,
|
||||
at: const Alignment(0.8, -0.75),
|
||||
child: _CloseWhenOnline(
|
||||
child: mode == ConnectMode.host
|
||||
? HostCard(flow: flow, fields: host)
|
||||
: JoinCard(flow: flow, fields: join, healthGet: healthGet),
|
||||
),
|
||||
);
|
||||
|
||||
class _CloseWhenOnline extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _CloseWhenOnline({required this.child});
|
||||
|
||||
@override
|
||||
State<_CloseWhenOnline> createState() => _CloseWhenOnlineState();
|
||||
}
|
||||
|
||||
class _CloseWhenOnlineState extends State<_CloseWhenOnline> {
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stop = effect(() {
|
||||
if (SessionState.instance.route.value != AppRoute.session) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Card frame shared by Join and Host.
|
||||
class _Card extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
const _Card({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _dense = InputDecoration(isDense: true);
|
||||
|
||||
/// Recent servers as one-click rows (with live status), then address and
|
||||
/// password.
|
||||
class JoinCard extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final JoinFields fields;
|
||||
final HealthGetter healthGet;
|
||||
const JoinCard({super.key, required this.flow, required this.fields, this.healthGet = defaultHealthGet});
|
||||
|
||||
void _join() => flow.submit(ConnectMode.join, (f) {
|
||||
f
|
||||
..host = fields.host.text
|
||||
..port = fields.port.text.trim()
|
||||
..secret = fields.secret.text;
|
||||
});
|
||||
|
||||
void _joinRecent(RecentServer r) {
|
||||
fields.host.text = r.host;
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
_join();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) {
|
||||
final busy = flow.busy == ConnectMode.join;
|
||||
return _Card(
|
||||
title: 'Join a server',
|
||||
children: [
|
||||
SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (!r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Recent', style: TextStyle(color: Colors.grey)),
|
||||
RecentServerRows(
|
||||
servers: list,
|
||||
enabled: flow.busy == null,
|
||||
healthGet: healthGet,
|
||||
onJoin: _joinRecent,
|
||||
onForget: SessionState.instance.forgetServer,
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
AbsorbPointer(
|
||||
absorbing: busy,
|
||||
child: Column(
|
||||
key: const ValueKey('join-row'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('join-address'),
|
||||
controller: fields.host,
|
||||
autofocus: true,
|
||||
decoration: _dense.copyWith(labelText: 'Address', hintText: 'IP or hostname'),
|
||||
onChanged: (_) => flow.clearError(ConnectMode.join),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: const ValueKey('join-port'),
|
||||
controller: fields.port,
|
||||
decoration: _dense.copyWith(labelText: 'Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey('join-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: _dense.copyWith(labelText: 'Password (optional)'),
|
||||
onSubmitted: (_) => _join(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join, expand: true),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.join),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Port and password; what to watch is picked afterwards, in the library.
|
||||
class HostCard extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
const HostCard({super.key, required this.flow, required this.fields});
|
||||
|
||||
void _host() => flow.submit(
|
||||
ConnectMode.host,
|
||||
(f) => f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
builder: (context, _) => _Card(
|
||||
title: 'Host a server',
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('host-port'),
|
||||
controller: fields.port,
|
||||
autofocus: true,
|
||||
decoration: _dense.copyWith(labelText: 'Port', hintText: '8080'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ListenableBuilder(
|
||||
listenable: fields.secret,
|
||||
builder: (context, _) => TextField(
|
||||
key: const ValueKey('host-password'),
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: _dense.copyWith(
|
||||
labelText: 'Password (optional)',
|
||||
helperText: fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
|
||||
),
|
||||
onSubmitted: (_) => _host(),
|
||||
),
|
||||
),
|
||||
_HostedRecents(fields: fields, enabled: flow.busy == null),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: _host, expand: true),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'You pick what to watch afterwards, in the library.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports hosted before: one click refills port and password.
|
||||
class _HostedRecents extends StatelessWidget {
|
||||
final HostFields fields;
|
||||
final bool enabled;
|
||||
const _HostedRecents({required this.fields, required this.enabled});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final list = [
|
||||
for (final r in SessionState.instance.recents.value)
|
||||
if (r.hosted) r
|
||||
];
|
||||
if (list.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final r in list)
|
||||
InputChip(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
|
||||
label: Text('Port ${r.port}'),
|
||||
tooltip: 'Use port ${r.port} again',
|
||||
isEnabled: enabled,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
onPressed: () {
|
||||
fields.port.text = r.port;
|
||||
fields.secret.text = r.secret;
|
||||
},
|
||||
deleteButtonTooltipMessage: 'Forget',
|
||||
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent servers as rows; polls each one's `/health` every
|
||||
/// [healthPollEvery] while mounted (i.e. while the Join card is open).
|
||||
class RecentServerRows extends StatefulWidget {
|
||||
final List<RecentServer> servers;
|
||||
final bool enabled;
|
||||
final HealthGetter healthGet;
|
||||
final ValueChanged<RecentServer> onJoin;
|
||||
final ValueChanged<RecentServer> onForget;
|
||||
const RecentServerRows({
|
||||
super.key,
|
||||
required this.servers,
|
||||
required this.enabled,
|
||||
required this.healthGet,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecentServerRows> createState() => _RecentServerRowsState();
|
||||
}
|
||||
|
||||
class _RecentServerRowsState extends State<RecentServerRows> {
|
||||
final _health = <String, ServerHealth>{};
|
||||
final _inFlight = <String>{};
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pollAll();
|
||||
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(RecentServerRows old) {
|
||||
super.didUpdateWidget(old);
|
||||
for (final r in widget.servers) {
|
||||
if (!_health.containsKey(r.key)) _poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _pollAll() {
|
||||
for (final r in widget.servers) {
|
||||
_poll(r);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll(RecentServer r) async {
|
||||
if (!_inFlight.add(r.key)) return;
|
||||
try {
|
||||
final h = await probeHealth(r.host, r.port, widget.healthGet);
|
||||
if (mounted) setState(() => _health[r.key] = h);
|
||||
} finally {
|
||||
_inFlight.remove(r.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
for (final r in widget.servers)
|
||||
_RecentRow(
|
||||
key: ValueKey('recent-${r.key}'),
|
||||
server: r,
|
||||
health: _health[r.key],
|
||||
enabled: widget.enabled,
|
||||
onJoin: () => widget.onJoin(r),
|
||||
onForget: () => widget.onForget(r),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentRow extends StatelessWidget {
|
||||
final RecentServer server;
|
||||
/// Null until the first probe answers.
|
||||
final ServerHealth? health;
|
||||
final bool enabled;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onForget;
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.server,
|
||||
required this.health,
|
||||
required this.enabled,
|
||||
required this.onJoin,
|
||||
required this.onForget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final h = health;
|
||||
final address = '${server.host}:${server.port}';
|
||||
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
|
||||
final (status, statusColor) = switch (h) {
|
||||
null => ('checking…', Colors.grey),
|
||||
ServerHealth(online: true) => ('● online · ${h.viewers} watching', Colors.greenAccent.shade400),
|
||||
_ => ('○ offline', Colors.grey),
|
||||
};
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(left: 4),
|
||||
enabled: enabled,
|
||||
leading: Icon(server.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 18),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(address, style: const TextStyle(color: Colors.grey)),
|
||||
Text(status, key: const ValueKey('recent-status'), style: TextStyle(color: statusColor)),
|
||||
if (h != null && h.needsUpdate)
|
||||
Tooltip(
|
||||
message: 'Server protocol ${h.proto}, this app '
|
||||
'${RelayConstants.protoVersion} — update so both match',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(color: scheme.errorContainer, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text('needs update', style: TextStyle(fontSize: 11, color: scheme.onErrorContainer)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Forget',
|
||||
visualDensity: VisualDensity.compact,
|
||||
iconSize: 16,
|
||||
onPressed: onForget,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
const Icon(Icons.login, size: 18),
|
||||
],
|
||||
),
|
||||
onTap: onJoin,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia;
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/session/swap_panel.dart';
|
||||
|
||||
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
|
||||
/// Throws [FormatException] with a user-facing message when the server is
|
||||
/// not linked any more or its sign-in expired.
|
||||
HelloMedia putUpJellyfinMedia(JellyfinAccounts accounts, JellyfinMedia media) {
|
||||
if (accounts.account(media.serverId) == null) {
|
||||
throw const FormatException('that Jellyfin server is not linked any more — pick again');
|
||||
}
|
||||
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
|
||||
throw const FormatException('Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations');
|
||||
}
|
||||
return mediaForPick(media);
|
||||
}
|
||||
|
||||
/// Brings back the last Jellyfin pick (kept in the Jellyfin box) once the
|
||||
/// accounts are read, unless something else was picked meanwhile or
|
||||
/// [alive] says the picker is gone.
|
||||
Future<void> restoreLastPick(
|
||||
MediaPickerController c, JellyfinAccounts accounts, bool Function() alive) async {
|
||||
final hadMedia = c.selection != null;
|
||||
try {
|
||||
await accounts.ensureLoaded();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final pick = accounts.lastPick;
|
||||
if (!alive() || pick == null || c.jellyfin != null) return;
|
||||
if (accounts.account(pick.serverId) == null) return;
|
||||
final source = c.source;
|
||||
c.pickJellyfin(pick);
|
||||
if (hadMedia) c.source = source;
|
||||
}
|
||||
|
||||
/// Library (round 1): the media picker as the "put it up" view. Offline
|
||||
/// it browses, with a hint that watching needs a server.
|
||||
class LibraryTab extends StatelessWidget {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
final bool online;
|
||||
final bool isHost;
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||
final VoidCallback? onPutUpDone;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const LibraryTab({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.accounts,
|
||||
required this.online,
|
||||
required this.isHost,
|
||||
required this.onPutUp,
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwapPanel(
|
||||
controller: controller,
|
||||
accounts: accounts,
|
||||
isHost: isHost,
|
||||
jellyfinStart: jellyfinStart,
|
||||
onSwap: onPutUp,
|
||||
onDone: onPutUpDone,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
disabledHint: online ? null : 'Join or host to watch together',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// The shell's shared motion: one curve and one duration for everything
|
||||
// that moves, a fade-through for views that replace each other, popovers
|
||||
// that grow from where they open, and mouse-friendly horizontal lists.
|
||||
|
||||
const ease = Curves.fastEaseInToSlowEaseOut;
|
||||
const easeTime = Duration(milliseconds: 320);
|
||||
|
||||
/// Fade-through length; the old view is gone after [fadeOutPart] of it.
|
||||
const fadeThroughTime = Duration(milliseconds: 360);
|
||||
const fadeOutPart = 0.35;
|
||||
|
||||
/// Fade through (Material motion): the old view is gone after the first
|
||||
/// 35%, then the new one fades and scales in; they never overlap. Key
|
||||
/// [child] by what it shows so a change of key runs the transition.
|
||||
Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
|
||||
key: key,
|
||||
duration: fadeThroughTime,
|
||||
layoutBuilder: (current, previous) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [...previous, ?current],
|
||||
),
|
||||
transitionBuilder: (c, anim) {
|
||||
if (c.key == child.key) {
|
||||
// Incoming (anim 0 -> 1): wait, then fade and scale up.
|
||||
final a = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: const Interval(fadeOutPart, 1, curve: ease),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(scale: Tween(begin: 0.985, end: 1.0).animate(a), child: c),
|
||||
);
|
||||
}
|
||||
// Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65.
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: const Interval(1 - fadeOutPart, 1, curve: Curves.easeIn),
|
||||
),
|
||||
child: c,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
|
||||
/// A card over the window that fades and scales in from [at] (window
|
||||
/// alignment) instead of popping; a click outside or Esc closes it. Short
|
||||
/// windows: the card scrolls instead of overflowing.
|
||||
Future<T?> showPopover<T>(
|
||||
BuildContext context, {
|
||||
required Alignment at,
|
||||
required Widget child,
|
||||
bool dim = false,
|
||||
}) =>
|
||||
showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'close',
|
||||
barrierColor: dim ? Colors.black26 : Colors.transparent,
|
||||
transitionDuration: easeTime,
|
||||
pageBuilder: (context, _, _) => SafeArea(
|
||||
child: Align(
|
||||
alignment: at,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height - 32),
|
||||
child: SingleChildScrollView(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
transitionBuilder: (context, anim, _, child) {
|
||||
final a = CurvedAnimation(parent: anim, curve: ease, reverseCurve: Curves.easeIn);
|
||||
return FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.96, end: 1.0).animate(a),
|
||||
alignment: at,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// Horizontal lists for mouse users: drag with the mouse, and the plain
|
||||
/// (vertical) wheel moves them sideways. At either end the wheel is left
|
||||
/// to the page, so a row never traps vertical scrolling.
|
||||
class HScroll extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final Widget Function(ScrollController controller) builder;
|
||||
const HScroll({super.key, this.controller, required this.builder});
|
||||
|
||||
@override
|
||||
State<HScroll> createState() => _HScrollState();
|
||||
}
|
||||
|
||||
class _HScrollState extends State<HScroll> {
|
||||
ScrollController? _own;
|
||||
ScrollController get _c => widget.controller ?? (_own ??= ScrollController());
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_own?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _wheel(PointerSignalEvent e) {
|
||||
if (e is! PointerScrollEvent) return;
|
||||
final dy = e.scrollDelta.dy;
|
||||
if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return;
|
||||
final p = _c.position;
|
||||
final target = (p.pixels + dy * 1.5).clamp(p.minScrollExtent, p.maxScrollExtent);
|
||||
if (target == p.pixels) return; // at the end: let the page scroll
|
||||
GestureBinding.instance.pointerSignalResolver.register(e, (_) {
|
||||
_c.animateTo(target, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context)
|
||||
.copyWith(dragDevices: PointerDeviceKind.values.toSet()),
|
||||
child: Listener(onPointerSignal: _wheel, child: widget.builder(_c)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../screens/demo/shell_demo.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge;
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import '../widgets/dialogs.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The shell's tabs, in strip order.
|
||||
enum ShellTab {
|
||||
library('Library'),
|
||||
schedule('Schedule'),
|
||||
settings('Settings');
|
||||
|
||||
final String label;
|
||||
const ShellTab(this.label);
|
||||
}
|
||||
|
||||
const partyStripHeight = 60.0;
|
||||
|
||||
/// Below this window width the strip shows 3 avatars and no server text,
|
||||
/// so the tabs keep their room.
|
||||
const stripCompactBelow = 1100.0;
|
||||
|
||||
/// The member plays the current Jellyfin item through the host's relay.
|
||||
bool viaHostOf(MemberView m, JellyfinRef? ref) =>
|
||||
ref != null && m.jf[ref.serverId] == false;
|
||||
|
||||
/// The 60 px strip over everything: your avatar (profile), the tabs, and
|
||||
/// the party: offline Join / Host, online the avatars (members card),
|
||||
/// server and readiness, chat and Leave. Empty space drags the window.
|
||||
class PartyStrip extends StatelessWidget {
|
||||
final ShellTab tab;
|
||||
final ValueChanged<ShellTab> onTab;
|
||||
final bool chatOpen;
|
||||
final int unread;
|
||||
final VoidCallback onChat;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onHost;
|
||||
final VoidCallback onLeave;
|
||||
final VoidCallback onProfile;
|
||||
|
||||
/// Online with something up but the player closed: brings it back.
|
||||
final VoidCallback? onShowPlayer;
|
||||
final UpdateController? updates;
|
||||
|
||||
/// Off in tests: the drag area and window buttons need window_manager.
|
||||
final bool windowControls;
|
||||
const PartyStrip({
|
||||
super.key,
|
||||
required this.tab,
|
||||
required this.onTab,
|
||||
required this.chatOpen,
|
||||
required this.unread,
|
||||
required this.onChat,
|
||||
required this.onJoin,
|
||||
required this.onHost,
|
||||
required this.onLeave,
|
||||
required this.onProfile,
|
||||
this.onShowPlayer,
|
||||
this.updates,
|
||||
this.windowControls = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final compact = MediaQuery.sizeOf(context).width < stripCompactBelow;
|
||||
final row = SignalBuilder(
|
||||
builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
final self = online ? coord.selfMember.value : null;
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Tooltip(
|
||||
message: 'Your profile',
|
||||
child: InkWell(
|
||||
key: const ValueKey('strip-profile'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onProfile,
|
||||
child: StatusAvatar(
|
||||
info: selfAvatarInfo(
|
||||
ready: self?.ready ?? false,
|
||||
host: online && coord.hostFile != null,
|
||||
),
|
||||
size: 32,
|
||||
ring: online,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: HScroll(
|
||||
builder: (c) => SingleChildScrollView(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in ShellTab.values)
|
||||
StripTab(
|
||||
key: ValueKey('tab-${t.name}'),
|
||||
label: t.label,
|
||||
selected: t == tab,
|
||||
compact: compact,
|
||||
onTap: () => onTab(t),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
IconButton(
|
||||
tooltip: 'Layout demo (debug builds only)',
|
||||
icon: const Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 20,
|
||||
color: Colors.amber,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellDemo()),
|
||||
),
|
||||
),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (online) ...[
|
||||
if (onShowPlayer != null)
|
||||
IconButton(
|
||||
key: const ValueKey('show-player'),
|
||||
tooltip: 'Show the player',
|
||||
onPressed: onShowPlayer,
|
||||
icon: const Icon(Icons.smart_display_outlined, size: 20),
|
||||
),
|
||||
PartyWatch(
|
||||
builder: (context) => AvatarStack(
|
||||
key: const ValueKey('party-avatars'),
|
||||
members: partyAvatars(coord),
|
||||
shown: compact ? 3 : 5,
|
||||
onTap: () => showMembersCard(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!compact) _ServerLabel(scheme: scheme),
|
||||
const SizedBox(width: 6),
|
||||
Badge(
|
||||
isLabelVisible: unread > 0,
|
||||
label: Text('$unread'),
|
||||
child: IconButton(
|
||||
key: const ValueKey('strip-chat'),
|
||||
tooltip: 'Chat & activity',
|
||||
isSelected: chatOpen,
|
||||
onPressed: onChat,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 20),
|
||||
selectedIcon: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
OutlinedButton(
|
||||
key: const ValueKey('strip-leave'),
|
||||
onPressed: onLeave,
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
] else ...[
|
||||
if (!compact)
|
||||
Text(
|
||||
'Offline',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('strip-join'),
|
||||
onPressed: onJoin,
|
||||
child: const Text('Join'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
key: const ValueKey('strip-host'),
|
||||
onPressed: onHost,
|
||||
child: const Text('Host'),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
if (windowControls) ...[
|
||||
SizedBox(
|
||||
height: 20,
|
||||
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
|
||||
),
|
||||
const WindowButtons(),
|
||||
] else
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLowest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Empty strip space drags the window; the controls on top take
|
||||
// their own clicks.
|
||||
if (windowControls)
|
||||
const Positioned.fill(
|
||||
child: DragToMoveArea(child: SizedBox.expand()),
|
||||
),
|
||||
Positioned.fill(child: row),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Server name and "ready n/m".
|
||||
class _ServerLabel extends StatelessWidget {
|
||||
final ColorScheme scheme;
|
||||
const _ServerLabel({required this.scheme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(
|
||||
builder: (context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final members = r?.members ?? const <MemberView>[];
|
||||
final ready = members.where((m) => m.ready).length;
|
||||
return Column(
|
||||
key: const ValueKey('server-label'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
serverName(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, height: 1.1),
|
||||
),
|
||||
Text(
|
||||
r == null ? 'connecting…' : 'ready $ready/${members.length}',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// "Your server" when hosting, else the address joined.
|
||||
String serverName() {
|
||||
final session = SessionState.instance;
|
||||
if (session.coordinator.hostFile != null) return 'Your server';
|
||||
return session.lastForm.host;
|
||||
}
|
||||
|
||||
/// One strip tab: label and an underline that grows in when selected.
|
||||
class StripTab extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool compact;
|
||||
final VoidCallback onTap;
|
||||
const StripTab({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 12),
|
||||
child: SizedBox(
|
||||
height: partyStripHeight,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected ? scheme.primary : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
height: 3,
|
||||
width: selected ? 22 : 0,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who is in the server, from the avatars: status, host / relay pills,
|
||||
/// and per member force ready and kick.
|
||||
Future<void> showMembersCard(BuildContext context) => showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(0.7, -0.84),
|
||||
child: const MembersCard(),
|
||||
);
|
||||
|
||||
class MembersCard extends StatelessWidget {
|
||||
const MembersCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
key: const ValueKey('members-card'),
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 340,
|
||||
child: PartyWatch(
|
||||
builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final r = coord.serverState.value;
|
||||
final hosting = coord.hostFile != null;
|
||||
final address = hosting
|
||||
? null
|
||||
: '${session.lastForm.host}:${session.lastForm.port}';
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Text(
|
||||
['In the server', ?address].join(' · '),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
for (final m in r?.members ?? const <MemberView>[])
|
||||
_MemberTile(
|
||||
member: m,
|
||||
info: memberAvatarInfo(m, r, coord),
|
||||
viaHost: viaHostOf(m, coord.currentRef.value),
|
||||
),
|
||||
if (r == null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'connecting…',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
if (address != null) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: address)),
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy address'),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberTile extends StatelessWidget {
|
||||
final MemberView member;
|
||||
final AvatarInfo info;
|
||||
final bool viaHost;
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.info,
|
||||
required this.viaHost,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final m = member;
|
||||
final status = m.stale
|
||||
? 'reconnecting…'
|
||||
: info.buffering
|
||||
? 'buffering ${m.buffered.toStringAsFixed(1)} s'
|
||||
: m.ready
|
||||
? 'ready'
|
||||
: 'not ready';
|
||||
return ListTile(
|
||||
key: ValueKey('member-${m.id}'),
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.only(left: 20, right: 8),
|
||||
leading: StatusAvatar(info: info, size: 36, badges: false),
|
||||
title: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(info.self ? '${m.name} (you)' : m.name),
|
||||
if (info.host) const Pill('host', Colors.amber),
|
||||
if (info.relaying)
|
||||
const Pill('passing the video on', Colors.lightBlueAccent),
|
||||
if (viaHost) const ViaHostBadge(),
|
||||
],
|
||||
),
|
||||
subtitle: Text(status),
|
||||
trailing: PopupMenuButton<String>(
|
||||
tooltip: 'More',
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == 'force') {
|
||||
coord.forceTarget(m.id, !m.ready);
|
||||
} else if (v == 'kick') {
|
||||
final ok = await showKickConfirmDialog(context, m.name);
|
||||
if (ok == true) coord.kick(m.id);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'force',
|
||||
child: Text(m.ready ? 'Force unready' : 'Force ready'),
|
||||
),
|
||||
if (!info.self)
|
||||
PopupMenuItem(
|
||||
value: 'kick',
|
||||
child: Text(
|
||||
'Kick',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
import 'settings_tab.dart' show SettingsSection;
|
||||
|
||||
/// Your profile, from your avatar in the strip: name and picture apply
|
||||
/// live (pushed to the server while in one), colour is yours only; links
|
||||
/// to the app-wide pages.
|
||||
Future<void> showProfileCard(
|
||||
BuildContext context, {
|
||||
required ValueChanged<SettingsSection> onOpenSettings,
|
||||
UpdateController? updates,
|
||||
}) =>
|
||||
showPopover<void>(
|
||||
context,
|
||||
at: const Alignment(-0.97, -0.84),
|
||||
child: ProfileCard(onOpenSettings: onOpenSettings, updates: updates),
|
||||
);
|
||||
|
||||
class ProfileCard extends StatefulWidget {
|
||||
final ValueChanged<SettingsSection> onOpenSettings;
|
||||
final UpdateController? updates;
|
||||
const ProfileCard({super.key, required this.onOpenSettings, this.updates});
|
||||
|
||||
@override
|
||||
State<ProfileCard> createState() => _ProfileCardState();
|
||||
}
|
||||
|
||||
class _ProfileCardState extends State<ProfileCard> {
|
||||
final _p = Profile.instance;
|
||||
late final _name = TextEditingController(text: _p.name.peek());
|
||||
bool _hoverPic = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open(SettingsSection s) {
|
||||
Navigator.of(context).maybePop();
|
||||
widget.onOpenSettings(s);
|
||||
}
|
||||
|
||||
Future<void> _changePicture() => showPopover<void>(
|
||||
context,
|
||||
at: Alignment.center,
|
||||
dim: true,
|
||||
child: CropPicker(onUse: _p.setPicture),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final small = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12);
|
||||
return Material(
|
||||
elevation: 12,
|
||||
color: scheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
final self = online ? coord.selfMember.value : null;
|
||||
final hosting = online && coord.hostFile != null;
|
||||
final color = _p.color.value;
|
||||
final png = _p.picture.value;
|
||||
final me = selfAvatarInfo(ready: self?.ready ?? false, host: hosting);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header tinted with your colour.
|
||||
AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [color.withValues(alpha: 0.55), color.withValues(alpha: 0.08)],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hoverPic = true),
|
||||
onExit: (_) => setState(() => _hoverPic = false),
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('profile-picture'),
|
||||
onTap: _changePicture,
|
||||
child: SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: Stack(
|
||||
children: [
|
||||
StatusAvatar(info: me, size: 88, badges: false, ring: online),
|
||||
Positioned.fill(
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: _hoverPic ? 1 : 0,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black54),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.photo_camera_outlined, color: Colors.white, size: 22),
|
||||
Text('Change', style: TextStyle(color: Colors.white, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
key: const ValueKey('profile-name'),
|
||||
controller: _name,
|
||||
maxLength: 32,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
hintText: 'Your name',
|
||||
suffixIcon: Icon(Icons.edit_outlined, size: 16),
|
||||
suffixIconConstraints: BoxConstraints(minWidth: 20),
|
||||
),
|
||||
onChanged: _p.setName,
|
||||
onSubmitted: (_) => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (hosting) const Pill('host', Colors.amber),
|
||||
Pill(online ? 'in a server' : 'offline',
|
||||
online ? Colors.greenAccent : Colors.grey),
|
||||
if (online)
|
||||
Pill((self?.ready ?? false) ? 'ready' : 'not ready', Colors.lightBlueAccent),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Colour', style: small),
|
||||
const Spacer(),
|
||||
for (final c in profileColors)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => _p.setColor(c),
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: color.toARGB32() == c.toARGB32() ? scheme.onSurface : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (png != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.image_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text('PNG · ${(png.length / 1024).toStringAsFixed(0)} KB', style: small),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () => _p.setPicture(null), child: const Text('Remove picture')),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_p.error.value case final err?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 0),
|
||||
child: Text(err, style: TextStyle(color: scheme.error, fontSize: 12)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 20, 0),
|
||||
child: Text(
|
||||
online
|
||||
? 'Name and picture update for everyone in the server right away. Colour is yours only.'
|
||||
: 'Others see your name and picture when you join. Colour is yours only.',
|
||||
style: small,
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.fromLTRB(12, 12, 12, 0), child: Divider(height: 1)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(children: [
|
||||
_link(
|
||||
Icons.extension_outlined,
|
||||
'Jellyfin & integrations',
|
||||
trailing: ListenableBuilder(
|
||||
listenable: JellyfinAccounts.instance,
|
||||
builder: (context, _) {
|
||||
final n = JellyfinAccounts.instance.accounts.length;
|
||||
return Text(n == 0 ? 'none' : '$n linked', style: small);
|
||||
},
|
||||
),
|
||||
onTap: () => _open(SettingsSection.integrations),
|
||||
),
|
||||
_link(Icons.settings_outlined, 'Settings', onTap: () => _open(SettingsSection.profile)),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: (widget.updates ?? UpdateController.instance).status,
|
||||
builder: (context, s, _) {
|
||||
final m = s.manifest;
|
||||
final available = s.phase != UpdatePhase.idle && m != null;
|
||||
return _link(
|
||||
Icons.system_update_alt,
|
||||
available ? 'Update to ${m.version}' : 'Updates',
|
||||
trailing: available ? Pill('new', Colors.greenAccent.shade400) : null,
|
||||
onTap: () => _open(SettingsSection.updates),
|
||||
);
|
||||
},
|
||||
),
|
||||
_link(Icons.info_outline, 'About', onTap: () => _open(SettingsSection.about)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _link(IconData icon, String label, {Widget? trailing, required VoidCallback onTap}) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
leading: Icon(icon, size: 20),
|
||||
title: Text(label),
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live
|
||||
/// previews at the sizes it is shown. "Use this" renders a 256 px PNG.
|
||||
class CropPicker extends StatefulWidget {
|
||||
final Future<void> Function(Uint8List png) onUse;
|
||||
|
||||
/// Opens the file dialog right away (that is why the picker opened).
|
||||
final bool autoPick;
|
||||
const CropPicker({super.key, required this.onUse, this.autoPick = true});
|
||||
|
||||
@override
|
||||
State<CropPicker> createState() => _CropPickerState();
|
||||
}
|
||||
|
||||
class _CropPickerState extends State<CropPicker> {
|
||||
static const _view = 300.0, _out = 256;
|
||||
ui.Image? _img;
|
||||
double _zoom = 1; // 1 = the image just covers the circle
|
||||
Offset _pan = Offset.zero; // in viewport pixels
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.autoPick) WidgetsBinding.instance.addPostFrameCallback((_) => _pick());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_img?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pick() async {
|
||||
final List<PlatformFile> files;
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a picture',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'],
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'file dialog unavailable — try again');
|
||||
return;
|
||||
}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(await File(path).readAsBytes());
|
||||
final frame = await codec.getNextFrame();
|
||||
if (!mounted) return;
|
||||
_img?.dispose();
|
||||
setState(() {
|
||||
_img = frame.image;
|
||||
_zoom = 1;
|
||||
_pan = Offset.zero;
|
||||
_error = null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'could not read that image');
|
||||
}
|
||||
}
|
||||
|
||||
double get _scale {
|
||||
final img = _img!;
|
||||
return math.max(_view / img.width, _view / img.height) * _zoom;
|
||||
}
|
||||
|
||||
/// Keeps the circle covered: the image edge never shows inside it.
|
||||
Offset _clamp(Offset p) {
|
||||
final img = _img!;
|
||||
final mx = (img.width * _scale - _view) / 2, my = (img.height * _scale - _view) / 2;
|
||||
return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my));
|
||||
}
|
||||
|
||||
void _setZoom(double z) => setState(() {
|
||||
_zoom = z.clamp(1.0, 6.0);
|
||||
_pan = _clamp(_pan);
|
||||
});
|
||||
|
||||
Future<void> _use() async {
|
||||
final img = _img;
|
||||
if (img == null) return;
|
||||
setState(() => _busy = true);
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = Canvas(rec);
|
||||
canvas.scale(_out / _view);
|
||||
CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view));
|
||||
final out = await rec.endRecording().toImage(_out, _out);
|
||||
final png = await out.toByteData(format: ui.ImageByteFormat.png);
|
||||
out.dispose();
|
||||
if (!mounted || png == null) return;
|
||||
await widget.onUse(png.buffer.asUint8List());
|
||||
if (mounted) Navigator.of(context).maybePop();
|
||||
}
|
||||
|
||||
Widget _preview(double size) => ClipOval(
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(painter: CropPainter(_img!, _scale * size / _view, _pan * (size / _view))),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final img = _img;
|
||||
const caption = TextStyle(fontSize: 11, color: Colors.grey);
|
||||
final editor = img == null
|
||||
? InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _pick,
|
||||
child: Container(
|
||||
width: _view,
|
||||
height: _view,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: scheme.surfaceContainerHighest),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined, size: 48, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(height: 8),
|
||||
Text(_error ?? 'Choose a picture', style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is PointerScrollEvent) _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07));
|
||||
},
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.move,
|
||||
// The dimmed surroundings stay inside this rounded square.
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: SizedBox(
|
||||
width: _view,
|
||||
height: _view,
|
||||
child: Stack(
|
||||
children: [
|
||||
// The whole image, dimmed outside the circle.
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: CustomPaint(painter: CropPainter(img, _scale, _pan, clipToView: false)),
|
||||
),
|
||||
),
|
||||
ClipOval(
|
||||
child: CustomPaint(size: const Size(_view, _view), painter: CropPainter(img, _scale, _pan)),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: scheme.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Material(
|
||||
elevation: 16,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: SizedBox(
|
||||
width: 560,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Your picture', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
img == null ? 'PNG, JPG, WebP, GIF or BMP' : 'Drag to move · scroll to zoom',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
editor,
|
||||
if (img != null) ...[
|
||||
const SizedBox(width: 24),
|
||||
Column(
|
||||
children: [
|
||||
_preview(88),
|
||||
const SizedBox(height: 4),
|
||||
const Text('profile', style: caption),
|
||||
const SizedBox(height: 14),
|
||||
_preview(42),
|
||||
const SizedBox(height: 4),
|
||||
const Text('party', style: caption),
|
||||
const SizedBox(height: 14),
|
||||
_preview(24),
|
||||
const SizedBox(height: 4),
|
||||
const Text('chat', style: caption),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (img != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.zoom_out, size: 18),
|
||||
Expanded(child: Slider(value: _zoom, min: 1, max: 6, onChanged: _setZoom)),
|
||||
const Icon(Icons.zoom_in, size: 18),
|
||||
],
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _pick,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: Text(img == null ? 'Choose…' : 'Other file…'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () => Navigator.of(context).maybePop(), child: const Text('Cancel')),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: img == null || _busy ? null : _use, child: const Text('Use this')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws [img] scaled by [scale], centred in the box and moved by [pan].
|
||||
class CropPainter extends CustomPainter {
|
||||
final ui.Image img;
|
||||
final double scale;
|
||||
final Offset pan;
|
||||
final bool clipToView;
|
||||
CropPainter(this.img, this.scale, this.pan, {this.clipToView = true});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (clipToView) canvas.clipRect(Offset.zero & size);
|
||||
final w = img.width * scale, h = img.height * scale;
|
||||
final dst = Rect.fromLTWH((size.width - w) / 2 + pan.dx, (size.height - h) / 2 + pan.dy, w, h);
|
||||
canvas.drawImageRect(
|
||||
img,
|
||||
Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()),
|
||||
dst,
|
||||
Paint()..filterQuality = FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CropPainter old) => old.img != img || old.scale != scale || old.pan != pan;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/accounts_panel.dart';
|
||||
import '../screens/home/about_page.dart';
|
||||
import '../screens/home/settings_page.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// The Settings tab's pages, in nav order.
|
||||
enum SettingsSection {
|
||||
profile(Icons.person_outline, 'Profile'),
|
||||
player(Icons.play_circle_outline, 'Player'),
|
||||
integrations(Icons.extension_outlined, 'Jellyfin & integrations'),
|
||||
updates(Icons.system_update_alt, 'Updates'),
|
||||
shortcuts(Icons.keyboard_outlined, 'Keyboard shortcuts'),
|
||||
about(Icons.info_outline, 'About');
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const SettingsSection(this.icon, this.label);
|
||||
}
|
||||
|
||||
/// App-wide settings under a left nav (chips on top when narrow). The
|
||||
/// pages hold the existing settings panels; the shell owns [section] so
|
||||
/// the profile card can open a page.
|
||||
class SettingsTab extends StatelessWidget {
|
||||
final SettingsSection section;
|
||||
final ValueChanged<SettingsSection> onSection;
|
||||
final VoidCallback? onEditProfile;
|
||||
final UpdateController? updates;
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
const SettingsTab({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.onSection,
|
||||
this.onEditProfile,
|
||||
this.updates,
|
||||
this.jellyfin,
|
||||
});
|
||||
|
||||
List<Widget> _page(BuildContext context) {
|
||||
switch (section) {
|
||||
case SettingsSection.profile:
|
||||
return [
|
||||
SettingsPanel(
|
||||
title: 'You',
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final me = selfAvatarInfo();
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: StatusAvatar(info: me, size: 48, badges: false, ring: false),
|
||||
title: Text(me.name == '?' ? 'No name yet' : me.name),
|
||||
subtitle: const Text('Name, picture and colour: click your avatar in the top left'),
|
||||
trailing: onEditProfile == null
|
||||
? null
|
||||
: TextButton(onPressed: onEditProfile, child: const Text('Edit')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
case SettingsSection.player:
|
||||
return const [
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [HwdecSetting(), SizedBox(height: 12), MpvControlsSetting()],
|
||||
),
|
||||
),
|
||||
SettingsPanel(title: 'Video quality & shaders', child: ShaderSettings()),
|
||||
];
|
||||
case SettingsSection.integrations:
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
|
||||
),
|
||||
const SettingsPanel(title: 'Jellyfin playback', child: AutoAdvanceSetting()),
|
||||
];
|
||||
case SettingsSection.updates:
|
||||
return [SettingsPanel(title: 'Updates', child: UpdateSettings(controller: updates))];
|
||||
case SettingsSection.shortcuts:
|
||||
return const [SettingsPanel(title: 'Keyboard shortcuts', child: ShortcutList())];
|
||||
case SettingsSection.about:
|
||||
return [AboutPanel(updates: updates)];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < 760;
|
||||
final nav = narrow
|
||||
? SizedBox(
|
||||
height: 52,
|
||||
child: HScroll(
|
||||
builder: (c) => ListView(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
children: [
|
||||
for (final s in SettingsSection.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('settings-${s.name}'),
|
||||
label: Text(s.label),
|
||||
selected: section == s,
|
||||
onSelected: (_) => onSection(s),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 250,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
||||
children: [
|
||||
for (final s in SettingsSection.values)
|
||||
ListTile(
|
||||
key: ValueKey('settings-${s.name}'),
|
||||
selected: section == s,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
leading: Icon(s.icon, size: 20),
|
||||
title: Text(s.label),
|
||||
onTap: () => onSection(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(section),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(section.label, style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 14),
|
||||
..._page(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return narrow
|
||||
? Column(children: [nav, Expanded(child: page)])
|
||||
: Row(children: [nav, Expanded(child: page)]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show MemberView;
|
||||
|
||||
import '../session/session_coordinator.dart' show ToastItem;
|
||||
import '../state/session_state.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// One toast on screen: who (null = the app itself), what, and whether it
|
||||
/// is on its way out.
|
||||
class ShellToast {
|
||||
final int id;
|
||||
final AvatarInfo? who;
|
||||
final IconData? icon;
|
||||
final String text;
|
||||
final bool chat;
|
||||
bool leaving = false;
|
||||
ShellToast({required this.id, required this.text, this.who, this.icon, this.chat = false});
|
||||
}
|
||||
|
||||
/// Splits a coordinator toast into who and what: feed toasts start with
|
||||
/// the actor's name ("Bob joined", "Bob: hi"); anything else is the app's.
|
||||
ShellToast shellToastFor(ToastItem t, Iterable<String> names, AvatarInfo Function(String name) avatar) {
|
||||
final sorted = names.where((n) => n.isNotEmpty).toSet().toList()..sort((a, b) => b.length - a.length);
|
||||
for (final n in sorted) {
|
||||
if (t.text.startsWith('$n: ')) {
|
||||
return ShellToast(id: t.id, who: avatar(n), text: t.text.substring(n.length + 2), chat: true);
|
||||
}
|
||||
if (t.text.startsWith('$n ')) {
|
||||
final rest = t.text.substring(n.length + 1);
|
||||
final icon = rest.startsWith('joined')
|
||||
? Icons.login
|
||||
: rest.startsWith('left')
|
||||
? Icons.logout
|
||||
: rest.startsWith('kicked')
|
||||
? Icons.gavel
|
||||
: rest.startsWith('changed')
|
||||
? Icons.swap_horiz
|
||||
: Icons.info_outline;
|
||||
return ShellToast(id: t.id, who: avatar(n), text: rest, icon: icon);
|
||||
}
|
||||
}
|
||||
return ShellToast(id: t.id, text: t.text, icon: Icons.info_outline);
|
||||
}
|
||||
|
||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||
/// slides in, stays [life], then slides out and collapses its gap.
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
const PartyToasts({super.key, this.life = const Duration(seconds: 5)});
|
||||
|
||||
@override
|
||||
State<PartyToasts> createState() => _PartyToastsState();
|
||||
}
|
||||
|
||||
class _PartyToastsState extends State<PartyToasts> {
|
||||
final _shown = <ShellToast>[];
|
||||
final _timers = <Timer>[];
|
||||
Set<int> _seen = {};
|
||||
late final void Function() _stop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final coord = SessionState.instance.coordinator;
|
||||
// What was there before this mounted is old news.
|
||||
_seen = {for (final t in coord.toasts.peek()) t.id};
|
||||
_stop = effect(() {
|
||||
final items = coord.toasts.value;
|
||||
final fresh = [for (final t in items) if (!_seen.contains(t.id)) t];
|
||||
_seen = {for (final t in items) t.id};
|
||||
if (fresh.isEmpty) return;
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
final r = coord.serverState.value;
|
||||
final names = [
|
||||
for (final m in r?.members ?? const <MemberView>[]) m.name,
|
||||
for (final f in coord.feedLog.value) f.actor,
|
||||
];
|
||||
for (final t in fresh) {
|
||||
_add(shellToastFor(t, names, (n) => avatarForName(n, coord)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _add(ShellToast t) {
|
||||
// Not from inside the effect's build-time notification.
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted) return;
|
||||
setState(() => _shown.add(t));
|
||||
_timers.add(Timer(widget.life, () {
|
||||
if (mounted) setState(() => t.leaving = true);
|
||||
}));
|
||||
_timers.add(Timer(widget.life + easeTime, () {
|
||||
if (mounted) setState(() => _shown.remove(t));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stop();
|
||||
for (final t in _timers) {
|
||||
t.cancel();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(child: ToastColumn(toasts: _shown.reversed.take(6).toList().reversed.toList()));
|
||||
}
|
||||
|
||||
class ToastColumn extends StatelessWidget {
|
||||
final List<ShellToast> toasts;
|
||||
const ToastColumn({super.key, required this.toasts});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final t in toasts)
|
||||
TweenAnimationBuilder<double>(
|
||||
key: ValueKey(t.id),
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
builder: (context, v, child) => Opacity(
|
||||
opacity: v,
|
||||
child: Transform.translate(offset: Offset(-24 * (1 - v), 0), child: child),
|
||||
),
|
||||
// Leaving: slide back out, fade, and collapse the gap it leaves.
|
||||
child: AnimatedAlign(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
heightFactor: t.leaving ? 0 : 1,
|
||||
child: AnimatedSlide(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero,
|
||||
child: AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
opacity: t.leaving ? 0 : 1,
|
||||
child: Padding(padding: const EdgeInsets.only(top: 8), child: ToastCard(toast: t)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToastCard extends StatelessWidget {
|
||||
final ShellToast toast;
|
||||
const ToastCard({super.key, required this.toast});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final who = toast.who;
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: scheme.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 380),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 16, 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: who == null ? CrossAxisAlignment.center : CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (who != null)
|
||||
StatusAvatar(info: who, size: 38, badges: false)
|
||||
else
|
||||
CircleAvatar(
|
||||
radius: 19,
|
||||
backgroundColor: scheme.primary.withValues(alpha: 0.15),
|
||||
child: Icon(toast.icon ?? Icons.info_outline, size: 18, color: scheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (who != null) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(who.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15)),
|
||||
),
|
||||
if (toast.icon != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(toast.icon, size: 16, color: scheme.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
Text(
|
||||
toast.text,
|
||||
style: TextStyle(
|
||||
fontSize: toast.chat ? 15 : 14,
|
||||
color: toast.chat || who == null ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user