diff --git a/HANDOFF.md b/HANDOFF.md index 9df53c8..247cd53 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,8 +4,10 @@ Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening + UI redesigns done, all green (core 42, server 40, desktop 17). Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race; Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect -flags (legacy/minimal/nosheet) still in app.dart — remove once -stable. Internet test PASSED (WAN port 6969, 09-12). Tonight's +screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes +session straight to SessionScreen. Internet test PASSED (WAN port +6969, 09-12); user reports 2-client LAN soak + 1.4 GB real-file +test also done 09-12 (outcomes not yet recorded below). Tonight's fixes: server logging wired into app (`initLogging` + hello logs), Cancel aborts pending connect, first-join failure no longer retry-loops. Git repo INITIALIZED (commit 427d851, root @@ -144,10 +146,14 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop (intents only, zero local transport buttons), center avatars-only 64 px, slide-in toasts, animated right sheet (users/log/chat/swap/ Leave w/ confirm), health pill only-when-unwell, ESC closes/ - asks-leave, global focus-proof keys (Space/Tab/arrows/ESC, - 350 ms throttle, ignored while typing), single media field - (parseMediaInput + tests), avatar pipeline (128 px JPEG, tap to - pick, no path fields), Discord-like corner profile (connect only; + asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F, + 350 ms throttle, ignored while typing; Tab traversal disabled on + the session screen 09-12 — game-style, Tab toggles the sheet), single media field + (parseMediaInput + tests), avatar pipeline (128 px PNG as of + 09-12 uncommitted change, was JPEG — NOTE: server still + enforces 64 KiB in session.dart vs pipeline's 512 KiB check, + mismatch open, see commit), tap to + pick, no path fields), Discord-like corner profile (connect only; welcome is title+Host/Join; edits apply next join — no rename intent on the wire), dialogs for terminal states. - UX decisions locked along the way: Space flips self-ready only; @@ -199,7 +205,12 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop 14. New `MediaDesktopVideoControlsThemeData` per build is fine (stock guards subscriptions) but wasteful; leave it. 15. Env overrides for mpv experiments (no rebuild to switch): - SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no). + SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no). +16. Hidden-but-mounted overlays keep focus: closing the sheet left + focus on an offstage button, so Space fell through (350 ms + throttle hole) into the hidden control. Fix pattern: ExcludeFocus + on the closed subtree + unfocus-on-close effect. Same reason new + overlay buttons reuse the shared no-focus node. ## 7. Sounds (deferred, wiring ready) @@ -225,17 +236,17 @@ over internet (WAN 2-client test, port 6969, late 09-12) — CLOSED. RULE: never unmount GPU-textured widgets on a transport-gated branch; keep mounted + skip paint. Bisect screens (legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart) -still wired — delete on approval. session_screen.dart may still -hold the user's commented bisect lines — restore or drop. +DELETED 09-12 (commit tracks this); no commented bisect lines +remain in session_screen.dart. ## 9. Next (ordered, updated 09-12 late session) 1. §8 black screen — CLOSED (fix verified incl. over internet). -2. Remove bisect flags (legacy/minimal/nosheet) in app.dart. -3. 2-client LAN soak (manual, user-driven; WAN test passed, LAN - still untested). -4. 1.4 GB real-file media test (user resupplies Jellyfin URL; - contains api_key — NEVER paste into files). +2. Remove bisect flags — DONE 09-12 (files deleted, app.dart + routes session directly; tree analyzes clean). +3. 2-client LAN soak — user reports DONE 09-12 (details pending). +4. 1.4 GB real-file media test — user reports DONE 09-12 + (details pending; api_key must never land in files). 5. Cache-exhaustion long outage via shaper --kill-after (Phase 5 chaos assertion, never run). 6. Sounds swap-in (§7) after audition. @@ -245,6 +256,51 @@ hold the user's commented bisect lines — restore or drop. 8. `/health` is unauthenticated and broadcasts the full media URL (incl. api_key when the link carries one) — acceptable on LAN, noted now that 6969 faces the internet. +9. FIXED 09-12: auto-unready fired while passively watching — + the 10-min check keyed off `_lastInput`, refreshed only by + explicit actions. Steady playback now counts as activity: + warn + auto-unready are skipped while room transport is + PLAYING (`session_coordinator.dart` idle block). HELD-and-idle + still unreadies after 10 min. +10. FIXED 09-12 (second attempt — first theory was wrong): one chat + send rendered as TWO log rows. Root cause is server dual + delivery, not double-send: `broadcastChat` + (`room_hub.dart:69`) emits both a `ChatMessage` (with text, + converted to a feed entry client-side) and a textless `feed` + entry, and the client logged both. The blank-guard in + `sendChat` was defense against a phantom. Real fix: client + drops textless `chat` feed entries (`sync_client.dart` feed + case) — one send, one row, one toast, one sfx. No wire + change, still v4. Chat field also keeps focus after send + (dedicated FocusNode, re-requested on submit/button). +11. KNOWN (non-breaking, fix later): seeks feel jumpy + (forward-back-forward) — suspect the seek-land retry + (`_checkSeekLanded`, `session_coordinator.dart:429`) + re-applying SeekAction against the 0.5 s deadband / 2 s + debounce and the 1 Hz room echo. Needs a live repro with a + timeline log before touching. Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present, ~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv -instance. No git repo — ask before creating one. +instance. Git repo at project root (init 09-12); commit code + docs +together; prefer FEW commits (one per topic/session, not per fix) +over bisect-granularity — user decision 09-12. + +## 10. Window chrome — frameless, mpv-style (09-12, VERIFIED by user) + +Native title bar gone everywhere (`window_manager ^0.5.2`, +`TitleBarStyle.hidden` in main.dart GUI path only, headless +untouched). New screens MUST bring their own drag + min/max/close +or they ship immovable — reuse `widgets/window_buttons.dart` + +`widgets/window_chrome_bar.dart` (welcome/connect pattern). +Session: drag is `widgets/window_drag_layer.dart`, topmost raw- +pointer observer inside the controls stack (a GestureDetector +below media_kit's overlay LOSES the arena — do not revert to +that); dead zones: bottom 120 px (seek stays exact), open sheet, +leave dialog. `modifyVolumeOnScroll: false` is load-bearing for +drag — side effect: volume is speaker-button-only, no scroll- +volume. Maximize button = media_kit fullscreen toggle in +session (no OS maximize there); NO aspect-ratio lock by user +decision (custom UI lives outside the video frame). Close = +graceful leave + stopServer, then window close. Min size +160x90, no max. Verified on KDE Wayland (Arch): bar-drag, +video-drag, fullscreen, free resize all good. diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart index fcc0166..dc9079f 100644 --- a/apps/desktop/lib/app.dart +++ b/apps/desktop/lib/app.dart @@ -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(), }; }, ), diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index c0126c9..18118fb 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List 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(); diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 36ab876..9c0337e 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -13,6 +13,7 @@ class PlaybackController { player = Player( configuration: PlayerConfiguration( bufferSize: 64 * 1024 * 1024, + libass: true, vo: Platform.environment['SYNC_RELAY_VO'], )); _ready = true; diff --git a/apps/desktop/lib/src/screens/connect_screen.dart b/apps/desktop/lib/src/screens/connect_screen.dart index bb7cd72..6cfa051 100644 --- a/apps/desktop/lib/src/screens/connect_screen.dart +++ b/apps/desktop/lib/src/screens/connect_screen.dart @@ -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 { 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, diff --git a/apps/desktop/lib/src/screens/legacy_session_screen.dart b/apps/desktop/lib/src/screens/legacy_session_screen.dart deleted file mode 100644 index a7b7ae2..0000000 --- a/apps/desktop/lib/src/screens/legacy_session_screen.dart +++ /dev/null @@ -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 createState() => _LegacySessionScreenState(); -} - -class _LegacySessionScreenState extends State { - 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'), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/desktop/lib/src/screens/minimal_session_screen.dart b/apps/desktop/lib/src/screens/minimal_session_screen.dart deleted file mode 100644 index 9c76227..0000000 --- a/apps/desktop/lib/src/screens/minimal_session_screen.dart +++ /dev/null @@ -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 createState() => _MinimalSessionScreenState(); -} - -class _MinimalSessionScreenState extends State { - 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)), - ); - } -} diff --git a/apps/desktop/lib/src/screens/nosheet_session_screen.dart b/apps/desktop/lib/src/screens/nosheet_session_screen.dart deleted file mode 100644 index f32781e..0000000 --- a/apps/desktop/lib/src/screens/nosheet_session_screen.dart +++ /dev/null @@ -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 createState() => _NosheetSessionScreenState(); -} - -class _NosheetSessionScreenState extends State { - 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)), - ); - } -} diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart index ddf05df..ce0538a 100644 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -2,11 +2,14 @@ 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 'package:sync_relay_core/core.dart'; import '../session/health.dart'; import '../state/session_state.dart'; import '../widgets/dialogs.dart'; import '../widgets/member_avatar.dart'; +import '../widgets/window_buttons.dart'; +import '../widgets/window_drag_layer.dart'; class SessionScreen extends StatefulWidget { const SessionScreen({super.key}); @@ -17,12 +20,16 @@ class SessionScreen extends StatefulWidget { class _SessionScreenState extends State { VideoController? _video; - bool _sheetOpen = false; - bool _confirmLeave = false; + final _videoKey = GlobalKey(); + final _sheetOpen = signal(false); + final _confirmLeave = signal(false); + final _isFullscreen = signal(false); DateTime? _lastKeyAt; final _chat = TextEditingController(); + final _chatFocus = FocusNode(); final _swapLink = TextEditingController(); late final void Function() _stopKickWatch; + late final void Function() _stopSheetWatch; late final FocusNode _noFocus; @override @@ -37,13 +44,30 @@ class _SessionScreenState extends State { session.route.value = AppRoute.connect; } }); + _stopSheetWatch = effect(() { + if (!_sheetOpen.value) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }); + } + + void _toggleFullscreen() { + if (_isFullscreen.peek()) { + if (mounted) { + Navigator.of(context, rootNavigator: true).maybePop(); + } + } else { + _videoKey.currentState?.enterFullscreen(); + } } @override void dispose() { HardwareKeyboard.instance.removeHandler(_globalKey); _stopKickWatch(); + _stopSheetWatch(); _chat.dispose(); + _chatFocus.dispose(); _swapLink.dispose(); _noFocus.dispose(); super.dispose(); @@ -74,9 +98,8 @@ class _SessionScreenState extends State { return true; } if (event.logicalKey == LogicalKeyboardKey.tab) { - if (!mounted) return false; _lastKeyAt = now; - setState(() => _sheetOpen = !_sheetOpen); + _sheetOpen.value = !_sheetOpen.peek(); coord.markInput(); return true; } @@ -91,13 +114,33 @@ class _SessionScreenState extends State { return true; } if (event.logicalKey == LogicalKeyboardKey.escape) { - if (!mounted) return false; - if (_sheetOpen) { - setState(() => _sheetOpen = false); + if (_sheetOpen.peek()) { + _sheetOpen.value = false; + return true; + } + if (_confirmLeave.peek()) { + _confirmLeave.value = false; + return true; + } + if (_isFullscreen.peek()) { + if (mounted) { + Navigator.of(context, rootNavigator: true).maybePop(); + } return true; } _lastKeyAt = now; - setState(() => _confirmLeave = true); + _confirmLeave.value = true; + return true; + } + if (event.logicalKey == LogicalKeyboardKey.keyF) { + _lastKeyAt = now; + if (_isFullscreen.peek()) { + if (mounted) { + Navigator.of(context, rootNavigator: true).maybePop(); + } + } else { + _videoKey.currentState?.enterFullscreen(); + } return true; } return false; @@ -105,14 +148,27 @@ class _SessionScreenState extends State { @override Widget build(BuildContext context) { - final session = SessionState.instance; - final coord = session.coordinator; + final coord = SessionState.instance.coordinator; final controlsTheme = MaterialDesktopVideoControlsThemeData( playAndPauseOnTap: false, + modifyVolumeOnScroll: false, keyboardShortcuts: {}, visibleOnMount: true, - hideMouseOnControlsRemoval: false, - controlsHoverDuration: const Duration(hours: 24), + hideMouseOnControlsRemoval: true, + controlsHoverDuration: const Duration(seconds: 3), + topButtonBar: [ + const Spacer(), + MaterialDesktopCustomButton( + icon: const Icon(Icons.group), + onPressed: () { + _sheetOpen.value = true; + coord.markInput(); + }, + ), + SignalBuilder( + builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen), + ), + ], bottomButtonBar: [ const MaterialDesktopVolumeButton(), _ReadyControlButton(focusNode: _noFocus), @@ -122,232 +178,299 @@ class _SessionScreenState extends State { const MaterialDesktopFullscreenButton(), ], ); - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: _video == null - ? const Center( - child: Text('player starting…', style: TextStyle(color: Colors.white)), - ) - : MaterialDesktopVideoControlsTheme( - normal: controlsTheme, - fullscreen: controlsTheme, - child: Video(controller: _video!), - ), + return FocusTraversalGroup( + descendantsAreTraversable: false, + child: Scaffold( + backgroundColor: Colors.black, + body: _video == null + ? const Center( + child: Text('player starting…', style: TextStyle(color: Colors.white)), + ) + : MaterialDesktopVideoControlsTheme( + normal: controlsTheme, + fullscreen: controlsTheme, + child: Video( + key: _videoKey, + controller: _video!, + subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false), + controls: _sessionControls, + onEnterFullscreen: () async { + _isFullscreen.value = true; + await defaultEnterNativeFullscreen(); + }, + onExitFullscreen: () async { + _isFullscreen.value = false; + await defaultExitNativeFullscreen(); + }, + ), + ), + ), + ); + } + + Widget _sessionControls(VideoState state) { + return Stack( + children: [ + MaterialDesktopVideoControls(state), + _healthOverlay(), + _waitingOverlay(), + _toastsOverlay(), + _sheetOverlay(), + _leaveOverlay(), + Positioned.fill( + child: SignalBuilder( + builder: (context) { + final sheetOpen = _sheetOpen.value; + final confirm = _confirmLeave.value; + return WindowDragLayer( + enabled: !confirm, + exclusions: [ + (s) => Rect.fromLTWH(0, s.height - 120, s.width, 120), + if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height), + ], + ); + }, ), - Positioned( - top: 0, - right: 0, - child: Container( - color: Colors.black.withValues(alpha: 0.6), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.group, color: Colors.white), - onPressed: () { - setState(() => _sheetOpen = !_sheetOpen); - coord.markInput(); - }, + ), + ], + ); + } + + Widget _healthOverlay() { + final coord = SessionState.instance.coordinator; + return 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 kicked = coord.kickReason.value; + final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null; + if (!unwell) return const SizedBox.shrink(); + final color = switch (h.severity) { + HealthSeverity.ok => Colors.green, + HealthSeverity.info => Colors.grey, + HealthSeverity.warn => Colors.orange, + HealthSeverity.error => Colors.red, + }; + final IconData icon; + if (retrying) { + icon = Icons.sync; + } else if (h.severity == HealthSeverity.error) { + icon = Icons.error_outline; + } else if (h.severity == HealthSeverity.warn) { + icon = Icons.warning_amber_outlined; + } else { + icon = Icons.hourglass_empty; + } + final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · '); + final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); + return Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Flexible( + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), + ), + if (retrying) + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () => coord.cancelReconnect(), + child: const Text('Cancel', style: TextStyle(fontSize: 12)), ), + if (canRetry) + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () => coord.retry(), + child: const Text('Retry', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + }, + ), + ); + } + + Widget _waitingOverlay() { + final coord = SessionState.instance.coordinator; + return SignalBuilder( + builder: (context) { + final r = coord.room.value; + final waiting = [ + for (final m in r?.members ?? []) + if (!m.ready) m, + ]; + final show = r != null && r.transport == 'held' && waiting.isNotEmpty; + final selfReady = coord.selfMember.value?.ready ?? false; + return Positioned.fill( + child: Center( + child: Visibility( + visible: show, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: Column( + mainAxisSize: .min, + spacing: 12, + children: [ + IgnorePointer( + child: Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadiusGeometry.circular(20)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: .min, + spacing: 10, + crossAxisAlignment: .center, + children: [ + Text("Waiting for", style: TextTheme.of(context).labelLarge), + Wrap( + alignment: WrapAlignment.center, + spacing: -20, + runSpacing: 8, + children: [ + for (final m in waiting) + Opacity( + opacity: m.stale ? 0.5 : 1.0, + child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96), + ), + ], + ), + ], + ), + ), + ), + ), + if (!selfReady) + FilledButton( + focusNode: _noFocus, + onPressed: () => coord.toggleReady(), + child: const Text("I'm Ready"), + ), ], ), ), ), - 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 kicked = coord.kickReason.value; - final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null; - if (!unwell) return const SizedBox.shrink(); - final color = switch (h.severity) { - HealthSeverity.ok => Colors.green, - HealthSeverity.info => Colors.grey, - HealthSeverity.warn => Colors.orange, - HealthSeverity.error => Colors.red, - }; - final IconData icon; - if (retrying) { - icon = Icons.sync; - } else if (h.severity == HealthSeverity.error) { - icon = Icons.error_outline; - } else if (h.severity == HealthSeverity.warn) { - icon = Icons.warning_amber_outlined; - } else { - icon = Icons.hourglass_empty; - } - final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · '); - final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); - return Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(width: 8), - Flexible( - child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), + ); + }, + ); + } + + Widget _toastsOverlay() { + final coord = SessionState.instance.coordinator; + return Positioned( + right: 12, + top: 56, + child: IgnorePointer( + child: SignalBuilder( + builder: (context) { + final items = coord.toasts.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)], + ); + }, + ), + ), + ); + } + + Widget _sheetOverlay() { + return SignalBuilder( + builder: (context) { + final open = _sheetOpen.value; + return ExcludeFocus( + excluding: !open, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: !open, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: open ? 1 : 0, + child: GestureDetector( + onTap: () => _sheetOpen.value = false, + child: Container(color: Colors.black54), + ), + ), + ), + ), + Positioned( + top: 20, + bottom: 20, + right: 0, + child: IgnorePointer( + ignoring: !open, + child: AnimatedSlide( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + offset: open ? Offset.zero : const Offset(1, 0), + child: SizedBox( + width: 380, + height: double.infinity, + child: _SheetPanel( + chat: _chat, + chatFocus: _chatFocus, + swapLink: _swapLink, + onClose: () => _sheetOpen.value = false, + onLeave: () { + _sheetOpen.value = false; + _confirmLeave.value = true; + }, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _leaveOverlay() { + final session = SessionState.instance; + return SignalBuilder( + builder: (context) { + if (!_confirmLeave.value) return const SizedBox.shrink(); + return Positioned.fill( + child: GestureDetector( + onTap: () => _confirmLeave.value = false, + child: Container( + color: Colors.black54, + child: Center( + child: GestureDetector( + onTap: () {}, + child: AlertDialog( + title: const Text('Leave room?'), + content: const Text('You can rejoin right away.'), + actions: [ + TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')), + TextButton( + onPressed: () { + _confirmLeave.value = false; + session.leave(); + }, + child: const Text('Leave'), ), - if (retrying) - TextButton( - style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () => coord.cancelReconnect(), - child: const Text('Cancel', style: TextStyle(fontSize: 12)), - ), - if (canRetry) - TextButton( - style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), - onPressed: () => coord.retry(), - child: const Text('Retry', style: TextStyle(fontSize: 12)), - ), ], ), - ); - }, - ), - ), - SignalBuilder( - builder: (context) { - final r = coord.room.value; - final waiting = [ - for (final m in r?.members ?? []) - if (!m.ready) m, - ]; - final show = r != null && r.transport == 'held' && waiting.isNotEmpty; - return Positioned.fill( - child: IgnorePointer( - child: Center( - child: Visibility( - visible: show, - maintainState: true, - maintainAnimation: true, - maintainSize: true, - child: Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadiusGeometry.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: .min, - spacing: 10, - crossAxisAlignment: .center, - children: [ - Text("Waiting for", style: TextTheme.of(context).labelLarge), - Wrap( - alignment: WrapAlignment.center, - spacing: -20, - runSpacing: 8, - children: [ - for (final m in waiting) - Opacity( - opacity: m.stale ? 0.5 : 1.0, - child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ), - ); - }, - ), - 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)) _ToastCard(key: ValueKey(t.id), text: t.text)], - ); - }, - ), - ), - Positioned.fill( - child: IgnorePointer( - ignoring: !_sheetOpen, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _sheetOpen ? 1 : 0, - child: GestureDetector( - onTap: () => setState(() => _sheetOpen = false), - child: Container(color: Colors.black54), ), ), ), ), - Positioned( - top: 0, - bottom: 0, - right: 0, - child: IgnorePointer( - ignoring: !_sheetOpen, - child: AnimatedSlide( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - offset: _sheetOpen ? Offset.zero : const Offset(1, 0), - child: SizedBox( - width: 380, - height: double.infinity, - child: _SheetPanel( - chat: _chat, - swapLink: _swapLink, - onClose: () => setState(() => _sheetOpen = false), - onLeave: () => setState(() { - _sheetOpen = false; - _confirmLeave = true; - }), - ), - ), - ), - ), - ), - if (_confirmLeave) - Positioned.fill( - child: GestureDetector( - onTap: () => setState(() => _confirmLeave = false), - child: Container( - color: Colors.black54, - child: Center( - child: GestureDetector( - onTap: () {}, - child: AlertDialog( - title: const Text('Leave room?'), - content: const Text('You can rejoin right away.'), - actions: [ - TextButton(onPressed: () => setState(() => _confirmLeave = false), child: const Text('Cancel')), - TextButton( - onPressed: () { - setState(() => _confirmLeave = false); - session.leave(); - }, - child: const Text('Leave'), - ), - ], - ), - ), - ), - ), - ), - ), - ], - ), + ); + }, ); } } @@ -433,16 +556,17 @@ class _ToastCardState extends State<_ToastCard> { class _SheetPanel extends StatelessWidget { final TextEditingController chat; + final FocusNode chatFocus; final TextEditingController swapLink; final VoidCallback onClose; final VoidCallback onLeave; - const _SheetPanel({required this.chat, required this.swapLink, required this.onClose, required this.onLeave}); + const _SheetPanel({required this.chat, required this.chatFocus, required this.swapLink, required this.onClose, required this.onLeave}); @override Widget build(BuildContext context) { - final session = SessionState.instance; - final coord = session.coordinator; + final coord = SessionState.instance.coordinator; return Material( + borderRadius: BorderRadius.horizontal(left: .circular(20)), elevation: 8, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), @@ -452,19 +576,29 @@ class _SheetPanel extends StatelessWidget { final log = coord.feedLog.value; final theme = Theme.of(context); final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold); - return ListView( + final members = r?.members ?? const []; + final readyCount = members.where((m) => m.ready).length; + final playing = r?.transport == 'playing'; + return Column( children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + _StatusChip(playing: playing, hasRoom: r != null), + ], + ), const SizedBox(height: 2), Text( - 'transport ${r?.transport ?? '…'} · event ${r?.eventId ?? 0} · media ${r?.mediaId ?? 0}', + r == null ? 'connecting…' : '${members.length} member${members.length == 1 ? '' : 's'} · $readyCount ready', style: theme.textTheme.bodySmall, ), ], @@ -474,57 +608,33 @@ class _SheetPanel extends StatelessWidget { ], ), const Divider(height: 20), - Text('Users (${r?.members.length ?? 0})', style: sectionStyle), - const SizedBox(height: 6), - if (r != null) - for (final m in r.members) - Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - dense: true, - leading: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 36), - title: Text(m.id == coord.sync.memberId ? '${m.name} (you)' : m.name), - subtitle: Text('ready=${m.ready} inSet=${m.inSet} buf=${m.buffered.toStringAsFixed(1)} stale=${m.stale}'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () => coord.forceTarget(m.id, !m.ready), - child: Text(m.ready ? 'Force unready' : 'Force ready'), - ), - TextButton( - onPressed: () async { - final ok = await showKickConfirmDialog(context, m.name); - if (ok == true) coord.kick(m.id); - }, - child: const Text('Kick'), - ), - ], - ), - ), - ), + Text('Users (${members.length})', style: sectionStyle), + const SizedBox(height: 2), + for (final m in members) _MemberRow(member: m, isSelf: m.id == coord.sync.memberId), const Divider(height: 20), - Text('Log', style: sectionStyle), + Text('Activity', style: sectionStyle), const SizedBox(height: 6), if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall), - for (final f in log.reversed.take(30)) - Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text('${f.actor} ${f.action} ${f.target ?? ''}', style: theme.textTheme.bodySmall), + Expanded( + child: ListView.builder( + reverse: true, + itemBuilder: (context, index) => _ActivityRow(item: log.reversed.toList()[index]), + itemCount: log.length, ), - const Divider(height: 20), - Text('Chat', style: sectionStyle), - const SizedBox(height: 8), + ), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: chat, + focusNode: chatFocus, decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true), onSubmitted: (v) { coord.sendChat(v); chat.clear(); + chatFocus.requestFocus(); }, ), ), @@ -534,6 +644,7 @@ class _SheetPanel extends StatelessWidget { onPressed: () { coord.sendChat(chat.text); chat.clear(); + chatFocus.requestFocus(); }, ), ], @@ -578,3 +689,248 @@ class _SheetPanel extends StatelessWidget { ); } } + +class _StatusChip extends StatelessWidget { + final bool playing; + final bool hasRoom; + const _StatusChip({required this.playing, required this.hasRoom}); + + @override + Widget build(BuildContext context) { + final Color dot; + final String label; + if (!hasRoom) { + dot = Colors.grey; + label = '…'; + } else if (playing) { + dot = Colors.green; + label = 'Playing'; + } else { + dot = Colors.orange; + label = 'Waiting'; + } + return Container( + decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(shape: BoxShape.circle, color: dot), + ), + const SizedBox(width: 6), + Text(label, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ); + } +} + +class _MemberRow extends StatelessWidget { + final MemberView member; + final bool isSelf; + const _MemberRow({required this.member, required this.isSelf}); + + @override + Widget build(BuildContext context) { + final coord = SessionState.instance.coordinator; + final theme = Theme.of(context); + final status = _memberStatus(member); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + MemberAvatar(avatarBase64: member.avatar, name: member.name, size: 32), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSelf ? '${member.name} (you)' : member.name, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: status.$2), + ), + const SizedBox(width: 6), + Text( + status.$1, + style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)), + ), + ], + ), + ], + ), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 18), + padding: EdgeInsets.zero, + onSelected: (v) async { + if (v == 'force') { + coord.forceTarget(member.id, !member.ready); + } else if (v == 'kick') { + final ok = await showKickConfirmDialog(context, member.name); + if (ok == true) coord.kick(member.id); + } + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'force', child: Text(member.ready ? 'Force unready' : 'Force ready')), + PopupMenuItem( + value: 'kick', + child: Text('Kick', style: TextStyle(color: theme.colorScheme.error)), + ), + ], + ), + ], + ), + ); + } +} + +(String, Color) _memberStatus(MemberView m) { + if (m.stale) return ('Stale', Colors.orange); + if (!m.ready) return ('Not ready', Colors.grey); + if (m.buffered < RelayConstants.minBufferSecs) return ('Buffering ${m.buffered.toStringAsFixed(0)}s', Colors.orange); + return ('Ready', Colors.green); +} + +class _ActivityRow extends StatelessWidget { + final FeedMessage item; + const _ActivityRow({required this.item}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final time = _clock(item.at); + final subtle = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6) ?? Colors.grey; + if (item.action == 'chat') { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.chat_bubble_outline, size: 15, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: '${item.actor} ', + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), + ), + TextSpan(text: item.reason, style: theme.textTheme.bodySmall), + ], + ), + ), + ), + const SizedBox(width: 8), + Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), + ], + ), + ); + } + if (item.actor == 'server') { + final style = _serverFeedStyle(item); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(style.$1, size: 15, color: style.$2), + const SizedBox(width: 8), + Expanded(child: Text(style.$3, style: theme.textTheme.bodySmall)), + const SizedBox(width: 8), + Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), + ], + ), + ); + } + final style = _feedStyle(item, theme); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(style.$1, size: 15, color: style.$2), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: item.actor, + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), + ), + TextSpan(text: ' ${style.$3}', style: theme.textTheme.bodySmall), + ], + ), + ), + ), + const SizedBox(width: 8), + Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)), + ], + ), + ); + } +} + +(IconData, Color, String) _feedStyle(FeedMessage f, ThemeData theme) { + switch (f.action) { + case 'join': + return (Icons.person_add, Colors.green, 'joined'); + case 'leave': + final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}'); + return (Icons.logout, Colors.grey, 'left$reason'); + case 'ready': + return (Icons.check_circle, Colors.green, 'is ready'); + case 'unready': + return (Icons.radio_button_unchecked, Colors.grey, "isn't ready"); + case 'force-ready': + return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready'); + case 'force-unready': + return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready'); + case 'force-play': + return (Icons.play_arrow, Colors.green, 'started playback for everyone'); + case 'force-hold': + return (Icons.pause, Colors.orange, 'held playback for everyone'); + case 'seek': + return (Icons.fast_forward, theme.colorScheme.primary, 'seeked'); + case 'swap': + return (Icons.movie, theme.colorScheme.primary, 'changed the media'); + case 'stale': + return (Icons.cloud_off, Colors.orange, 'lost signal'); + case 'kick': + final reason = f.reason.isEmpty ? '' : ' · ${f.reason}'; + return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason'); + default: + final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; + return (Icons.info_outline, Colors.grey, '${f.action}$target'); + } +} + +(IconData, Color, String) _serverFeedStyle(FeedMessage f) { + switch (f.action) { + case 'resume': + return (Icons.play_arrow, Colors.green, 'Playback resumed'); + default: + return (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness'); + } +} + +String _clock(DateTime at) { + final l = at.toLocal(); + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(l.hour)}:${two(l.minute)}'; +} diff --git a/apps/desktop/lib/src/screens/welcome_screen.dart b/apps/desktop/lib/src/screens/welcome_screen.dart index fb298d6..a87f647 100644 --- a/apps/desktop/lib/src/screens/welcome_screen.dart +++ b/apps/desktop/lib/src/screens/welcome_screen.dart @@ -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 { ], ), ), - ), - ); + ); } } diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index c5b4102..0383ee8 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -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); } diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index 2f31d61..d065605 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -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 { diff --git a/apps/desktop/lib/src/widgets/avatar_pipeline.dart b/apps/desktop/lib/src/widgets/avatar_pipeline.dart index a382031..f3697e1 100644 --- a/apps/desktop/lib/src/widgets/avatar_pipeline.dart +++ b/apps/desktop/lib/src/widgets/avatar_pipeline.dart @@ -9,36 +9,37 @@ Future 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 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); } diff --git a/apps/desktop/lib/src/widgets/member_avatar.dart b/apps/desktop/lib/src/widgets/member_avatar.dart index 85555f8..266ed8f 100644 --- a/apps/desktop/lib/src/widgets/member_avatar.dart +++ b/apps/desktop/lib/src/widgets/member_avatar.dart @@ -9,12 +9,7 @@ class MemberAvatar extends StatelessWidget { static final Map _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), + ), + ); } diff --git a/apps/desktop/lib/src/widgets/user_section.dart b/apps/desktop/lib/src/widgets/user_section.dart index e10fa19..8d33253 100644 --- a/apps/desktop/lib/src/widgets/user_section.dart +++ b/apps/desktop/lib/src/widgets/user_section.dart @@ -86,8 +86,7 @@ class _ConnectProfileCardState extends State { 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 { @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 { 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 { 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(); }, diff --git a/apps/desktop/lib/src/widgets/window_buttons.dart b/apps/desktop/lib/src/widgets/window_buttons.dart new file mode 100644 index 0000000..7903f49 --- /dev/null +++ b/apps/desktop/lib/src/widgets/window_buttons.dart @@ -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 createState() => _WindowButtonsState(); +} + +class _WindowButtonsState extends State 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 _toggleMaximize() async { + if (widget.onToggleFullscreen != null) { + widget.onToggleFullscreen!(); + return; + } + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); + } else { + await windowManager.maximize(); + } + } + + Future _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, + ), + ); + } +} diff --git a/apps/desktop/lib/src/widgets/window_chrome_bar.dart b/apps/desktop/lib/src/widgets/window_chrome_bar.dart new file mode 100644 index 0000000..0b71f7a --- /dev/null +++ b/apps/desktop/lib/src/widgets/window_chrome_bar.dart @@ -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(), + ], + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/widgets/window_drag_layer.dart b/apps/desktop/lib/src/widgets/window_drag_layer.dart new file mode 100644 index 0000000..6e0ac1e --- /dev/null +++ b/apps/desktop/lib/src/widgets/window_drag_layer.dart @@ -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 exclusions; + const WindowDragLayer({ + super.key, + this.enabled = true, + this.exclusions = const [], + }); + + @override + State createState() => _WindowDragLayerState(); +} + +class _WindowDragLayerState extends State { + 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(), + ); + } +} diff --git a/apps/desktop/linux/flutter/generated_plugin_registrant.cc b/apps/desktop/linux/flutter/generated_plugin_registrant.cc index e4b9cf8..e14e0d3 100644 --- a/apps/desktop/linux/flutter/generated_plugin_registrant.cc +++ b/apps/desktop/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include 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); } diff --git a/apps/desktop/linux/flutter/generated_plugins.cmake b/apps/desktop/linux/flutter/generated_plugins.cmake index 04c38ab..2c152b9 100644 --- a/apps/desktop/linux/flutter/generated_plugins.cmake +++ b/apps/desktop/linux/flutter/generated_plugins.cmake @@ -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 diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index 9093fba..d49fc48 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -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" diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index a9cbfd6..66495db 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -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 diff --git a/apps/desktop/windows/flutter/generated_plugin_registrant.cc b/apps/desktop/windows/flutter/generated_plugin_registrant.cc index e402a65..19fd3c6 100644 --- a/apps/desktop/windows/flutter/generated_plugin_registrant.cc +++ b/apps/desktop/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,16 @@ #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); MediaKitVideoPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/apps/desktop/windows/flutter/generated_plugins.cmake b/apps/desktop/windows/flutter/generated_plugins.cmake index e0c93a1..84c3843 100644 --- a/apps/desktop/windows/flutter/generated_plugins.cmake +++ b/apps/desktop/windows/flutter/generated_plugins.cmake @@ -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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index d8f95c5..f57f709 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -45,6 +45,10 @@ force-hold, kick, join, leave, stale, swap, seek, pause, resume, chat — every one carries actor + server timestamp. Force-play-all is `force` with `target:"all",ready:true`; anyone not ready Spaces back. Sounds key off feed actions. +Chat dual delivery: the server emits both `{"t":"chat","text","from"}` +(render this one — it carries the text) and a textless `feed` chat +entry (housekeeping echo). Clients must render exactly one row per +send; the reference client drops the textless feed entry. ## Membership set (kills join-cascade + ghost-quorum) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c2636e7..913ca46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -60,10 +60,9 @@ join/reject logs in `room_hub.dart`); Cancel aborts a pending connect (`SyncClient.disconnect` completes the welcome future); first-join failure shows the dialog and stops (auto-reconnect only after an established session drops). -Open exits (manual, user-driven): remove bisect flags in -`apps/desktop/lib/app.dart`; 2-client LAN soak; 1.4 GB real-file test -(user resupplies URL, never paste api_key into files); -cache-exhaustion long outage. +Open exits (manual, user-driven): bisect flags REMOVED 09-12; +2-client LAN soak + 1.4 GB real-file test user-reports-done 09-12 +(details pending). Still open: cache-exhaustion long outage. ## Phase 5 — Resilience (PARTIAL)