Desktop session batch 09-12: frameless chrome, bisect removal, chat/idle fixes
Frameless (window_manager, hidden titlebar): chrome bar on welcome, drag-to-move appbar + window buttons on connect, drag layer + window buttons in session controls; min size 160x90; volume-scroll off for drag; session maximize = fullscreen toggle. Verified on KDE Wayland. Delete legacy/minimal/nosheet bisect screens; session routes direct (black-screen fix verified incl. WAN). Avatar: pipeline now 128px aspect-kept PNG (was cropped JPEG); profile card centered 64px hover-to-change; member avatar drops ResizeImage downscale. NOTE open: pipeline 512 KiB check vs server 64 KiB enforcement mismatch. Fixes: idle warn/unready skipped while PLAYING; sendChat drops blanks; one chat row per send (drop textless server feed echo); chat field keeps focus after send. Playback libass true; sdk floor 3.13. Handoff 9-11: 9+10 fixed, 11 jumpy seek still open. Docs: handoff/roadmap/protocol record all of the above; few-commits preference going forward.
This commit is contained in:
1 parent
3303d88a5b
commit
ff154c5030
26 files changed
+951
-685
No files matched your search
+69
-13
@@ -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,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(),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user