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,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;
|
||||
}
|
||||
Reference in new issue
Block a user