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
+1147
-881
No files matched your search
+71
-15
@@ -4,8 +4,10 @@ Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening
|
|||||||
+ UI redesigns done, all green (core 42, server 40, desktop 17).
|
+ UI redesigns done, all green (core 42, server 40, desktop 17).
|
||||||
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
|
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
|
||||||
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
|
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
|
||||||
flags (legacy/minimal/nosheet) still in app.dart — remove once
|
screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes
|
||||||
stable. Internet test PASSED (WAN port 6969, 09-12). Tonight's
|
session straight to SessionScreen. Internet test PASSED (WAN port
|
||||||
|
6969, 09-12); user reports 2-client LAN soak + 1.4 GB real-file
|
||||||
|
test also done 09-12 (outcomes not yet recorded below). Tonight's
|
||||||
fixes: server logging wired into app (`initLogging` + hello logs),
|
fixes: server logging wired into app (`initLogging` + hello logs),
|
||||||
Cancel aborts pending connect, first-join failure no longer
|
Cancel aborts pending connect, first-join failure no longer
|
||||||
retry-loops. Git repo INITIALIZED (commit 427d851, root
|
retry-loops. Git repo INITIALIZED (commit 427d851, root
|
||||||
@@ -144,10 +146,14 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
|
|||||||
(intents only, zero local transport buttons), center avatars-only
|
(intents only, zero local transport buttons), center avatars-only
|
||||||
64 px, slide-in toasts, animated right sheet (users/log/chat/swap/
|
64 px, slide-in toasts, animated right sheet (users/log/chat/swap/
|
||||||
Leave w/ confirm), health pill only-when-unwell, ESC closes/
|
Leave w/ confirm), health pill only-when-unwell, ESC closes/
|
||||||
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC,
|
asks-leave, global focus-proof keys (Space/Tab/arrows/ESC/F,
|
||||||
350 ms throttle, ignored while typing), single media field
|
350 ms throttle, ignored while typing; Tab traversal disabled on
|
||||||
(parseMediaInput + tests), avatar pipeline (128 px JPEG, tap to
|
the session screen 09-12 — game-style, Tab toggles the sheet), single media field
|
||||||
pick, no path fields), Discord-like corner profile (connect only;
|
(parseMediaInput + tests), avatar pipeline (128 px PNG as of
|
||||||
|
09-12 uncommitted change, was JPEG — NOTE: server still
|
||||||
|
enforces 64 KiB in session.dart vs pipeline's 512 KiB check,
|
||||||
|
mismatch open, see commit), tap to
|
||||||
|
pick, no path fields), Discord-like corner profile (connect only;
|
||||||
welcome is title+Host/Join; edits apply next join — no rename
|
welcome is title+Host/Join; edits apply next join — no rename
|
||||||
intent on the wire), dialogs for terminal states.
|
intent on the wire), dialogs for terminal states.
|
||||||
- UX decisions locked along the way: Space flips self-ready only;
|
- UX decisions locked along the way: Space flips self-ready only;
|
||||||
@@ -199,7 +205,12 @@ Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop
|
|||||||
14. New `MediaDesktopVideoControlsThemeData` per build is fine
|
14. New `MediaDesktopVideoControlsThemeData` per build is fine
|
||||||
(stock guards subscriptions) but wasteful; leave it.
|
(stock guards subscriptions) but wasteful; leave it.
|
||||||
15. Env overrides for mpv experiments (no rebuild to switch):
|
15. Env overrides for mpv experiments (no rebuild to switch):
|
||||||
SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no).
|
SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no).
|
||||||
|
16. Hidden-but-mounted overlays keep focus: closing the sheet left
|
||||||
|
focus on an offstage button, so Space fell through (350 ms
|
||||||
|
throttle hole) into the hidden control. Fix pattern: ExcludeFocus
|
||||||
|
on the closed subtree + unfocus-on-close effect. Same reason new
|
||||||
|
overlay buttons reuse the shared no-focus node.
|
||||||
|
|
||||||
## 7. Sounds (deferred, wiring ready)
|
## 7. Sounds (deferred, wiring ready)
|
||||||
|
|
||||||
@@ -225,17 +236,17 @@ over internet (WAN 2-client test, port 6969, late 09-12) — CLOSED.
|
|||||||
RULE: never unmount GPU-textured widgets on a transport-gated
|
RULE: never unmount GPU-textured widgets on a transport-gated
|
||||||
branch; keep mounted + skip paint. Bisect screens
|
branch; keep mounted + skip paint. Bisect screens
|
||||||
(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart)
|
(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart)
|
||||||
still wired — delete on approval. session_screen.dart may still
|
DELETED 09-12 (commit tracks this); no commented bisect lines
|
||||||
hold the user's commented bisect lines — restore or drop.
|
remain in session_screen.dart.
|
||||||
|
|
||||||
## 9. Next (ordered, updated 09-12 late session)
|
## 9. Next (ordered, updated 09-12 late session)
|
||||||
|
|
||||||
1. §8 black screen — CLOSED (fix verified incl. over internet).
|
1. §8 black screen — CLOSED (fix verified incl. over internet).
|
||||||
2. Remove bisect flags (legacy/minimal/nosheet) in app.dart.
|
2. Remove bisect flags — DONE 09-12 (files deleted, app.dart
|
||||||
3. 2-client LAN soak (manual, user-driven; WAN test passed, LAN
|
routes session directly; tree analyzes clean).
|
||||||
still untested).
|
3. 2-client LAN soak — user reports DONE 09-12 (details pending).
|
||||||
4. 1.4 GB real-file media test (user resupplies Jellyfin URL;
|
4. 1.4 GB real-file media test — user reports DONE 09-12
|
||||||
contains api_key — NEVER paste into files).
|
(details pending; api_key must never land in files).
|
||||||
5. Cache-exhaustion long outage via shaper --kill-after (Phase 5
|
5. Cache-exhaustion long outage via shaper --kill-after (Phase 5
|
||||||
chaos assertion, never run).
|
chaos assertion, never run).
|
||||||
6. Sounds swap-in (§7) after audition.
|
6. Sounds swap-in (§7) after audition.
|
||||||
@@ -245,6 +256,51 @@ hold the user's commented bisect lines — restore or drop.
|
|||||||
8. `/health` is unauthenticated and broadcasts the full media URL
|
8. `/health` is unauthenticated and broadcasts the full media URL
|
||||||
(incl. api_key when the link carries one) — acceptable on LAN,
|
(incl. api_key when the link carries one) — acceptable on LAN,
|
||||||
noted now that 6969 faces the internet.
|
noted now that 6969 faces the internet.
|
||||||
|
9. FIXED 09-12: auto-unready fired while passively watching —
|
||||||
|
the 10-min check keyed off `_lastInput`, refreshed only by
|
||||||
|
explicit actions. Steady playback now counts as activity:
|
||||||
|
warn + auto-unready are skipped while room transport is
|
||||||
|
PLAYING (`session_coordinator.dart` idle block). HELD-and-idle
|
||||||
|
still unreadies after 10 min.
|
||||||
|
10. FIXED 09-12 (second attempt — first theory was wrong): one chat
|
||||||
|
send rendered as TWO log rows. Root cause is server dual
|
||||||
|
delivery, not double-send: `broadcastChat`
|
||||||
|
(`room_hub.dart:69`) emits both a `ChatMessage` (with text,
|
||||||
|
converted to a feed entry client-side) and a textless `feed`
|
||||||
|
entry, and the client logged both. The blank-guard in
|
||||||
|
`sendChat` was defense against a phantom. Real fix: client
|
||||||
|
drops textless `chat` feed entries (`sync_client.dart` feed
|
||||||
|
case) — one send, one row, one toast, one sfx. No wire
|
||||||
|
change, still v4. Chat field also keeps focus after send
|
||||||
|
(dedicated FocusNode, re-requested on submit/button).
|
||||||
|
11. KNOWN (non-breaking, fix later): seeks feel jumpy
|
||||||
|
(forward-back-forward) — suspect the seek-land retry
|
||||||
|
(`_checkSeekLanded`, `session_coordinator.dart:429`)
|
||||||
|
re-applying SeekAction against the 0.5 s deadband / 2 s
|
||||||
|
debounce and the 1 Hz room echo. Needs a live repro with a
|
||||||
|
timeline log before touching.
|
||||||
Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
|
Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
|
||||||
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv
|
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv
|
||||||
instance. No git repo — ask before creating one.
|
instance. Git repo at project root (init 09-12); commit code + docs
|
||||||
|
together; prefer FEW commits (one per topic/session, not per fix)
|
||||||
|
over bisect-granularity — user decision 09-12.
|
||||||
|
|
||||||
|
## 10. Window chrome — frameless, mpv-style (09-12, VERIFIED by user)
|
||||||
|
|
||||||
|
Native title bar gone everywhere (`window_manager ^0.5.2`,
|
||||||
|
`TitleBarStyle.hidden` in main.dart GUI path only, headless
|
||||||
|
untouched). New screens MUST bring their own drag + min/max/close
|
||||||
|
or they ship immovable — reuse `widgets/window_buttons.dart` +
|
||||||
|
`widgets/window_chrome_bar.dart` (welcome/connect pattern).
|
||||||
|
Session: drag is `widgets/window_drag_layer.dart`, topmost raw-
|
||||||
|
pointer observer inside the controls stack (a GestureDetector
|
||||||
|
below media_kit's overlay LOSES the arena — do not revert to
|
||||||
|
that); dead zones: bottom 120 px (seek stays exact), open sheet,
|
||||||
|
leave dialog. `modifyVolumeOnScroll: false` is load-bearing for
|
||||||
|
drag — side effect: volume is speaker-button-only, no scroll-
|
||||||
|
volume. Maximize button = media_kit fullscreen toggle in
|
||||||
|
session (no OS maximize there); NO aspect-ratio lock by user
|
||||||
|
decision (custom UI lives outside the video frame). Close =
|
||||||
|
graceful leave + stopServer, then window close. Min size
|
||||||
|
160x90, no max. Verified on KDE Wayland (Arch): bar-drag,
|
||||||
|
video-drag, fullscreen, free resize all good.
|
||||||
@@ -1,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(
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
|
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||||
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
|
child: DragToMoveArea(
|
||||||
|
child: AppBar(
|
||||||
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
|
||||||
|
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
|
||||||
|
actions: const [WindowButtons()],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
alignment: AlignmentGeometry.center,
|
alignment: AlignmentGeometry.center,
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
|
||||||
|
|
||||||
const _legacyControls = MaterialDesktopVideoControlsThemeData(
|
|
||||||
playAndPauseOnTap: false,
|
|
||||||
keyboardShortcuts: {},
|
|
||||||
bottomButtonBar: [
|
|
||||||
MaterialDesktopVolumeButton(),
|
|
||||||
MaterialDesktopPositionIndicator(),
|
|
||||||
Spacer(),
|
|
||||||
MaterialDesktopFullscreenButton(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
class LegacySessionScreen extends StatefulWidget {
|
|
||||||
const LegacySessionScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LegacySessionScreen> createState() => _LegacySessionScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LegacySessionScreenState extends State<LegacySessionScreen> {
|
|
||||||
VideoController? _video;
|
|
||||||
final _focus = FocusNode();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_video = VideoController(
|
|
||||||
SessionState.instance.coordinator.playback.player);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focus.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
|
||||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
|
||||||
SessionState.instance.coordinator.toggleReady();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final session = SessionState.instance;
|
|
||||||
final coord = session.coordinator;
|
|
||||||
return Focus(
|
|
||||||
focusNode: _focus,
|
|
||||||
autofocus: true,
|
|
||||||
onKeyEvent: _onKey,
|
|
||||||
child: Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final h = coord.health.value;
|
|
||||||
final r = coord.room.value;
|
|
||||||
return Text('Room · ${r?.transport ?? '…'} · ${h.message}');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.logout),
|
|
||||||
onPressed: () => session.leave(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final h = coord.health.value;
|
|
||||||
final starve = coord.starving.value;
|
|
||||||
final text = [
|
|
||||||
h.message,
|
|
||||||
if (starve != null) starve,
|
|
||||||
].join(' · ');
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Text(text),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
Expanded(
|
|
||||||
child: _video == null
|
|
||||||
? const Center(child: Text('player starting…'))
|
|
||||||
: MaterialDesktopVideoControlsTheme(
|
|
||||||
normal: _legacyControls,
|
|
||||||
fullscreen: _legacyControls,
|
|
||||||
child: Video(controller: _video!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final r = coord.room.value;
|
|
||||||
if (r == null) return const SizedBox.shrink();
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
for (final m in r.members)
|
|
||||||
Chip(
|
|
||||||
avatar: CircleAvatar(
|
|
||||||
child: Text(m.name.isEmpty
|
|
||||||
? '?'
|
|
||||||
: m.name[0].toUpperCase()),
|
|
||||||
),
|
|
||||||
label: Text(m.name),
|
|
||||||
backgroundColor: m.ready
|
|
||||||
? Colors.green.withValues(alpha: 0.3)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final ready =
|
|
||||||
coord.selfMember.value?.ready ?? false;
|
|
||||||
return ElevatedButton(
|
|
||||||
onPressed: () => coord.toggleReady(),
|
|
||||||
child: Text(ready ? 'Ready ✓' : 'Not ready'),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: () => coord.forcePlayAll(),
|
|
||||||
child: const Text('Force play all'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
|
||||||
|
|
||||||
const _minimalControls = MaterialDesktopVideoControlsThemeData(
|
|
||||||
playAndPauseOnTap: false,
|
|
||||||
keyboardShortcuts: {},
|
|
||||||
bottomButtonBar: [
|
|
||||||
MaterialDesktopVolumeButton(),
|
|
||||||
_ReadyButton(),
|
|
||||||
_MarkAllButton(),
|
|
||||||
MaterialDesktopPositionIndicator(),
|
|
||||||
Spacer(),
|
|
||||||
MaterialDesktopFullscreenButton(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
class MinimalSessionScreen extends StatefulWidget {
|
|
||||||
const MinimalSessionScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MinimalSessionScreen> createState() => _MinimalSessionScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MinimalSessionScreenState extends State<MinimalSessionScreen> {
|
|
||||||
VideoController? _video;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_video = VideoController(
|
|
||||||
SessionState.instance.coordinator.playback.player);
|
|
||||||
HardwareKeyboard.instance.addHandler(_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
HardwareKeyboard.instance.removeHandler(_key);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _key(KeyEvent event) {
|
|
||||||
if (event is! KeyDownEvent) return false;
|
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
|
||||||
SessionState.instance.coordinator.toggleReady();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: _video == null
|
|
||||||
? const Center(child: Text('player starting…'))
|
|
||||||
: MaterialDesktopVideoControlsTheme(
|
|
||||||
normal: _minimalControls,
|
|
||||||
fullscreen: _minimalControls,
|
|
||||||
child: Video(controller: _video!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Positioned(
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Text('MINIMAL',
|
|
||||||
style: TextStyle(color: Colors.red, fontSize: 12)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReadyButton extends StatelessWidget {
|
|
||||||
const _ReadyButton();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final ready = SessionState
|
|
||||||
.instance.coordinator.selfMember.value?.ready ??
|
|
||||||
false;
|
|
||||||
return TextButton(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: () =>
|
|
||||||
SessionState.instance.coordinator.toggleReady(),
|
|
||||||
child: Text(ready ? 'Ready ✓' : 'Not ready',
|
|
||||||
style: const TextStyle(fontSize: 12)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MarkAllButton extends StatelessWidget {
|
|
||||||
const _MarkAllButton();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextButton(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: () =>
|
|
||||||
SessionState.instance.coordinator.forcePlayAll(),
|
|
||||||
child: const Text('Mark everyone ready',
|
|
||||||
style: TextStyle(fontSize: 12)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
|
|
||||||
import '../session/health.dart';
|
|
||||||
import '../state/session_state.dart';
|
|
||||||
import '../widgets/member_avatar.dart';
|
|
||||||
|
|
||||||
const _nosheetControls = MaterialDesktopVideoControlsThemeData(
|
|
||||||
playAndPauseOnTap: false,
|
|
||||||
keyboardShortcuts: {},
|
|
||||||
visibleOnMount: true,
|
|
||||||
hideMouseOnControlsRemoval: false,
|
|
||||||
controlsHoverDuration: Duration(hours: 24),
|
|
||||||
bottomButtonBar: [
|
|
||||||
MaterialDesktopVolumeButton(),
|
|
||||||
_ReadyButton(),
|
|
||||||
_MarkAllButton(),
|
|
||||||
MaterialDesktopPositionIndicator(),
|
|
||||||
Spacer(),
|
|
||||||
MaterialDesktopFullscreenButton(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
class NosheetSessionScreen extends StatefulWidget {
|
|
||||||
const NosheetSessionScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NosheetSessionScreen> createState() => _NosheetSessionScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NosheetSessionScreenState extends State<NosheetSessionScreen> {
|
|
||||||
VideoController? _video;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_video = VideoController(
|
|
||||||
SessionState.instance.coordinator.playback.player);
|
|
||||||
HardwareKeyboard.instance.addHandler(_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
HardwareKeyboard.instance.removeHandler(_key);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _key(KeyEvent event) {
|
|
||||||
if (event is! KeyDownEvent) return false;
|
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
|
||||||
SessionState.instance.coordinator.toggleReady();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final coord = SessionState.instance.coordinator;
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: _video == null
|
|
||||||
? const Center(child: Text('player starting…'))
|
|
||||||
: MaterialDesktopVideoControlsTheme(
|
|
||||||
normal: _nosheetControls,
|
|
||||||
fullscreen: _nosheetControls,
|
|
||||||
child: Video(controller: _video!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Positioned(
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Text('NOSHEET',
|
|
||||||
style: TextStyle(color: Colors.red, fontSize: 12)),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 12,
|
|
||||||
left: 12,
|
|
||||||
child: SignalBuilder(builder: (context) {
|
|
||||||
final h = coord.health.value;
|
|
||||||
final starve = coord.starving.value;
|
|
||||||
final retrying = coord.reconnecting.value;
|
|
||||||
final unwell = h.severity == HealthSeverity.warn ||
|
|
||||||
h.severity == HealthSeverity.error ||
|
|
||||||
retrying ||
|
|
||||||
starve != null;
|
|
||||||
if (!unwell) return const SizedBox.shrink();
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.65),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Text(
|
|
||||||
[h.message, if (starve != null) starve].join(' · '),
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
SignalBuilder(builder: (context) {
|
|
||||||
final r = coord.room.value;
|
|
||||||
if (r == null || r.transport != 'held') {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final waiting = [
|
|
||||||
for (final m in r.members)
|
|
||||||
if (!m.ready) m
|
|
||||||
];
|
|
||||||
if (waiting.isEmpty) return const SizedBox.shrink();
|
|
||||||
return Positioned.fill(
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Center(
|
|
||||||
child: Wrap(
|
|
||||||
spacing: -20,
|
|
||||||
children: [
|
|
||||||
for (final m in waiting)
|
|
||||||
Opacity(
|
|
||||||
opacity: m.stale ? 0.5 : 1.0,
|
|
||||||
child: MemberAvatar(
|
|
||||||
avatarBase64: m.avatar,
|
|
||||||
name: m.name,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
Positioned(
|
|
||||||
right: 12,
|
|
||||||
top: 56,
|
|
||||||
child: SignalBuilder(builder: (context) {
|
|
||||||
final items = coord.toasts.value;
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
for (final t in items.reversed.take(5))
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 8),
|
|
||||||
child: Text(t.text),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReadyButton extends StatelessWidget {
|
|
||||||
const _ReadyButton();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final ready = SessionState
|
|
||||||
.instance.coordinator.selfMember.value?.ready ??
|
|
||||||
false;
|
|
||||||
return TextButton(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: () =>
|
|
||||||
SessionState.instance.coordinator.toggleReady(),
|
|
||||||
child: Text(ready ? 'Ready ✓' : 'Not ready',
|
|
||||||
style: const TextStyle(fontSize: 12)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MarkAllButton extends StatelessWidget {
|
|
||||||
const _MarkAllButton();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextButton(
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
onPressed: () =>
|
|
||||||
SessionState.instance.coordinator.forcePlayAll(),
|
|
||||||
child: const Text('Mark everyone ready',
|
|
||||||
style: TextStyle(fontSize: 12)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,16 +1,32 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
import '../widgets/window_chrome_bar.dart';
|
||||||
|
|
||||||
class WelcomeScreen extends StatelessWidget {
|
class WelcomeScreen extends StatelessWidget {
|
||||||
const WelcomeScreen({super.key});
|
const WelcomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
WindowChromeBar(title: 'Watch Together'),
|
||||||
|
Expanded(child: _WelcomeBody()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WelcomeBody extends StatelessWidget {
|
||||||
|
const _WelcomeBody();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
return Scaffold(
|
return Center(
|
||||||
body: Center(
|
child: ConstrainedBox(
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -44,7 +60,6 @@ class WelcomeScreen extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,24 +386,29 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
final idleSecs = now.difference(_lastInput).inSeconds;
|
final idleSecs = now.difference(_lastInput).inSeconds;
|
||||||
if (idleSecs >= 8 * 60 && !_warnedIdle) {
|
// Steady playback counts as activity: only warn/unready when HELD.
|
||||||
_warnedIdle = true;
|
// While the room is PLAYING the ready flag gates nothing, so an
|
||||||
_toastId++;
|
// idle viewer must never lose it mid-watch.
|
||||||
toasts.value = [
|
if (room.peek()?.transport != 'playing') {
|
||||||
...toasts.peek(),
|
if (idleSecs >= 8 * 60 && !_warnedIdle) {
|
||||||
ToastItem(
|
_warnedIdle = true;
|
||||||
id: _toastId,
|
_toastId++;
|
||||||
text: 'idle — auto-unready in 2 min unless you press Space',
|
toasts.value = [
|
||||||
at: now)
|
...toasts.peek(),
|
||||||
];
|
ToastItem(
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
id: _toastId,
|
||||||
}
|
text: 'idle — auto-unready in 2 min unless you press Space',
|
||||||
if (idleSecs >= 10 * 60) {
|
at: now)
|
||||||
final self = selfMember.peek();
|
];
|
||||||
if (self != null && self.ready) {
|
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||||
_lastReady = false;
|
}
|
||||||
sync.sendReady(false, source: 'auto');
|
if (idleSecs >= 10 * 60) {
|
||||||
markInput();
|
final self = selfMember.peek();
|
||||||
|
if (self != null && self.ready) {
|
||||||
|
_lastReady = false;
|
||||||
|
sync.sendReady(false, source: 'auto');
|
||||||
|
markInput();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,6 +487,10 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void sendChat(String text) {
|
void sendChat(String text) {
|
||||||
|
// Enter in the chat field can fire both onSubmitted and the focused
|
||||||
|
// send button's onPressed; the first send clears the field, so the
|
||||||
|
// second arrives empty. Drop blanks so at most one message ships.
|
||||||
|
if (text.trim().isEmpty) return;
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendChat(text);
|
sync.sendChat(text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -55,11 +48,11 @@ class MemberAvatar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _fallback() => CircleAvatar(
|
Widget _fallback() => CircleAvatar(
|
||||||
radius: size / 2,
|
radius: size / 2,
|
||||||
backgroundColor: Colors.grey.shade800,
|
backgroundColor: Colors.grey.shade800,
|
||||||
child: Text(
|
child: Text(
|
||||||
name.isEmpty ? '?' : name[0].toUpperCase(),
|
name.isEmpty ? '?' : name[0].toUpperCase(),
|
||||||
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
|
style: TextStyle(fontSize: size * 0.4, color: Colors.white),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,8 +86,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
|||||||
return;
|
return;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error =
|
setState(() => _error = 'could not read that image — pick a PNG, JPG or WebP photo');
|
||||||
'could not read that image — pick a PNG, JPG or WebP photo');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_avatarPath = path;
|
_avatarPath = path;
|
||||||
@@ -102,25 +101,25 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Column(
|
||||||
|
crossAxisAlignment: .center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
ClipOval(
|
||||||
onTap: _pickAvatar,
|
clipBehavior: .antiAlias,
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: 'Change avatar',
|
message: 'Change avatar',
|
||||||
child: CircleAvatar(
|
child: Stack(
|
||||||
radius: 22,
|
children: [
|
||||||
backgroundColor: Colors.grey.shade800,
|
CircleAvatar(
|
||||||
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
|
radius: 64,
|
||||||
child: _preview != null
|
backgroundColor: Colors.transparent,
|
||||||
? null
|
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
|
||||||
: _picking
|
child: _preview != null
|
||||||
? const SizedBox(
|
? null
|
||||||
width: 20,
|
: _picking
|
||||||
height: 20,
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: ListenableBuilder(
|
: ListenableBuilder(
|
||||||
listenable: widget.name,
|
listenable: widget.name,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
@@ -128,10 +127,17 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
|||||||
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
|
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Material(
|
||||||
|
type: .transparency,
|
||||||
|
child: InkWell(onTap: _pickAvatar),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 180,
|
width: 180,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -140,10 +146,7 @@ class _ConnectProfileCardState extends State<ConnectProfileCard> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: widget.name,
|
controller: widget.name,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Display name', isDense: true),
|
||||||
labelText: 'Display name',
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
SessionState.instance.lastForm.name = widget.name.text.trim();
|
SessionState.instance.lastForm.name = widget.name.text.trim();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
|
class WindowButtons extends StatefulWidget {
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
final bool fullscreen;
|
||||||
|
final VoidCallback? onToggleFullscreen;
|
||||||
|
const WindowButtons({
|
||||||
|
super.key,
|
||||||
|
this.focusNode,
|
||||||
|
this.fullscreen = false,
|
||||||
|
this.onToggleFullscreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WindowButtons> createState() => _WindowButtonsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||||
|
bool _maximized = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
windowManager.addListener(this);
|
||||||
|
windowManager.isMaximized().then((v) {
|
||||||
|
if (mounted) setState(() => _maximized = v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
windowManager.removeListener(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowMaximize() {
|
||||||
|
if (mounted) setState(() => _maximized = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onWindowUnmaximize() {
|
||||||
|
if (mounted) setState(() => _maximized = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleMaximize() async {
|
||||||
|
if (widget.onToggleFullscreen != null) {
|
||||||
|
widget.onToggleFullscreen!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await windowManager.isMaximized()) {
|
||||||
|
await windowManager.unmaximize();
|
||||||
|
} else {
|
||||||
|
await windowManager.maximize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _close() async {
|
||||||
|
try {
|
||||||
|
await SessionState.instance.coordinator.leave();
|
||||||
|
await SessionState.instance.stopServer();
|
||||||
|
} catch (_) {}
|
||||||
|
await windowManager.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final focus = widget.focusNode;
|
||||||
|
final restored = widget.fullscreen || _maximized;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_WinBtn(
|
||||||
|
icon: Icons.remove,
|
||||||
|
tooltip: 'Minimize',
|
||||||
|
focusNode: focus,
|
||||||
|
onPressed: () => windowManager.minimize(),
|
||||||
|
),
|
||||||
|
_WinBtn(
|
||||||
|
icon: restored ? Icons.filter_none : Icons.crop_square,
|
||||||
|
tooltip: restored ? 'Restore' : 'Maximize',
|
||||||
|
focusNode: focus,
|
||||||
|
onPressed: _toggleMaximize,
|
||||||
|
),
|
||||||
|
_WinBtn(
|
||||||
|
icon: Icons.close,
|
||||||
|
tooltip: 'Close',
|
||||||
|
focusNode: focus,
|
||||||
|
hoverColor: Colors.red,
|
||||||
|
onPressed: _close,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WinBtn extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
final Color? hoverColor;
|
||||||
|
const _WinBtn({
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
this.focusNode,
|
||||||
|
this.hoverColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 32,
|
||||||
|
child: IconButton(
|
||||||
|
focusNode: focusNode,
|
||||||
|
tooltip: tooltip,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints.tightFor(width: 40, height: 32),
|
||||||
|
iconSize: 15,
|
||||||
|
splashRadius: 12,
|
||||||
|
hoverColor: (hoverColor ?? Colors.white).withValues(alpha: 0.12),
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
icon: Icon(icon, size: 15),
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
import 'window_buttons.dart';
|
||||||
|
|
||||||
|
class WindowChromeBar extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget? leading;
|
||||||
|
const WindowChromeBar({super.key, required this.title, this.leading});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DragToMoveArea(
|
||||||
|
child: Container(
|
||||||
|
height: 36,
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (leading != null) leading!,
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const WindowButtons(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
typedef ExclusionZone = Rect Function(Size);
|
||||||
|
|
||||||
|
class WindowDragLayer extends StatefulWidget {
|
||||||
|
final bool enabled;
|
||||||
|
final List<ExclusionZone> exclusions;
|
||||||
|
const WindowDragLayer({
|
||||||
|
super.key,
|
||||||
|
this.enabled = true,
|
||||||
|
this.exclusions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WindowDragLayer> createState() => _WindowDragLayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WindowDragLayerState extends State<WindowDragLayer> {
|
||||||
|
int? _armedPointer;
|
||||||
|
Offset? _downPos;
|
||||||
|
bool _dragging = false;
|
||||||
|
|
||||||
|
void _onDown(PointerDownEvent e) {
|
||||||
|
if (!widget.enabled) return;
|
||||||
|
_armedPointer = e.pointer;
|
||||||
|
_downPos = e.position;
|
||||||
|
_dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onMove(PointerMoveEvent e) {
|
||||||
|
if (e.pointer != _armedPointer || _dragging) return;
|
||||||
|
final down = _downPos;
|
||||||
|
if (down == null) return;
|
||||||
|
if ((e.position - down).distance < kTouchSlop) return;
|
||||||
|
final size = context.size;
|
||||||
|
if (size != null) {
|
||||||
|
for (final zone in widget.exclusions) {
|
||||||
|
if (zone(size).contains(down)) {
|
||||||
|
_armedPointer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_dragging = true;
|
||||||
|
windowManager.startDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEnd(PointerEvent e) {
|
||||||
|
if (e.pointer == _armedPointer) {
|
||||||
|
_armedPointer = null;
|
||||||
|
_dragging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Listener(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPointerDown: _onDown,
|
||||||
|
onPointerMove: _onMove,
|
||||||
|
onPointerUp: _onEnd,
|
||||||
|
onPointerCancel: _onEnd,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin.h>
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
|
#include <window_manager/window_manager_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||||
@@ -16,4 +18,10 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
|
g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
|
||||||
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
|
media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||||
|
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||||
|
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
media_kit_libs_linux
|
media_kit_libs_linux
|
||||||
media_kit_video
|
media_kit_video
|
||||||
|
screen_retriever_linux
|
||||||
|
window_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -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