Files
sync_replay_project/apps/desktop/lib/src/shell/app_shell.dart
T
foxyzandClaude Opus 5.5 d29c12e4f0 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>
2026-10-09 00:23:46 +03:00

621 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
),
),
),
),
);
}
}