forked from Foxyz/sync_replay_project
Desktop session batch 09-12: frameless chrome, bisect removal, chat/idle fixes
Frameless (window_manager, hidden titlebar): chrome bar on welcome, drag-to-move appbar + window buttons on connect, drag layer + window buttons in session controls; min size 160x90; volume-scroll off for drag; session maximize = fullscreen toggle. Verified on KDE Wayland. Delete legacy/minimal/nosheet bisect screens; session routes direct (black-screen fix verified incl. WAN). Avatar: pipeline now 128px aspect-kept PNG (was cropped JPEG); profile card centered 64px hover-to-change; member avatar drops ResizeImage downscale. NOTE open: pipeline 512 KiB check vs server 64 KiB enforcement mismatch. Fixes: idle warn/unready skipped while PLAYING; sendChat drops blanks; one chat row per send (drop textless server feed echo); chat field keeps focus after send. Playback libass true; sdk floor 3.13. Handoff 9-11: 9+10 fixed, 11 jumpy seek still open. Docs: handoff/roadmap/protocol record all of the above; few-commits preference going forward.
This commit is contained in:
1 parent
3303d88a5b
commit
ff154c5030
26 files changed
+1147
-881
No files matched your search
@@ -9,36 +9,37 @@ Future<String> encodeAvatar(String path) async {
|
||||
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 — check the path or pick again with Browse');
|
||||
}
|
||||
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 with Browse');
|
||||
}
|
||||
if (raw.isEmpty) {
|
||||
throw const FormatException(
|
||||
'that avatar file is empty — pick a PNG, JPG or WebP photo');
|
||||
throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo');
|
||||
}
|
||||
final decoded = img.decodeImage(Uint8List.fromList(raw));
|
||||
if (decoded == null) {
|
||||
throw const FormatException(
|
||||
'could not read that image — pick a PNG, JPG or WebP photo');
|
||||
throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo');
|
||||
}
|
||||
final side =
|
||||
decoded.width < decoded.height ? decoded.width : decoded.height;
|
||||
final x = (decoded.width - side) ~/ 2;
|
||||
final y = (decoded.height - side) ~/ 2;
|
||||
final cropped =
|
||||
img.copyCrop(decoded, x: x, y: y, width: side, height: side);
|
||||
final resized = img.copyResize(cropped, width: 128, height: 128);
|
||||
final jpg = img.encodeJpg(resized, quality: 85);
|
||||
if (jpg.length > 64 * 1024) {
|
||||
throw const FormatException(
|
||||
'that photo is still too large after shrinking — pick a smaller one');
|
||||
// final side = decoded.width < decoded.height ? decoded.width : decoded.height;
|
||||
// final x = (decoded.width - side) ~/ 2;
|
||||
// final y = (decoded.height - side) ~/ 2;
|
||||
// final cropped = img.copyCrop(decoded, x: x, y: y, width: side, height: side);
|
||||
final resized = img.copyResize(
|
||||
decoded,
|
||||
width: 128,
|
||||
height: 128,
|
||||
maintainAspect: true,
|
||||
// backgroundColor: img.ColorFloat16.rgba(0, 0, 0, 0),
|
||||
backgroundColor: img.PixelUndefined(),
|
||||
interpolation: img.Interpolation.cubic,
|
||||
);
|
||||
final png = img.encodePng(resized);
|
||||
if (resized.length > 512 * 1024) {
|
||||
throw const FormatException('that photo is still too large after shrinking — pick a smaller one');
|
||||
}
|
||||
return base64Encode(jpg);
|
||||
return base64Encode(png);
|
||||
}
|
||||
@@ -9,12 +9,7 @@ class MemberAvatar extends StatelessWidget {
|
||||
|
||||
static final Map<String, MemoryImage> _images = {};
|
||||
|
||||
const MemberAvatar({
|
||||
super.key,
|
||||
required this.avatarBase64,
|
||||
required this.name,
|
||||
this.size = 40,
|
||||
});
|
||||
const MemberAvatar({super.key, required this.avatarBase64, required this.name, this.size = 40});
|
||||
|
||||
static MemoryImage? _providerFor(String raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
@@ -33,15 +28,13 @@ class MemberAvatar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = _providerFor(avatarBase64);
|
||||
final decoration = BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
);
|
||||
final decoration = BoxDecoration(shape: BoxShape.circle);
|
||||
if (image != null) {
|
||||
return Container(
|
||||
decoration: decoration,
|
||||
child: ClipOval(
|
||||
child: Image(
|
||||
image: ResizeImage(height: (size * MediaQuery.of(context).devicePixelRatio).round(), image),
|
||||
image: image,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
@@ -55,11 +48,11 @@ class MemberAvatar extends StatelessWidget {
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
radius: size / 2,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
child: Text(
|
||||
name.isEmpty ? '?' : name[0].toUpperCase(),
|
||||
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -86,8 +86,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
||||
return;
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error =
|
||||
'could not read that image — pick a PNG, JPG or WebP photo');
|
||||
setState(() => _error = 'could not read that image — pick a PNG, JPG or WebP photo');
|
||||
return;
|
||||
}
|
||||
_avatarPath = path;
|
||||
@@ -102,25 +101,25 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: .center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
ClipOval(
|
||||
clipBehavior: .antiAlias,
|
||||
child: Tooltip(
|
||||
message: 'Change avatar',
|
||||
child: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
|
||||
child: _preview != null
|
||||
? null
|
||||
: _picking
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 64,
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
|
||||
child: _preview != null
|
||||
? null
|
||||
: _picking
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: ListenableBuilder(
|
||||
listenable: widget.name,
|
||||
builder: (context, _) {
|
||||
@@ -128,10 +127,17 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
||||
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
type: .transparency,
|
||||
child: InkWell(onTap: _pickAvatar),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: Column(
|
||||
@@ -140,10 +146,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
||||
children: [
|
||||
TextField(
|
||||
controller: widget.name,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Display name',
|
||||
isDense: true,
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Display name', isDense: true),
|
||||
onChanged: (_) {
|
||||
SessionState.instance.lastForm.name = widget.name.text.trim();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
|
||||
class WindowButtons extends StatefulWidget {
|
||||
final FocusNode? focusNode;
|
||||
final bool fullscreen;
|
||||
final VoidCallback? onToggleFullscreen;
|
||||
const WindowButtons({
|
||||
super.key,
|
||||
this.focusNode,
|
||||
this.fullscreen = false,
|
||||
this.onToggleFullscreen,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
bool _maximized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
windowManager.addListener(this);
|
||||
windowManager.isMaximized().then((v) {
|
||||
if (mounted) setState(() => _maximized = v);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
if (mounted) setState(() => _maximized = true);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
if (mounted) setState(() => _maximized = false);
|
||||
}
|
||||
|
||||
Future<void> _toggleMaximize() async {
|
||||
if (widget.onToggleFullscreen != null) {
|
||||
widget.onToggleFullscreen!();
|
||||
return;
|
||||
}
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _close() async {
|
||||
try {
|
||||
await SessionState.instance.coordinator.leave();
|
||||
await SessionState.instance.stopServer();
|
||||
} catch (_) {}
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final focus = widget.focusNode;
|
||||
final restored = widget.fullscreen || _maximized;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_WinBtn(
|
||||
icon: Icons.remove,
|
||||
tooltip: 'Minimize',
|
||||
focusNode: focus,
|
||||
onPressed: () => windowManager.minimize(),
|
||||
),
|
||||
_WinBtn(
|
||||
icon: restored ? Icons.filter_none : Icons.crop_square,
|
||||
tooltip: restored ? 'Restore' : 'Maximize',
|
||||
focusNode: focus,
|
||||
onPressed: _toggleMaximize,
|
||||
),
|
||||
_WinBtn(
|
||||
icon: Icons.close,
|
||||
tooltip: 'Close',
|
||||
focusNode: focus,
|
||||
hoverColor: Colors.red,
|
||||
onPressed: _close,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
final FocusNode? focusNode;
|
||||
final Color? hoverColor;
|
||||
const _WinBtn({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.focusNode,
|
||||
this.hoverColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
focusNode: focusNode,
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(width: 40, height: 32),
|
||||
iconSize: 15,
|
||||
splashRadius: 12,
|
||||
hoverColor: (hoverColor ?? Colors.white).withValues(alpha: 0.12),
|
||||
highlightColor: Colors.transparent,
|
||||
icon: Icon(icon, size: 15),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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