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:
foxyz committed 2026-09-12 11:42:18 +03:00
1 parent 3303d88a5b
commit ff154c5030
26 files changed
+1147 -881

No files matched your search

+1 -21
View File
@@ -1,25 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'src/screens/connect_screen.dart';
import 'src/screens/legacy_session_screen.dart';
import 'src/screens/minimal_session_screen.dart';
import 'src/screens/nosheet_session_screen.dart';
import 'src/screens/session_screen.dart';
import 'src/screens/welcome_screen.dart';
import 'src/state/session_state.dart';
bool get useLegacySession =>
Platform.environment['SYNC_RELAY_UI'] == 'legacy';
bool get useMinimalSession =>
Platform.environment['SYNC_RELAY_UI'] == 'minimal';
bool get useNosheetSession =>
Platform.environment['SYNC_RELAY_UI'] == 'nosheet';
class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key});
@@ -34,13 +20,7 @@ class WatchTogetherApp extends StatelessWidget {
return switch (session.route.value) {
AppRoute.welcome => const WelcomeScreen(),
AppRoute.connect => const ConnectScreen(),
AppRoute.session => useMinimalSession
? const MinimalSessionScreen()
: useNosheetSession
? const NosheetSessionScreen()
: useLegacySession
? const LegacySessionScreen()
: const SessionScreen(),
AppRoute.session => const SessionScreen(),
};
},
),
+14
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'src/state/session_state.dart';
@@ -32,6 +33,19 @@ Future<void> main(List<String> args) async {
),
);
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
size: Size(1280, 720),
minimumSize: Size(160, 90),
center: true,
backgroundColor: Color(0xFF000000),
titleBarStyle: TitleBarStyle.hidden,
title: 'Watch Together',
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
await Hive.initFlutter();
try {
await SessionState.instance.loadForm();
@@ -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);
}
+6 -1
View File
@@ -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);
}
+10 -17
View File
@@ -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),
),
);
}
+25 -22
View File
@@ -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(),
);
}
}
@@ -8,6 +8,8 @@
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
@@ -16,4 +18,10 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}
@@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux
media_kit_video
screen_retriever_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
+49 -1
View File
@@ -608,6 +608,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.6"
screen_retriever:
dependency: transitive
description:
name: screen_retriever
sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6
url: "https://pub.dev"
source: hosted
version: "0.2.2"
screen_retriever_linux:
dependency: transitive
description:
name: screen_retriever_linux
sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
screen_retriever_macos:
dependency: transitive
description:
name: screen_retriever_macos
sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a
url: "https://pub.dev"
source: hosted
version: "0.2.2"
screen_retriever_platform_interface:
dependency: transitive
description:
name: screen_retriever_platform_interface
sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
screen_retriever_windows:
dependency: transitive
description:
name: screen_retriever_windows
sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324
url: "https://pub.dev"
source: hosted
version: "0.2.2"
signals:
dependency: "direct main"
description:
@@ -811,6 +851,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.4.0"
window_manager:
dependency: "direct main"
description:
name: window_manager
sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9"
url: "https://pub.dev"
source: hosted
version: "0.5.2"
windows_file_picker:
dependency: transitive
description:
@@ -844,5 +892,5 @@ packages:
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.14.0-0 <4.0.0"
dart: ">=3.13.0-0 <4.0.0"
flutter: ">=3.44.0"
+2 -1
View File
@@ -3,7 +3,7 @@ description: Watch-together desktop app (working title, repo sync_relay_project)
publish_to: none
version: 0.1.0
environment:
sdk: '>=3.14.0-0 <4.0.0'
sdk: '>=3.13.0-0 <4.0.0'
dependencies:
flutter:
@@ -22,6 +22,7 @@ dependencies:
hive_ce: ^2.19.3
hive_ce_flutter: ^2.3.4
image: ^4.9.2
window_manager: ^0.5.2
dev_dependencies:
flutter_test:
sdk: flutter
@@ -8,10 +8,16 @@
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
@@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_windows_video
media_kit_video
screen_retriever_windows
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST