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

+71 -15
View File
@@ -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). + UI redesigns done, all green (core 42, server 40, desktop 17).
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race; Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
flags (legacy/minimal/nosheet) still in app.dart — remove once screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes
stable. Internet test PASSED (WAN port 6969, 09-12). Tonight's 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), fixes: server logging wired into app (`initLogging` + hello logs),
Cancel aborts pending connect, first-join failure no longer Cancel aborts pending connect, first-join failure no longer
retry-loops. Git repo INITIALIZED (commit 427d851, root 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 (intents only, zero local transport buttons), center avatars-only
64 px, slide-in toasts, animated right sheet (users/log/chat/swap/ 64 px, slide-in toasts, animated right sheet (users/log/chat/swap/
Leave w/ confirm), health pill only-when-unwell, ESC closes/ Leave w/ confirm), health pill only-when-unwell, ESC closes/
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC, asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F,
350 ms throttle, ignored while typing), single media field 350 ms throttle, ignored while typing; Tab traversal disabled on
(parseMediaInput + tests), avatar pipeline (128 px JPEG, tap to the session screen 09-12 — game-style, Tab toggles the sheet), single media field
pick, no path fields), Discord-like corner profile (connect only; (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 welcome is title+Host/Join; edits apply next join — no rename
intent on the wire), dialogs for terminal states. intent on the wire), dialogs for terminal states.
- UX decisions locked along the way: Space flips self-ready only; - 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 14. New `MediaDesktopVideoControlsThemeData` per build is fine
(stock guards subscriptions) but wasteful; leave it. (stock guards subscriptions) but wasteful; leave it.
15. Env overrides for mpv experiments (no rebuild to switch): 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) ## 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 RULE: never unmount GPU-textured widgets on a transport-gated
branch; keep mounted + skip paint. Bisect screens branch; keep mounted + skip paint. Bisect screens
(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart) (legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart)
still wired — delete on approval. session_screen.dart may still DELETED 09-12 (commit tracks this); no commented bisect lines
hold the user's commented bisect lines — restore or drop. remain in session_screen.dart.
## 9. Next (ordered, updated 09-12 late session) ## 9. Next (ordered, updated 09-12 late session)
1. §8 black screen — CLOSED (fix verified incl. over internet). 1. §8 black screen — CLOSED (fix verified incl. over internet).
2. Remove bisect flags (legacy/minimal/nosheet) in app.dart. 2. Remove bisect flags — DONE 09-12 (files deleted, app.dart
3. 2-client LAN soak (manual, user-driven; WAN test passed, LAN routes session directly; tree analyzes clean).
still untested). 3. 2-client LAN soak — user reports DONE 09-12 (details pending).
4. 1.4 GB real-file media test (user resupplies Jellyfin URL; 4. 1.4 GB real-file media test — user reports DONE 09-12
contains api_key — NEVER paste into files). (details pending; api_key must never land in files).
5. Cache-exhaustion long outage via shaper --kill-after (Phase 5 5. Cache-exhaustion long outage via shaper --kill-after (Phase 5
chaos assertion, never run). chaos assertion, never run).
6. Sounds swap-in (§7) after audition. 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 8. `/health` is unauthenticated and broadcasts the full media URL
(incl. api_key when the link carries one) — acceptable on LAN, (incl. api_key when the link carries one) — acceptable on LAN,
noted now that 6969 faces the internet. 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, Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv ~/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.
+1 -21
View File
@@ -1,25 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'src/screens/connect_screen.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/session_screen.dart';
import 'src/screens/welcome_screen.dart'; import 'src/screens/welcome_screen.dart';
import 'src/state/session_state.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 { class WatchTogetherApp extends StatelessWidget {
const WatchTogetherApp({super.key}); const WatchTogetherApp({super.key});
@@ -34,13 +20,7 @@ class WatchTogetherApp extends StatelessWidget {
return switch (session.route.value) { return switch (session.route.value) {
AppRoute.welcome => const WelcomeScreen(), AppRoute.welcome => const WelcomeScreen(),
AppRoute.connect => const ConnectScreen(), AppRoute.connect => const ConnectScreen(),
AppRoute.session => useMinimalSession AppRoute.session => const SessionScreen(),
? const MinimalSessionScreen()
: useNosheetSession
? const NosheetSessionScreen()
: useLegacySession
? const LegacySessionScreen()
: const SessionScreen(),
}; };
}, },
), ),
+14
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
@@ -32,6 +33,19 @@ Future<void> main(List<String> args) async {
), ),
); );
WidgetsFlutterBinding.ensureInitialized(); 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(); await Hive.initFlutter();
try { try {
await SessionState.instance.loadForm(); await SessionState.instance.loadForm();
@@ -13,6 +13,7 @@ class PlaybackController {
player = Player( player = Player(
configuration: PlayerConfiguration( configuration: PlayerConfiguration(
bufferSize: 64 * 1024 * 1024, bufferSize: 64 * 1024 * 1024,
libass: true,
vo: Platform.environment['SYNC_RELAY_VO'], vo: Platform.environment['SYNC_RELAY_VO'],
)); ));
_ready = true; _ready = true;
@@ -3,12 +3,14 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../session/session_coordinator.dart'; import '../session/session_coordinator.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/avatar_pipeline.dart'; import '../widgets/avatar_pipeline.dart';
import '../widgets/media_input.dart'; import '../widgets/media_input.dart';
import '../widgets/user_section.dart'; import '../widgets/user_section.dart';
import '../widgets/window_buttons.dart';
class ConnectScreen extends StatefulWidget { class ConnectScreen extends StatefulWidget {
const ConnectScreen({super.key}); const ConnectScreen({super.key});
@@ -226,9 +228,15 @@ class _ConnectScreenState extends State<ConnectScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance; final session = SessionState.instance;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: PreferredSize(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome), preferredSize: const Size.fromHeight(kToolbarHeight),
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')), 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( body: Stack(
alignment: AlignmentGeometry.center, 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 'package:flutter/material.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/window_chrome_bar.dart';
class WelcomeScreen extends StatelessWidget { class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance; final session = SessionState.instance;
return Scaffold( return Center(
body: Center( child: ConstrainedBox(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -44,7 +60,6 @@ class WelcomeScreen extends StatelessWidget {
], ],
), ),
), ),
), );
);
} }
} }
@@ -386,24 +386,29 @@ class SessionCoordinator {
} }
} }
final idleSecs = now.difference(_lastInput).inSeconds; final idleSecs = now.difference(_lastInput).inSeconds;
if (idleSecs >= 8 * 60 && !_warnedIdle) { // Steady playback counts as activity: only warn/unready when HELD.
_warnedIdle = true; // While the room is PLAYING the ready flag gates nothing, so an
_toastId++; // idle viewer must never lose it mid-watch.
toasts.value = [ if (room.peek()?.transport != 'playing') {
...toasts.peek(), if (idleSecs >= 8 * 60 && !_warnedIdle) {
ToastItem( _warnedIdle = true;
id: _toastId, _toastId++;
text: 'idle — auto-unready in 2 min unless you press Space', toasts.value = [
at: now) ...toasts.peek(),
]; ToastItem(
unawaited(sfx.play(const SfxCue(SfxKind.warn))); id: _toastId,
} text: 'idle — auto-unready in 2 min unless you press Space',
if (idleSecs >= 10 * 60) { at: now)
final self = selfMember.peek(); ];
if (self != null && self.ready) { unawaited(sfx.play(const SfxCue(SfxKind.warn)));
_lastReady = false; }
sync.sendReady(false, source: 'auto'); if (idleSecs >= 10 * 60) {
markInput(); 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) { 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(); markInput();
sync.sendChat(text); sync.sendChat(text);
} }
+6 -1
View File
@@ -121,7 +121,12 @@ class SyncClient {
} catch (_) {} } catch (_) {}
case 'feed': case 'feed':
try { 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 (_) {} } catch (_) {}
case 'kicked': case 'kicked':
try { try {
@@ -9,36 +9,37 @@ Future<String> encodeAvatar(String path) async {
if (trimmed.isEmpty) return ''; if (trimmed.isEmpty) return '';
final file = File(trimmed); final file = File(trimmed);
if (!await file.exists()) { if (!await file.exists()) {
throw const FormatException( throw const FormatException('avatar file not found — check the path or pick again with Browse');
'avatar file not found — check the path or pick again with Browse');
} }
late final List<int> raw; late final List<int> raw;
try { try {
raw = await file.readAsBytes(); raw = await file.readAsBytes();
} catch (_) { } catch (_) {
throw const FormatException( throw const FormatException('could not read that avatar file — pick it again with Browse');
'could not read that avatar file — pick it again with Browse');
} }
if (raw.isEmpty) { if (raw.isEmpty) {
throw const FormatException( throw const FormatException('that avatar file is empty — pick a PNG, JPG or WebP photo');
'that avatar file is empty — pick a PNG, JPG or WebP photo');
} }
final decoded = img.decodeImage(Uint8List.fromList(raw)); final decoded = img.decodeImage(Uint8List.fromList(raw));
if (decoded == null) { if (decoded == null) {
throw const FormatException( throw const FormatException('could not read that image — pick a PNG, JPG or WebP photo');
'could not read that image — pick a PNG, JPG or WebP photo');
} }
final side = // final side = decoded.width < decoded.height ? decoded.width : decoded.height;
decoded.width < decoded.height ? decoded.width : decoded.height; // final x = (decoded.width - side) ~/ 2;
final x = (decoded.width - side) ~/ 2; // final y = (decoded.height - side) ~/ 2;
final y = (decoded.height - side) ~/ 2; // final cropped = img.copyCrop(decoded, x: x, y: y, width: side, height: side);
final cropped = final resized = img.copyResize(
img.copyCrop(decoded, x: x, y: y, width: side, height: side); decoded,
final resized = img.copyResize(cropped, width: 128, height: 128); width: 128,
final jpg = img.encodeJpg(resized, quality: 85); height: 128,
if (jpg.length > 64 * 1024) { maintainAspect: true,
throw const FormatException( // backgroundColor: img.ColorFloat16.rgba(0, 0, 0, 0),
'that photo is still too large after shrinking — pick a smaller one'); 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 = {}; static final Map<String, MemoryImage> _images = {};
const MemberAvatar({ const MemberAvatar({super.key, required this.avatarBase64, required this.name, this.size = 40});
super.key,
required this.avatarBase64,
required this.name,
this.size = 40,
});
static MemoryImage? _providerFor(String raw) { static MemoryImage? _providerFor(String raw) {
if (raw.isEmpty) return null; if (raw.isEmpty) return null;
@@ -33,15 +28,13 @@ class MemberAvatar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final image = _providerFor(avatarBase64); final image = _providerFor(avatarBase64);
final decoration = BoxDecoration( final decoration = BoxDecoration(shape: BoxShape.circle);
shape: BoxShape.circle,
);
if (image != null) { if (image != null) {
return Container( return Container(
decoration: decoration, decoration: decoration,
child: ClipOval( child: ClipOval(
child: Image( child: Image(
image: ResizeImage(height: (size * MediaQuery.of(context).devicePixelRatio).round(), image), image: image,
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -55,11 +48,11 @@ class MemberAvatar extends StatelessWidget {
} }
Widget _fallback() => CircleAvatar( Widget _fallback() => CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
child: Text( child: Text(
name.isEmpty ? '?' : name[0].toUpperCase(), name.isEmpty ? '?' : name[0].toUpperCase(),
style: TextStyle(fontSize: size * 0.4, color: Colors.white), style: TextStyle(fontSize: size * 0.4, color: Colors.white),
), ),
); );
} }
+25 -22
View File
@@ -86,8 +86,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
return; return;
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
setState(() => _error = setState(() => _error = 'could not read that image — pick a PNG, JPG or WebP photo');
'could not read that image — pick a PNG, JPG or WebP photo');
return; return;
} }
_avatarPath = path; _avatarPath = path;
@@ -102,25 +101,25 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
crossAxisAlignment: .center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [ children: [
GestureDetector( ClipOval(
onTap: _pickAvatar, clipBehavior: .antiAlias,
child: Tooltip( child: Tooltip(
message: 'Change avatar', message: 'Change avatar',
child: CircleAvatar( child: Stack(
radius: 22, children: [
backgroundColor: Colors.grey.shade800, CircleAvatar(
backgroundImage: _preview != null ? MemoryImage(_preview!) : null, radius: 64,
child: _preview != null backgroundColor: Colors.transparent,
? null backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
: _picking child: _preview != null
? const SizedBox( ? null
width: 20, : _picking
height: 20, ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
child: CircularProgressIndicator(strokeWidth: 2),
)
: ListenableBuilder( : ListenableBuilder(
listenable: widget.name, listenable: widget.name,
builder: (context, _) { builder: (context, _) {
@@ -128,10 +127,17 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
return Text(t.isEmpty ? '?' : t[0].toUpperCase()); return Text(t.isEmpty ? '?' : t[0].toUpperCase());
}, },
), ),
),
Positioned.fill(
child: Material(
type: .transparency,
child: InkWell(onTap: _pickAvatar),
),
),
],
), ),
), ),
), ),
const SizedBox(width: 12),
SizedBox( SizedBox(
width: 180, width: 180,
child: Column( child: Column(
@@ -140,10 +146,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
children: [ children: [
TextField( TextField(
controller: widget.name, controller: widget.name,
decoration: const InputDecoration( decoration: const InputDecoration(labelText: 'Display name', isDense: true),
labelText: 'Display name',
isDense: true,
),
onChanged: (_) { onChanged: (_) {
SessionState.instance.lastForm.name = widget.name.text.trim(); 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_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_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) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = 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 = g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); 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 list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video
screen_retriever_linux
window_manager
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+49 -1
View File
@@ -608,6 +608,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.6" 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: signals:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -811,6 +851,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.4.0" 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: windows_file_picker:
dependency: transitive dependency: transitive
description: description:
@@ -844,5 +892,5 @@ packages:
source: hosted source: hosted
version: "3.1.4" version: "3.1.4"
sdks: sdks:
dart: ">=3.14.0-0 <4.0.0" dart: ">=3.13.0-0 <4.0.0"
flutter: ">=3.44.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 publish_to: none
version: 0.1.0 version: 0.1.0
environment: environment:
sdk: '>=3.14.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
@@ -22,6 +22,7 @@ dependencies:
hive_ce: ^2.19.3 hive_ce: ^2.19.3
hive_ce_flutter: ^2.3.4 hive_ce_flutter: ^2.3.4
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@@ -8,10 +8,16 @@
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #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 <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) { void RegisterPlugins(flutter::PluginRegistry* registry) {
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
MediaKitVideoPluginCApiRegisterWithRegistrar( MediaKitVideoPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
} }
@@ -5,6 +5,8 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
screen_retriever_windows
window_manager
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+4
View File
@@ -45,6 +45,10 @@ force-hold, kick, join, leave, stale, swap, seek, pause, resume, chat —
every one carries actor + server timestamp. every one carries actor + server timestamp.
Force-play-all is `force` with `target:"all",ready:true`; anyone not Force-play-all is `force` with `target:"all",ready:true`; anyone not
ready Spaces back. Sounds key off feed actions. 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) ## Membership set (kills join-cascade + ghost-quorum)
+3 -4
View File
@@ -60,10 +60,9 @@ join/reject logs in `room_hub.dart`); Cancel aborts a pending
connect (`SyncClient.disconnect` completes the welcome future); connect (`SyncClient.disconnect` completes the welcome future);
first-join failure shows the dialog and stops (auto-reconnect only first-join failure shows the dialog and stops (auto-reconnect only
after an established session drops). after an established session drops).
Open exits (manual, user-driven): remove bisect flags in Open exits (manual, user-driven): bisect flags REMOVED 09-12;
`apps/desktop/lib/app.dart`; 2-client LAN soak; 1.4 GB real-file test 2-client LAN soak + 1.4 GB real-file test user-reports-done 09-12
(user resupplies URL, never paste api_key into files); (details pending). Still open: cache-exhaustion long outage.
cache-exhaustion long outage.
## Phase 5 — Resilience (PARTIAL) ## Phase 5 — Resilience (PARTIAL)