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

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

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

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

No files matched your search

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