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
@@ -13,6 +13,7 @@ class PlaybackController {
|
||||
player = Player(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: 64 * 1024 * 1024,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
));
|
||||
_ready = true;
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import '../widgets/media_input.dart';
|
||||
import '../widgets/user_section.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
|
||||
class ConnectScreen extends StatefulWidget {
|
||||
const ConnectScreen({super.key});
|
||||
@@ -226,9 +228,15 @@ class _ConnectScreenState extends State<ConnectScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
|
||||
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||
child: DragToMoveArea(
|
||||
child: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
|
||||
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
|
||||
actions: const [WindowButtons()],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
alignment: AlignmentGeometry.center,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
|
||||
const _legacyControls = MaterialDesktopVideoControlsThemeData(
|
||||
playAndPauseOnTap: false,
|
||||
keyboardShortcuts: {},
|
||||
bottomButtonBar: [
|
||||
MaterialDesktopVolumeButton(),
|
||||
MaterialDesktopPositionIndicator(),
|
||||
Spacer(),
|
||||
MaterialDesktopFullscreenButton(),
|
||||
],
|
||||
);
|
||||
|
||||
class LegacySessionScreen extends StatefulWidget {
|
||||
const LegacySessionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LegacySessionScreen> createState() => _LegacySessionScreenState();
|
||||
}
|
||||
|
||||
class _LegacySessionScreenState extends State<LegacySessionScreen> {
|
||||
VideoController? _video;
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_video = VideoController(
|
||||
SessionState.instance.coordinator.playback.player);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
SessionState.instance.coordinator.toggleReady();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
final coord = session.coordinator;
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: SignalBuilder(
|
||||
builder: (context) {
|
||||
final h = coord.health.value;
|
||||
final r = coord.room.value;
|
||||
return Text('Room · ${r?.transport ?? '…'} · ${h.message}');
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => session.leave(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SignalBuilder(builder: (context) {
|
||||
final h = coord.health.value;
|
||||
final starve = coord.starving.value;
|
||||
final text = [
|
||||
h.message,
|
||||
if (starve != null) starve,
|
||||
].join(' · ');
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Text(text),
|
||||
);
|
||||
}),
|
||||
Expanded(
|
||||
child: _video == null
|
||||
? const Center(child: Text('player starting…'))
|
||||
: MaterialDesktopVideoControlsTheme(
|
||||
normal: _legacyControls,
|
||||
fullscreen: _legacyControls,
|
||||
child: Video(controller: _video!),
|
||||
),
|
||||
),
|
||||
SignalBuilder(builder: (context) {
|
||||
final r = coord.room.value;
|
||||
if (r == null) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final m in r.members)
|
||||
Chip(
|
||||
avatar: CircleAvatar(
|
||||
child: Text(m.name.isEmpty
|
||||
? '?'
|
||||
: m.name[0].toUpperCase()),
|
||||
),
|
||||
label: Text(m.name),
|
||||
backgroundColor: m.ready
|
||||
? Colors.green.withValues(alpha: 0.3)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SignalBuilder(builder: (context) {
|
||||
final ready =
|
||||
coord.selfMember.value?.ready ?? false;
|
||||
return ElevatedButton(
|
||||
onPressed: () => coord.toggleReady(),
|
||||
child: Text(ready ? 'Ready ✓' : 'Not ready'),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => coord.forcePlayAll(),
|
||||
child: const Text('Force play all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
|
||||
const _minimalControls = MaterialDesktopVideoControlsThemeData(
|
||||
playAndPauseOnTap: false,
|
||||
keyboardShortcuts: {},
|
||||
bottomButtonBar: [
|
||||
MaterialDesktopVolumeButton(),
|
||||
_ReadyButton(),
|
||||
_MarkAllButton(),
|
||||
MaterialDesktopPositionIndicator(),
|
||||
Spacer(),
|
||||
MaterialDesktopFullscreenButton(),
|
||||
],
|
||||
);
|
||||
|
||||
class MinimalSessionScreen extends StatefulWidget {
|
||||
const MinimalSessionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MinimalSessionScreen> createState() => _MinimalSessionScreenState();
|
||||
}
|
||||
|
||||
class _MinimalSessionScreenState extends State<MinimalSessionScreen> {
|
||||
VideoController? _video;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_video = VideoController(
|
||||
SessionState.instance.coordinator.playback.player);
|
||||
HardwareKeyboard.instance.addHandler(_key);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_key);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _key(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
if (SessionState.instance.route.peek() != AppRoute.session) {
|
||||
return false;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
SessionState.instance.coordinator.toggleReady();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _video == null
|
||||
? const Center(child: Text('player starting…'))
|
||||
: MaterialDesktopVideoControlsTheme(
|
||||
normal: _minimalControls,
|
||||
fullscreen: _minimalControls,
|
||||
child: Video(controller: _video!),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Text('MINIMAL',
|
||||
style: TextStyle(color: Colors.red, fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadyButton extends StatelessWidget {
|
||||
const _ReadyButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final ready = SessionState
|
||||
.instance.coordinator.selfMember.value?.ready ??
|
||||
false;
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () =>
|
||||
SessionState.instance.coordinator.toggleReady(),
|
||||
child: Text(ready ? 'Ready ✓' : 'Not ready',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkAllButton extends StatelessWidget {
|
||||
const _MarkAllButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () =>
|
||||
SessionState.instance.coordinator.forcePlayAll(),
|
||||
child: const Text('Mark everyone ready',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/health.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/member_avatar.dart';
|
||||
|
||||
const _nosheetControls = MaterialDesktopVideoControlsThemeData(
|
||||
playAndPauseOnTap: false,
|
||||
keyboardShortcuts: {},
|
||||
visibleOnMount: true,
|
||||
hideMouseOnControlsRemoval: false,
|
||||
controlsHoverDuration: Duration(hours: 24),
|
||||
bottomButtonBar: [
|
||||
MaterialDesktopVolumeButton(),
|
||||
_ReadyButton(),
|
||||
_MarkAllButton(),
|
||||
MaterialDesktopPositionIndicator(),
|
||||
Spacer(),
|
||||
MaterialDesktopFullscreenButton(),
|
||||
],
|
||||
);
|
||||
|
||||
class NosheetSessionScreen extends StatefulWidget {
|
||||
const NosheetSessionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NosheetSessionScreen> createState() => _NosheetSessionScreenState();
|
||||
}
|
||||
|
||||
class _NosheetSessionScreenState extends State<NosheetSessionScreen> {
|
||||
VideoController? _video;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_video = VideoController(
|
||||
SessionState.instance.coordinator.playback.player);
|
||||
HardwareKeyboard.instance.addHandler(_key);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_key);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _key(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
if (SessionState.instance.route.peek() != AppRoute.session) {
|
||||
return false;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
SessionState.instance.coordinator.toggleReady();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _video == null
|
||||
? const Center(child: Text('player starting…'))
|
||||
: MaterialDesktopVideoControlsTheme(
|
||||
normal: _nosheetControls,
|
||||
fullscreen: _nosheetControls,
|
||||
child: Video(controller: _video!),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Text('NOSHEET',
|
||||
style: TextStyle(color: Colors.red, fontSize: 12)),
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final h = coord.health.value;
|
||||
final starve = coord.starving.value;
|
||||
final retrying = coord.reconnecting.value;
|
||||
final unwell = h.severity == HealthSeverity.warn ||
|
||||
h.severity == HealthSeverity.error ||
|
||||
retrying ||
|
||||
starve != null;
|
||||
if (!unwell) return const SizedBox.shrink();
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Text(
|
||||
[h.message, if (starve != null) starve].join(' · '),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
SignalBuilder(builder: (context) {
|
||||
final r = coord.room.value;
|
||||
if (r == null || r.transport != 'held') {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final waiting = [
|
||||
for (final m in r.members)
|
||||
if (!m.ready) m
|
||||
];
|
||||
if (waiting.isEmpty) return const SizedBox.shrink();
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Wrap(
|
||||
spacing: -20,
|
||||
children: [
|
||||
for (final m in waiting)
|
||||
Opacity(
|
||||
opacity: m.stale ? 0.5 : 1.0,
|
||||
child: MemberAvatar(
|
||||
avatarBase64: m.avatar,
|
||||
name: m.name,
|
||||
size: 64,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
Positioned(
|
||||
right: 12,
|
||||
top: 56,
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final items = coord.toasts.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final t in items.reversed.take(5))
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
child: Text(t.text),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadyButton extends StatelessWidget {
|
||||
const _ReadyButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final ready = SessionState
|
||||
.instance.coordinator.selfMember.value?.ready ??
|
||||
false;
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () =>
|
||||
SessionState.instance.coordinator.toggleReady(),
|
||||
child: Text(ready ? 'Ready ✓' : 'Not ready',
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkAllButton extends StatelessWidget {
|
||||
const _MarkAllButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () =>
|
||||
SessionState.instance.coordinator.forcePlayAll(),
|
||||
child: const Text('Mark everyone ready',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,16 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../state/session_state.dart';
|
||||
import '../widgets/window_chrome_bar.dart';
|
||||
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
WindowChromeBar(title: 'Watch Together'),
|
||||
Expanded(child: _WelcomeBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeBody extends StatelessWidget {
|
||||
const _WelcomeBody();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -44,7 +60,6 @@ class WelcomeScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -386,24 +386,29 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
final idleSecs = now.difference(_lastInput).inSeconds;
|
||||
if (idleSecs >= 8 * 60 && !_warnedIdle) {
|
||||
_warnedIdle = true;
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: 'idle — auto-unready in 2 min unless you press Space',
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
}
|
||||
if (idleSecs >= 10 * 60) {
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
_lastReady = false;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
markInput();
|
||||
// Steady playback counts as activity: only warn/unready when HELD.
|
||||
// While the room is PLAYING the ready flag gates nothing, so an
|
||||
// idle viewer must never lose it mid-watch.
|
||||
if (room.peek()?.transport != 'playing') {
|
||||
if (idleSecs >= 8 * 60 && !_warnedIdle) {
|
||||
_warnedIdle = true;
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
ToastItem(
|
||||
id: _toastId,
|
||||
text: 'idle — auto-unready in 2 min unless you press Space',
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
}
|
||||
if (idleSecs >= 10 * 60) {
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
_lastReady = false;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
markInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,6 +487,10 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void sendChat(String text) {
|
||||
// Enter in the chat field can fire both onSubmitted and the focused
|
||||
// send button's onPressed; the first send clears the field, so the
|
||||
// second arrives empty. Drop blanks so at most one message ships.
|
||||
if (text.trim().isEmpty) return;
|
||||
markInput();
|
||||
sync.sendChat(text);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,12 @@ class SyncClient {
|
||||
} catch (_) {}
|
||||
case 'feed':
|
||||
try {
|
||||
_feeds.add(FeedMessage.fromJson(decoded));
|
||||
final f = FeedMessage.fromJson(decoded);
|
||||
// Server delivers every chat twice: a ChatMessage carrying the
|
||||
// text (converted to a feed entry in `case 'chat'` below) and a
|
||||
// textless feed entry. Keep only the former: one send, one row,
|
||||
// one toast, one sfx.
|
||||
if (f.action != 'chat') _feeds.add(f);
|
||||
} catch (_) {}
|
||||
case 'kicked':
|
||||
try {
|
||||
|
||||
@@ -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