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
@@ -5,23 +5,30 @@ import 'dart:typed_data';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
/// An image file shrunk to 128 px as base64 PNG ('' for an empty path).
|
||||
/// Throws [FormatException] with a user-facing message.
|
||||
Future<String> encodeAvatar(String path) async {
|
||||
final trimmed = path.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
final file = File(trimmed);
|
||||
if (!await file.exists()) {
|
||||
throw const FormatException('avatar file not found — check the path or pick again with Browse');
|
||||
throw const FormatException('avatar file not found — pick it again');
|
||||
}
|
||||
late final List<int> raw;
|
||||
try {
|
||||
raw = await file.readAsBytes();
|
||||
} catch (_) {
|
||||
throw const FormatException('could not read that avatar file — pick it again with Browse');
|
||||
throw const FormatException('could not read that avatar file — pick it again');
|
||||
}
|
||||
if (raw.isEmpty) {
|
||||
throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo');
|
||||
}
|
||||
final decoded = img.decodeImage(Uint8List.fromList(raw));
|
||||
return encodeAvatarBytes(Uint8List.fromList(raw));
|
||||
}
|
||||
|
||||
/// Any image's bytes shrunk to 128 px as base64 PNG.
|
||||
String encodeAvatarBytes(Uint8List raw) {
|
||||
final decoded = img.decodeImage(raw);
|
||||
if (decoded == null) {
|
||||
throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo');
|
||||
}
|
||||
@@ -33,10 +40,16 @@ Future<String> encodeAvatar(String path) async {
|
||||
backgroundColor: img.PixelUndefined(),
|
||||
interpolation: img.Interpolation.cubic,
|
||||
);
|
||||
final png = img.encodePng(resized);
|
||||
final b64 = base64Encode(png);
|
||||
final b64 = base64Encode(img.encodePng(resized));
|
||||
if (b64.length > RelayConstants.maxAvatarBytes) {
|
||||
throw const FormatException('that photo is still too large after shrinking — pick a smaller one');
|
||||
}
|
||||
return b64;
|
||||
}
|
||||
|
||||
/// The profile's cropped PNG as sent to the server: as is when it fits
|
||||
/// the cap, else shrunk like [encodeAvatarBytes].
|
||||
String avatarWireBase64(Uint8List png) {
|
||||
final b64 = base64Encode(png);
|
||||
return b64.length <= RelayConstants.maxAvatarBytes ? b64 : encodeAvatarBytes(png);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MemberAvatar extends StatelessWidget {
|
||||
final String avatarBase64;
|
||||
final String name;
|
||||
final double size;
|
||||
|
||||
static final Map<String, MemoryImage> _images = {};
|
||||
|
||||
const MemberAvatar({super.key, required this.avatarBase64, required this.name, this.size = 40});
|
||||
|
||||
static MemoryImage? _providerFor(String raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
var hit = _images[raw];
|
||||
if (hit != null) return hit;
|
||||
try {
|
||||
hit = MemoryImage(base64Decode(raw));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (_images.length > 64) _images.clear();
|
||||
_images[raw] = hit;
|
||||
return hit;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = _providerFor(avatarBase64);
|
||||
final decoration = BoxDecoration(shape: BoxShape.circle);
|
||||
if (image != null) {
|
||||
return Container(
|
||||
decoration: decoration,
|
||||
child: ClipOval(
|
||||
child: Image(
|
||||
image: image,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, __, ___) => _fallback(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(decoration: decoration, child: _fallback());
|
||||
}
|
||||
|
||||
Widget _fallback() => CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
child: Text(
|
||||
name.isEmpty ? '?' : name[0].toUpperCase(),
|
||||
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,9 @@ import 'package:flutter/widgets.dart';
|
||||
import '../playback/frame_watchdog.dart';
|
||||
|
||||
/// Fingerprints what the compositor really shows of the video. Place
|
||||
/// [anchor] in the video controls (built in the window and again in the
|
||||
/// fullscreen route); [sample] captures the route around the newest
|
||||
/// mounted anchor at ~200 px wide — the texture included — and hashes its
|
||||
/// centre. This is the only per-presented-frame signal: mpv's own
|
||||
/// [anchor] under a `RepaintBoundary` around the video (the session player
|
||||
/// does); [sample] captures that boundary around the newest mounted anchor
|
||||
/// at ~200 px wide — the texture included — and hashes its centre. This is the only per-presented-frame signal: mpv's own
|
||||
/// counters keep ticking when the Flutter texture is stuck.
|
||||
class ShownFrameProbe {
|
||||
final _anchors = <_AnchorState>[];
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'window_buttons.dart';
|
||||
|
||||
class WindowChromeBar extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
const WindowChromeBar({super.key, required this.title, this.leading});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DragToMoveArea(
|
||||
child: Container(
|
||||
height: 36,
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const WindowButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
typedef ExclusionZone = Rect Function(Size);
|
||||
|
||||
class WindowDragLayer extends StatefulWidget {
|
||||
final bool enabled;
|
||||
final List<ExclusionZone> exclusions;
|
||||
const WindowDragLayer({
|
||||
super.key,
|
||||
this.enabled = true,
|
||||
this.exclusions = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<WindowDragLayer> createState() => _WindowDragLayerState();
|
||||
}
|
||||
|
||||
class _WindowDragLayerState extends State<WindowDragLayer> {
|
||||
int? _armedPointer;
|
||||
Offset? _downPos;
|
||||
bool _dragging = false;
|
||||
|
||||
void _onDown(PointerDownEvent e) {
|
||||
if (!widget.enabled) return;
|
||||
_armedPointer = e.pointer;
|
||||
_downPos = e.position;
|
||||
_dragging = false;
|
||||
}
|
||||
|
||||
void _onMove(PointerMoveEvent e) {
|
||||
if (e.pointer != _armedPointer || _dragging) return;
|
||||
final down = _downPos;
|
||||
if (down == null) return;
|
||||
if ((e.position - down).distance < kTouchSlop) return;
|
||||
final size = context.size;
|
||||
if (size != null) {
|
||||
for (final zone in widget.exclusions) {
|
||||
if (zone(size).contains(down)) {
|
||||
_armedPointer = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_dragging = true;
|
||||
windowManager.startDragging();
|
||||
}
|
||||
|
||||
void _onEnd(PointerEvent e) {
|
||||
if (e.pointer == _armedPointer) {
|
||||
_armedPointer = null;
|
||||
_dragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: _onDown,
|
||||
onPointerMove: _onMove,
|
||||
onPointerUp: _onEnd,
|
||||
onPointerCancel: _onEnd,
|
||||
child: const SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user