1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash
The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
Host cards (hosting no longer takes media), status-ring avatar stack
with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
draggable corner-snapping mini player, or fullscreen (window_manager,
party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
= the new SchedulePage; Settings = left nav over the existing panels.
Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
"avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
/a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
cached by hash in the app (AvatarCache).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f790470935
commit
d29c12e4f0
62 files changed
+5777
-3572
No files matched your search
@@ -0,0 +1,620 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/connect_flow.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/home/profile.dart';
|
||||
import '../screens/home/update_indicator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../update/update_controller.dart';
|
||||
import 'chat_panel.dart';
|
||||
import 'connect_cards.dart';
|
||||
import 'library_tab.dart';
|
||||
import 'motion.dart';
|
||||
import 'party_strip.dart';
|
||||
import 'profile_card.dart';
|
||||
import '../schedule/ui/schedule_page.dart';
|
||||
import 'session_player.dart';
|
||||
import 'settings_tab.dart';
|
||||
import 'toasts.dart';
|
||||
|
||||
/// The one window: the party strip over a body that fades through between
|
||||
/// the tabs and the full player, the floating mini player, the chat panel
|
||||
/// and the toasts. Online/offline follows [SessionState.route].
|
||||
///
|
||||
/// The player ([SessionPlayer], the only `Video`) lives in one layer of
|
||||
/// the body: full it fills the body (fullscreen: the window, strip
|
||||
/// hidden), mini it is a 384x216 box snapped to a corner. A mode change
|
||||
/// fades the layer out, moves it while invisible and fades it back in, so
|
||||
/// the video is never mounted twice nor remounted.
|
||||
class AppShell extends StatefulWidget {
|
||||
final UpdateController? updates;
|
||||
final bool autoUpdateCheck;
|
||||
/// Off in tests: the drag area, window buttons and fullscreen need
|
||||
/// window_manager.
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Stand-in for media_kit's `Video` (tests).
|
||||
final VideoViewBuilder? videoBuilder;
|
||||
const AppShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = true,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
this.videoBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => AppShellState();
|
||||
}
|
||||
|
||||
class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
|
||||
static const miniSize = Size(384, 216);
|
||||
static const _miniMargin = 16.0;
|
||||
|
||||
ShellTab _tab = ShellTab.library;
|
||||
SettingsSection _section = SettingsSection.profile;
|
||||
|
||||
/// Where the player is going ([_mode]) and where it is drawn ([_shown]):
|
||||
/// they differ for the first part of a change, while it fades out.
|
||||
PlayerMode _mode = PlayerMode.none;
|
||||
PlayerMode _shown = PlayerMode.none;
|
||||
PlayerMode _from = PlayerMode.none;
|
||||
int _modeSeq = 0;
|
||||
late final AnimationController _fade = AnimationController(vsync: this, value: 1);
|
||||
|
||||
bool _fullscreen = false;
|
||||
bool _chatOpen = false;
|
||||
int _unread = 0;
|
||||
int _feedSeen = 0;
|
||||
final _chat = TextEditingController();
|
||||
final _chatFocus = FocusNode();
|
||||
final _playerKey = GlobalKey();
|
||||
|
||||
Alignment _corner = Alignment.bottomRight;
|
||||
Offset? _drag; // mini top-left while dragging
|
||||
bool _snapping = false;
|
||||
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
late final JellyfinAccounts _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||
late MediaPickerController _picker = _newPicker();
|
||||
BrowseStart? _libraryStart;
|
||||
int _libraryGen = 0;
|
||||
|
||||
/// The next media to come up opens full (just joined, or put up here).
|
||||
bool _autoFull = false;
|
||||
int _lastMediaId = -1;
|
||||
double? _mutedVolume;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
final _stops = <void Function()>[];
|
||||
// Rebuild the shell on these flips, not on every state broadcast.
|
||||
final _online = computed(() => SessionState.instance.route.value == AppRoute.session);
|
||||
final _hasMedia = computed(() =>
|
||||
SessionState.instance.route.value == AppRoute.session &&
|
||||
SessionState.instance.coordinator.serverState.value?.media != null);
|
||||
|
||||
PlayerMode get mode => _mode;
|
||||
PlayerMode get shownMode => _shown;
|
||||
bool get fullscreen => _fullscreen;
|
||||
|
||||
MediaPickerController _newPicker({JellyfinMedia? jellyfin, MediaKind? source}) {
|
||||
final f = SessionState.instance.lastForm;
|
||||
final c = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl, jellyfin: jellyfin, source: source);
|
||||
if (jellyfin == null) unawaited(restoreLastPick(c, _jellyfin, () => mounted && identical(c, _picker)));
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(Profile.instance.ensureLoaded());
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKicked;
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
_feedSeen = coord.feedLog.peek().length;
|
||||
_stops.add(effect(() {
|
||||
final online = session.route.value == AppRoute.session;
|
||||
// Set now, not later: the media effect below may run first in this
|
||||
// same flush, and its scheduled show reads these.
|
||||
_autoFull = online;
|
||||
if (!online) _lastMediaId = -1;
|
||||
untracked(() => _later(() => online ? _wentOnline() : _wentOffline()));
|
||||
}));
|
||||
// Something comes up while the player is closed: show it (full right
|
||||
// after joining or a put-up of ours, else as the mini player).
|
||||
_stops.add(effect(() {
|
||||
final r = coord.serverState.value;
|
||||
final online = session.route.value == AppRoute.session;
|
||||
if (!online || r == null || r.media == null) return;
|
||||
if (r.mediaId == _lastMediaId) return;
|
||||
_lastMediaId = r.mediaId;
|
||||
_later(() {
|
||||
if (_mode == PlayerMode.none) {
|
||||
_show(_autoFull ? PlayerMode.full : PlayerMode.mini);
|
||||
} else if (_autoFull) {
|
||||
_show(PlayerMode.full);
|
||||
}
|
||||
_autoFull = false;
|
||||
});
|
||||
}));
|
||||
// Chat that arrives while the panel is closed.
|
||||
_stops.add(effect(() {
|
||||
final log = coord.feedLog.value;
|
||||
final fresh = log.length < _feedSeen ? log : log.sublist(_feedSeen);
|
||||
_feedSeen = log.length;
|
||||
final chats = fresh.where((f) => f.action == 'chat').length;
|
||||
if (chats == 0) return;
|
||||
untracked(() => _later(() {
|
||||
if (!_chatOpen) setState(() => _unread += chats);
|
||||
}));
|
||||
}));
|
||||
// Kicked: back offline, and say so.
|
||||
_stops.add(effect(() {
|
||||
final kick = coord.kickReason.value;
|
||||
if (kick == null) {
|
||||
_lastKickShown = null;
|
||||
return;
|
||||
}
|
||||
untracked(() {
|
||||
if (session.route.value == AppRoute.session) session.route.value = AppRoute.home;
|
||||
});
|
||||
if (kick.isNotEmpty) _later(() => _showKicked(kick));
|
||||
}));
|
||||
// The hosted server died mid-session (SessionState leaves): say so.
|
||||
_stops.add(effect(() {
|
||||
final crash = session.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
_later(() {
|
||||
session.serverCrash.value = null;
|
||||
_alert('Server crashed', crash);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final stop in _stops) {
|
||||
stop();
|
||||
}
|
||||
_fade.dispose();
|
||||
_online.dispose();
|
||||
_hasMedia.dispose();
|
||||
_flow.dispose();
|
||||
_join.dispose();
|
||||
_host.dispose();
|
||||
_picker.dispose();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
if (_fullscreen) _setWindowFullscreen(false);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Runs [f] after the current signal notification / build.
|
||||
void _later(VoidCallback f) => scheduleMicrotask(() {
|
||||
if (mounted) f();
|
||||
});
|
||||
|
||||
Future<void> _autoCheck() async {
|
||||
final s = AppSettings.instance;
|
||||
await s.ensureLoaded();
|
||||
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||
}
|
||||
|
||||
void _wentOnline() {
|
||||
_unread = 0;
|
||||
_feedSeen = SessionState.instance.coordinator.feedLog.peek().length;
|
||||
// Hosting starts with nothing up: the library is where to pick it.
|
||||
setState(() => _tab = ShellTab.library);
|
||||
}
|
||||
|
||||
void _wentOffline() {
|
||||
_mutedVolume = null;
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() {
|
||||
_chatOpen = false;
|
||||
_unread = 0;
|
||||
});
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- player mode
|
||||
|
||||
/// Fade through to [m]: the player layer (and, when full is involved,
|
||||
/// the tabs) fades out, moves while invisible, then fades in.
|
||||
Future<void> _show(PlayerMode m) async {
|
||||
if (m == _mode) return;
|
||||
final seq = ++_modeSeq;
|
||||
if (m != PlayerMode.none) _unmute();
|
||||
setState(() {
|
||||
_from = _shown;
|
||||
_mode = m;
|
||||
});
|
||||
if (m == PlayerMode.full) FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (_shown != PlayerMode.none || m == PlayerMode.full) {
|
||||
await _fade.animateTo(0,
|
||||
duration: fadeThroughTime * fadeOutPart, curve: Curves.easeIn);
|
||||
} else {
|
||||
_fade.value = 0;
|
||||
}
|
||||
if (!mounted || seq != _modeSeq) return;
|
||||
setState(() => _shown = m);
|
||||
await _fade.animateTo(1, duration: fadeThroughTime * (1 - fadeOutPart), curve: ease);
|
||||
}
|
||||
|
||||
void _expand() => _show(PlayerMode.full);
|
||||
|
||||
void _collapse() {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
_show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
/// Mini's ×: stop watching but stay in the server (muted until shown).
|
||||
void _closePlayer() {
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.alive.peek()) {
|
||||
try {
|
||||
_mutedVolume = playback.player.state.volume;
|
||||
playback.player.setVolume(0);
|
||||
} catch (_) {}
|
||||
}
|
||||
_show(PlayerMode.none);
|
||||
}
|
||||
|
||||
void _unmute() {
|
||||
final v = _mutedVolume;
|
||||
if (v == null) return;
|
||||
_mutedVolume = null;
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (!playback.alive.peek()) return;
|
||||
try {
|
||||
playback.player.setVolume(v);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _setWindowFullscreen(bool on) async {
|
||||
if (!widget.windowControls) return;
|
||||
try {
|
||||
await windowManager.setFullScreen(on);
|
||||
} catch (_) {
|
||||
// No window manager.
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFullscreen() {
|
||||
final on = !_fullscreen;
|
||||
setState(() => _fullscreen = on);
|
||||
if (on && _mode != PlayerMode.full) _show(PlayerMode.full);
|
||||
_setWindowFullscreen(on);
|
||||
}
|
||||
|
||||
void _selectTab(ShellTab t) {
|
||||
if (_fullscreen) _toggleFullscreen();
|
||||
setState(() => _tab = t);
|
||||
if (_mode == PlayerMode.full) _show(PlayerMode.mini);
|
||||
}
|
||||
|
||||
void _openSettings(SettingsSection s) {
|
||||
setState(() => _section = s);
|
||||
_selectTab(ShellTab.settings);
|
||||
}
|
||||
|
||||
void _toggleChat() => setState(() {
|
||||
_chatOpen = !_chatOpen;
|
||||
_unread = 0;
|
||||
if (!_chatOpen) _chatFocus.unfocus();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- library
|
||||
|
||||
/// "Change video": the library, opened on the series of the episode
|
||||
/// playing (season selected, episode highlighted), else where it was.
|
||||
void _changeVideo() {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
coord.markInput();
|
||||
final ref = coord.currentRef.peek();
|
||||
final pick = ref == null ? null : pickForRef(_jellyfin, ref);
|
||||
final plan = coord.jellyfinPlan.peek();
|
||||
final old = _picker;
|
||||
_picker = pick == null
|
||||
? _newPicker()
|
||||
: _newPicker(jellyfin: pick, source: MediaKind.jellyfin);
|
||||
old.dispose();
|
||||
_libraryStart = ref != null && plan is OwnPlay && plan.item != null
|
||||
? BrowseStart.seriesOf(ref.serverId, plan.item!)
|
||||
: null;
|
||||
_libraryGen++;
|
||||
_selectTab(ShellTab.library);
|
||||
}
|
||||
|
||||
/// Library → "Put it up for everyone"; the player opens full.
|
||||
Future<String?> _putUp(PickedMedia media) async {
|
||||
final session = SessionState.instance;
|
||||
if (media is JellyfinMedia) {
|
||||
try {
|
||||
putUpJellyfinMedia(_jellyfin, media);
|
||||
} on FormatException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
unawaited(_jellyfin.rememberPick(media));
|
||||
}
|
||||
final error = await session.coordinator.swapPicked(media);
|
||||
if (error != null || !mounted) return error;
|
||||
final f = session.lastForm;
|
||||
f
|
||||
..linkUrl = media is LinkMedia ? media.url : f.linkUrl
|
||||
..mediaPath = media is FileMedia ? media.path : f.mediaPath;
|
||||
unawaited(session.saveForm().catchError((Object _) {}));
|
||||
_autoFull = true;
|
||||
_show(PlayerMode.full);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- dialogs
|
||||
|
||||
Future<void> _confirmLeave() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Leave server?'),
|
||||
content: const Text('You can rejoin right away.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
key: const ValueKey('confirm-leave'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await SessionState.instance.leave();
|
||||
}
|
||||
|
||||
Future<void> _showKicked(String reason) async {
|
||||
if (reason == _lastKickShown) return;
|
||||
_lastKickShown = reason;
|
||||
await _alert('You were removed', reason.isEmpty ? 'The host removed you from the server.' : reason);
|
||||
}
|
||||
|
||||
Future<void> _alert(String title, String text) async {
|
||||
if (!mounted || _dialogOpen) return;
|
||||
_dialogOpen = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(text),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
_dialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _connect(ConnectMode mode) => showConnectCard(
|
||||
context,
|
||||
mode: mode,
|
||||
flow: _flow,
|
||||
join: _join,
|
||||
host: _host,
|
||||
healthGet: widget.healthGet,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.comma, control: true): () => _selectTab(ShellTab.settings),
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: _fullscreen ? Colors.black : scheme.surface,
|
||||
body: SignalBuilder(builder: (context) {
|
||||
final online = _online.value;
|
||||
final hasMedia = _hasMedia.value;
|
||||
return Column(
|
||||
children: [
|
||||
if (!_fullscreen)
|
||||
PartyStrip(
|
||||
key: const ValueKey('strip'),
|
||||
tab: _tab,
|
||||
onTab: _selectTab,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onChat: _toggleChat,
|
||||
onJoin: () => _connect(ConnectMode.join),
|
||||
onHost: () => _connect(ConnectMode.host),
|
||||
onLeave: _confirmLeave,
|
||||
onProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
onShowPlayer: hasMedia && _mode == PlayerMode.none ? () => _show(PlayerMode.mini) : null,
|
||||
updates: widget.updates,
|
||||
windowControls: widget.windowControls,
|
||||
),
|
||||
Expanded(
|
||||
key: const ValueKey('body'),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _body(online)),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _chatOpen && online
|
||||
? ChatPanel(chat: _chat, chatFocus: _chatFocus, onClose: _toggleChat)
|
||||
: const SizedBox(height: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(bool online) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
final tabBody = switch (_tab) {
|
||||
ShellTab.library => LibraryTab(
|
||||
key: ValueKey('library-$_libraryGen'),
|
||||
controller: _picker,
|
||||
accounts: _jellyfin,
|
||||
online: online,
|
||||
isHost: coord.hostFile != null,
|
||||
jellyfinStart: _libraryStart,
|
||||
onPutUp: _putUp,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
),
|
||||
ShellTab.schedule => SchedulePage(
|
||||
online: online,
|
||||
onPutUp: _putUp,
|
||||
onToast: coord.showToast,
|
||||
),
|
||||
ShellTab.settings => SettingsTab(
|
||||
section: _section,
|
||||
onSection: (s) => setState(() => _section = s),
|
||||
onEditProfile: () => showProfileCard(context, onOpenSettings: _openSettings, updates: widget.updates),
|
||||
updates: widget.updates,
|
||||
jellyfin: _jellyfin,
|
||||
),
|
||||
};
|
||||
final full = _shown == PlayerMode.full;
|
||||
final fullInvolved = _mode == PlayerMode.full || _from == PlayerMode.full;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final area = box.biggest;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
// The tabs take part in the fade only when the full player
|
||||
// comes or goes; a mini player appearing leaves them be.
|
||||
final v = full ? 0.0 : (fullInvolved ? _fade.value : 1.0);
|
||||
return Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: 0.985 + 0.015 * v, child: child),
|
||||
);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
ignoring: full,
|
||||
child: ExcludeFocus(
|
||||
excluding: full,
|
||||
child: TickerMode(
|
||||
enabled: !full,
|
||||
child: fadeThrough(KeyedSubtree(key: ValueKey(_tab), child: tabBody)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (online && _shown != PlayerMode.none) _playerLayer(area),
|
||||
Positioned(left: 16, bottom: _fullscreen ? 90 : 16, child: const PartyToasts()),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Offset _cornerPos(Size area, Alignment a, Size size) => Offset(
|
||||
a.x < 0 ? _miniMargin : (area.width - size.width - _miniMargin).clamp(0, double.infinity),
|
||||
a.y < 0 ? _miniMargin : (area.height - size.height - _miniMargin).clamp(0, double.infinity),
|
||||
);
|
||||
|
||||
Widget _playerLayer(Size area) {
|
||||
final mini = _shown == PlayerMode.mini;
|
||||
final size = mini
|
||||
? Size(miniSize.width.clamp(0, area.width), miniSize.height.clamp(0, area.height))
|
||||
: area;
|
||||
final pos = mini ? (_drag ?? _cornerPos(area, _corner, size)) : Offset.zero;
|
||||
return AnimatedPositioned(
|
||||
key: const ValueKey('player-layer'),
|
||||
duration: _snapping ? easeTime : Duration.zero,
|
||||
curve: ease,
|
||||
left: pos.dx,
|
||||
top: pos.dy,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
onEnd: () => _snapping = false,
|
||||
child: AnimatedBuilder(
|
||||
animation: _fade,
|
||||
builder: (context, child) {
|
||||
final v = _fade.value;
|
||||
return IgnorePointer(
|
||||
ignoring: v < 0.5 || _shown != _mode,
|
||||
child: Opacity(
|
||||
opacity: v,
|
||||
child: Transform.scale(scale: mini ? 0.97 + 0.03 * v : 0.985 + 0.015 * v, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Same widgets in both modes (only their settings change), so the
|
||||
// player below never moves in the element tree.
|
||||
child: GestureDetector(
|
||||
onPanStart: mini ? (_) => setState(() => _drag = pos) : null,
|
||||
onPanUpdate: mini ? (d) => setState(() => _drag = _drag! + d.delta) : null,
|
||||
onPanEnd: mini
|
||||
? (_) => setState(() {
|
||||
final c = _drag! + Offset(size.width / 2, size.height / 2);
|
||||
_corner = Alignment(c.dx < area.width / 2 ? -1 : 1, c.dy < area.height / 2 ? -1 : 1);
|
||||
_drag = null;
|
||||
_snapping = true;
|
||||
})
|
||||
: null,
|
||||
child: DoubleClickListener(
|
||||
onDoubleClick: mini ? _expand : () {},
|
||||
skipTop: 40,
|
||||
skipBottom: 60,
|
||||
child: Material(
|
||||
elevation: mini ? 16 : 0,
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(mini ? 14 : 0),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: KeyedSubtree(
|
||||
key: _playerKey,
|
||||
child: SessionPlayer(
|
||||
mode: _shown,
|
||||
fullscreen: _fullscreen,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onExpand: _expand,
|
||||
onCollapse: _collapse,
|
||||
onClose: _closePlayer,
|
||||
onToggleFullscreen: _toggleFullscreen,
|
||||
onToggleChat: _toggleChat,
|
||||
onChangeVideo: _changeVideo,
|
||||
onOpenIntegrations: () => _openSettings(SettingsSection.integrations),
|
||||
onLeave: _confirmLeave,
|
||||
videoBuilder: widget.videoBuilder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user