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
+951 -685

No files matched your search

+69 -13
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,9 +146,13 @@ 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
(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; 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.
@@ -200,6 +206,11 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
(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(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: DragToMoveArea(
child: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome), 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')), 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)),
);
}
}
+464 -108
View File
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart'; import '../session/health.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/dialogs.dart'; import '../widgets/dialogs.dart';
import '../widgets/member_avatar.dart'; import '../widgets/member_avatar.dart';
import '../widgets/window_buttons.dart';
import '../widgets/window_drag_layer.dart';
class SessionScreen extends StatefulWidget { class SessionScreen extends StatefulWidget {
const SessionScreen({super.key}); const SessionScreen({super.key});
@@ -17,12 +20,16 @@ class SessionScreen extends StatefulWidget {
class _SessionScreenState extends State<SessionScreen> { class _SessionScreenState extends State<SessionScreen> {
VideoController? _video; VideoController? _video;
bool _sheetOpen = false; final _videoKey = GlobalKey<VideoState>();
bool _confirmLeave = false; final _sheetOpen = signal(false);
final _confirmLeave = signal(false);
final _isFullscreen = signal(false);
DateTime? _lastKeyAt; DateTime? _lastKeyAt;
final _chat = TextEditingController(); final _chat = TextEditingController();
final _chatFocus = FocusNode();
final _swapLink = TextEditingController(); final _swapLink = TextEditingController();
late final void Function() _stopKickWatch; late final void Function() _stopKickWatch;
late final void Function() _stopSheetWatch;
late final FocusNode _noFocus; late final FocusNode _noFocus;
@override @override
@@ -37,13 +44,30 @@ class _SessionScreenState extends State<SessionScreen> {
session.route.value = AppRoute.connect; 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 @override
void dispose() { void dispose() {
HardwareKeyboard.instance.removeHandler(_globalKey); HardwareKeyboard.instance.removeHandler(_globalKey);
_stopKickWatch(); _stopKickWatch();
_stopSheetWatch();
_chat.dispose(); _chat.dispose();
_chatFocus.dispose();
_swapLink.dispose(); _swapLink.dispose();
_noFocus.dispose(); _noFocus.dispose();
super.dispose(); super.dispose();
@@ -74,9 +98,8 @@ class _SessionScreenState extends State<SessionScreen> {
return true; return true;
} }
if (event.logicalKey == LogicalKeyboardKey.tab) { if (event.logicalKey == LogicalKeyboardKey.tab) {
if (!mounted) return false;
_lastKeyAt = now; _lastKeyAt = now;
setState(() => _sheetOpen = !_sheetOpen); _sheetOpen.value = !_sheetOpen.peek();
coord.markInput(); coord.markInput();
return true; return true;
} }
@@ -91,13 +114,33 @@ class _SessionScreenState extends State<SessionScreen> {
return true; return true;
} }
if (event.logicalKey == LogicalKeyboardKey.escape) { if (event.logicalKey == LogicalKeyboardKey.escape) {
if (!mounted) return false; if (_sheetOpen.peek()) {
if (_sheetOpen) { _sheetOpen.value = false;
setState(() => _sheetOpen = false); return true;
}
if (_confirmLeave.peek()) {
_confirmLeave.value = false;
return true;
}
if (_isFullscreen.peek()) {
if (mounted) {
Navigator.of(context, rootNavigator: true).maybePop();
}
return true; return true;
} }
_lastKeyAt = now; _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 true;
} }
return false; return false;
@@ -105,14 +148,27 @@ class _SessionScreenState extends State<SessionScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance; final coord = SessionState.instance.coordinator;
final coord = session.coordinator;
final controlsTheme = MaterialDesktopVideoControlsThemeData( final controlsTheme = MaterialDesktopVideoControlsThemeData(
playAndPauseOnTap: false, playAndPauseOnTap: false,
modifyVolumeOnScroll: false,
keyboardShortcuts: {}, keyboardShortcuts: {},
visibleOnMount: true, visibleOnMount: true,
hideMouseOnControlsRemoval: false, hideMouseOnControlsRemoval: true,
controlsHoverDuration: const Duration(hours: 24), 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: [ bottomButtonBar: [
const MaterialDesktopVolumeButton(), const MaterialDesktopVolumeButton(),
_ReadyControlButton(focusNode: _noFocus), _ReadyControlButton(focusNode: _noFocus),
@@ -122,42 +178,67 @@ class _SessionScreenState extends State<SessionScreen> {
const MaterialDesktopFullscreenButton(), const MaterialDesktopFullscreenButton(),
], ],
); );
return Scaffold( return FocusTraversalGroup(
descendantsAreTraversable: false,
child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: _video == null
children: [
Positioned.fill(
child: _video == null
? const Center( ? const Center(
child: Text('player starting…', style: TextStyle(color: Colors.white)), child: Text('player starting…', style: TextStyle(color: Colors.white)),
) )
: MaterialDesktopVideoControlsTheme( : MaterialDesktopVideoControlsTheme(
normal: controlsTheme, normal: controlsTheme,
fullscreen: controlsTheme, fullscreen: controlsTheme,
child: Video(controller: _video!), child: Video(
), key: _videoKey,
), controller: _video!,
Positioned( subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
top: 0, controls: _sessionControls,
right: 0, onEnterFullscreen: () async {
child: Container( _isFullscreen.value = true;
color: Colors.black.withValues(alpha: 0.6), await defaultEnterNativeFullscreen();
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), },
child: Row( onExitFullscreen: () async {
mainAxisSize: MainAxisSize.min, _isFullscreen.value = false;
children: [ await defaultExitNativeFullscreen();
IconButton(
icon: const Icon(Icons.group, color: Colors.white),
onPressed: () {
setState(() => _sheetOpen = !_sheetOpen);
coord.markInput();
}, },
), ),
),
),
);
}
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( );
}
Widget _healthOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned(
top: 12, top: 12,
left: 12, left: 12,
child: SignalBuilder( child: SignalBuilder(
@@ -214,8 +295,12 @@ class _SessionScreenState extends State<SessionScreen> {
); );
}, },
), ),
), );
SignalBuilder( }
Widget _waitingOverlay() {
final coord = SessionState.instance.coordinator;
return SignalBuilder(
builder: (context) { builder: (context) {
final r = coord.room.value; final r = coord.room.value;
final waiting = [ final waiting = [
@@ -223,19 +308,21 @@ class _SessionScreenState extends State<SessionScreen> {
if (!m.ready) m, if (!m.ready) m,
]; ];
final show = r != null && r.transport == 'held' && waiting.isNotEmpty; final show = r != null && r.transport == 'held' && waiting.isNotEmpty;
final selfReady = coord.selfMember.value?.ready ?? false;
return Positioned.fill( return Positioned.fill(
child: IgnorePointer(
child: Center( child: Center(
child: Visibility( child: Visibility(
visible: show, visible: show,
maintainState: true, maintainState: true,
maintainAnimation: true, maintainAnimation: true,
maintainSize: true, maintainSize: true,
child: Column(
mainAxisSize: .min,
spacing: 12,
children: [
IgnorePointer(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadiusGeometry.circular(20)),
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadiusGeometry.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
@@ -261,14 +348,27 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
), ),
), ),
if (!selfReady)
FilledButton(
focusNode: _noFocus,
onPressed: () => coord.toggleReady(),
child: const Text("I'm Ready"),
),
],
),
), ),
), ),
); );
}, },
), );
Positioned( }
Widget _toastsOverlay() {
final coord = SessionState.instance.coordinator;
return Positioned(
right: 12, right: 12,
top: 56, top: 56,
child: IgnorePointer(
child: SignalBuilder( child: SignalBuilder(
builder: (context) { builder: (context) {
final items = coord.toasts.value; final items = coord.toasts.value;
@@ -279,49 +379,73 @@ class _SessionScreenState extends State<SessionScreen> {
}, },
), ),
), ),
);
}
Widget _sheetOverlay() {
return SignalBuilder(
builder: (context) {
final open = _sheetOpen.value;
return ExcludeFocus(
excluding: !open,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: !_sheetOpen, ignoring: !open,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
opacity: _sheetOpen ? 1 : 0, opacity: open ? 1 : 0,
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _sheetOpen = false), onTap: () => _sheetOpen.value = false,
child: Container(color: Colors.black54), child: Container(color: Colors.black54),
), ),
), ),
), ),
), ),
Positioned( Positioned(
top: 0, top: 20,
bottom: 0, bottom: 20,
right: 0, right: 0,
child: IgnorePointer( child: IgnorePointer(
ignoring: !_sheetOpen, ignoring: !open,
child: AnimatedSlide( child: AnimatedSlide(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOut, curve: Curves.easeOut,
offset: _sheetOpen ? Offset.zero : const Offset(1, 0), offset: open ? Offset.zero : const Offset(1, 0),
child: SizedBox( child: SizedBox(
width: 380, width: 380,
height: double.infinity, height: double.infinity,
child: _SheetPanel( child: _SheetPanel(
chat: _chat, chat: _chat,
chatFocus: _chatFocus,
swapLink: _swapLink, swapLink: _swapLink,
onClose: () => setState(() => _sheetOpen = false), onClose: () => _sheetOpen.value = false,
onLeave: () => setState(() { onLeave: () {
_sheetOpen = false; _sheetOpen.value = false;
_confirmLeave = true; _confirmLeave.value = true;
}), },
), ),
), ),
), ),
), ),
), ),
if (_confirmLeave) ],
Positioned.fill( ),
);
},
);
}
Widget _leaveOverlay() {
final session = SessionState.instance;
return SignalBuilder(
builder: (context) {
if (!_confirmLeave.value) return const SizedBox.shrink();
return Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _confirmLeave = false), onTap: () => _confirmLeave.value = false,
child: Container( child: Container(
color: Colors.black54, color: Colors.black54,
child: Center( child: Center(
@@ -331,10 +455,10 @@ class _SessionScreenState extends State<SessionScreen> {
title: const Text('Leave room?'), title: const Text('Leave room?'),
content: const Text('You can rejoin right away.'), content: const Text('You can rejoin right away.'),
actions: [ actions: [
TextButton(onPressed: () => setState(() => _confirmLeave = false), child: const Text('Cancel')), TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
setState(() => _confirmLeave = false); _confirmLeave.value = false;
session.leave(); session.leave();
}, },
child: const Text('Leave'), child: const Text('Leave'),
@@ -345,9 +469,8 @@ class _SessionScreenState extends State<SessionScreen> {
), ),
), ),
), ),
), );
], },
),
); );
} }
} }
@@ -433,16 +556,17 @@ class _ToastCardState extends State<_ToastCard> {
class _SheetPanel extends StatelessWidget { class _SheetPanel extends StatelessWidget {
final TextEditingController chat; final TextEditingController chat;
final FocusNode chatFocus;
final TextEditingController swapLink; final TextEditingController swapLink;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback onLeave; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = SessionState.instance; final coord = SessionState.instance.coordinator;
final coord = session.coordinator;
return Material( return Material(
borderRadius: BorderRadius.horizontal(left: .circular(20)),
elevation: 8, elevation: 8,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
@@ -452,19 +576,29 @@ class _SheetPanel extends StatelessWidget {
final log = coord.feedLog.value; final log = coord.feedLog.value;
final theme = Theme.of(context); final theme = Theme.of(context);
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold); final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
return ListView( final members = r?.members ?? const <MemberView>[];
final readyCount = members.where((m) => m.ready).length;
final playing = r?.transport == 'playing';
return Column(
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
_StatusChip(playing: playing, hasRoom: r != null),
],
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( 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, style: theme.textTheme.bodySmall,
), ),
], ],
@@ -474,57 +608,33 @@ class _SheetPanel extends StatelessWidget {
], ],
), ),
const Divider(height: 20), const Divider(height: 20),
Text('Users (${r?.members.length ?? 0})', style: sectionStyle), Text('Users (${members.length})', style: sectionStyle),
const SizedBox(height: 6), const SizedBox(height: 2),
if (r != null) for (final m in members) _MemberRow(member: m, isSelf: m.id == coord.sync.memberId),
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'),
),
],
),
),
),
const Divider(height: 20), const Divider(height: 20),
Text('Log', style: sectionStyle), Text('Activity', style: sectionStyle),
const SizedBox(height: 6), const SizedBox(height: 6),
if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall), if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall),
for (final f in log.reversed.take(30)) Expanded(
Padding( child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 2), reverse: true,
child: Text('${f.actor} ${f.action} ${f.target ?? ''}', style: theme.textTheme.bodySmall), itemBuilder: (context, index) => _ActivityRow(item: log.reversed.toList()[index]),
itemCount: log.length,
), ),
const Divider(height: 20), ),
Text('Chat', style: sectionStyle), const SizedBox(height: 12),
const SizedBox(height: 8),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
controller: chat, controller: chat,
focusNode: chatFocus,
decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true), decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true),
onSubmitted: (v) { onSubmitted: (v) {
coord.sendChat(v); coord.sendChat(v);
chat.clear(); chat.clear();
chatFocus.requestFocus();
}, },
), ),
), ),
@@ -534,6 +644,7 @@ class _SheetPanel extends StatelessWidget {
onPressed: () { onPressed: () {
coord.sendChat(chat.text); coord.sendChat(chat.text);
chat.clear(); 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<String>(
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)}';
}
@@ -1,15 +1,31 @@
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(
@@ -44,7 +60,6 @@ class WelcomeScreen extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }
@@ -386,6 +386,10 @@ class SessionCoordinator {
} }
} }
final idleSecs = now.difference(_lastInput).inSeconds; final idleSecs = now.difference(_lastInput).inSeconds;
// 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) { if (idleSecs >= 8 * 60 && !_warnedIdle) {
_warnedIdle = true; _warnedIdle = true;
_toastId++; _toastId++;
@@ -407,6 +411,7 @@ class SessionCoordinator {
} }
} }
} }
}
void _detectUserSeek(RoomBroadcast r) { void _detectUserSeek(RoomBroadcast r) {
if (_openUrl == null) { if (_openUrl == null) {
@@ -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);
} }
@@ -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,
+21 -18
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(
radius: 64,
backgroundColor: Colors.transparent,
backgroundImage: _preview != null ? MemoryImage(_preview!) : null, backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
child: _preview != null child: _preview != null
? null ? null
: _picking : _picking
? const SizedBox( ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: ListenableBuilder( : ListenableBuilder(
listenable: widget.name, listenable: widget.name,
builder: (context, _) { builder: (context, _) {
@@ -129,9 +128,16 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
}, },
), ),
), ),
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)