From 427d851a5bf02eb964683931f066633b8875176d Mon Sep 17 00:00:00 2001 From: foxyz Date: Sat, 12 Sep 2026 00:13:43 +0300 Subject: [PATCH] Initial commit: watch-together phases 0-4 + presence hardening, internet-tested --- .github/workflows/ci.yml | 33 + .gitignore | 39 + HANDOFF.md | 238 +++++ README.md | 28 + analysis_options.yaml | 6 + apps/desktop/.gitignore | 48 + apps/desktop/.metadata | 33 + apps/desktop/README.md | 17 + apps/desktop/analysis_options.yaml | 34 + apps/desktop/lib/app.dart | 49 + apps/desktop/lib/main.dart | 50 ++ .../lib/src/playback/playback_controller.dart | 102 +++ .../lib/src/screens/connect_screen.dart | 351 ++++++++ .../src/screens/legacy_session_screen.dart | 151 ++++ .../src/screens/minimal_session_screen.dart | 125 +++ .../src/screens/nosheet_session_screen.dart | 208 +++++ .../lib/src/screens/session_screen.dart | 580 ++++++++++++ .../lib/src/screens/welcome_screen.dart | 50 ++ apps/desktop/lib/src/session/health.dart | 7 + .../lib/src/session/session_coordinator.dart | 603 +++++++++++++ apps/desktop/lib/src/sfx/sfx.dart | 72 ++ apps/desktop/lib/src/state/connect_prefs.dart | 21 + apps/desktop/lib/src/state/session_state.dart | 159 ++++ apps/desktop/lib/src/sync/room_diff.dart | 142 +++ apps/desktop/lib/src/sync/sync_client.dart | 228 +++++ .../lib/src/widgets/avatar_pipeline.dart | 44 + apps/desktop/lib/src/widgets/dialogs.dart | 22 + apps/desktop/lib/src/widgets/media_input.dart | 8 + .../lib/src/widgets/member_avatar.dart | 65 ++ .../desktop/lib/src/widgets/user_section.dart | 163 ++++ apps/desktop/linux/.gitignore | 1 + apps/desktop/linux/CMakeLists.txt | 128 +++ apps/desktop/linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 19 + .../flutter/generated_plugin_registrant.h | 15 + .../linux/flutter/generated_plugins.cmake | 26 + apps/desktop/linux/runner/CMakeLists.txt | 26 + apps/desktop/linux/runner/main.cc | 6 + apps/desktop/linux/runner/my_application.cc | 148 +++ apps/desktop/linux/runner/my_application.h | 21 + apps/desktop/pubspec.lock | 848 ++++++++++++++++++ apps/desktop/pubspec.yaml | 30 + apps/desktop/test/coord_presence_test.dart | 59 ++ apps/desktop/test/media_input_test.dart | 33 + apps/desktop/test/room_diff_test.dart | 159 ++++ apps/desktop/test/sync_playback_e2e_test.dart | 93 ++ apps/desktop/windows/.gitignore | 17 + apps/desktop/windows/CMakeLists.txt | 108 +++ apps/desktop/windows/flutter/CMakeLists.txt | 109 +++ .../flutter/generated_plugin_registrant.cc | 17 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 26 + apps/desktop/windows/runner/CMakeLists.txt | 40 + apps/desktop/windows/runner/Runner.rc | 121 +++ .../desktop/windows/runner/flutter_window.cpp | 71 ++ apps/desktop/windows/runner/flutter_window.h | 33 + apps/desktop/windows/runner/main.cpp | 43 + apps/desktop/windows/runner/resource.h | 16 + .../windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes .../windows/runner/runner.exe.manifest | 14 + apps/desktop/windows/runner/utils.cpp | 69 ++ apps/desktop/windows/runner/utils.h | 19 + apps/desktop/windows/runner/win32_window.cpp | 288 ++++++ apps/desktop/windows/runner/win32_window.h | 102 +++ deploy/watch-together.service | 11 + docs/ARCHITECTURE.md | 77 ++ docs/PROTOCOL.md | 115 +++ docs/ROADMAP.md | 94 ++ docs/mpv-http-contract.md | 70 ++ packages/core/lib/core.dart | 7 + packages/core/lib/src/config.dart | 80 ++ packages/core/lib/src/constants.dart | 21 + packages/core/lib/src/errors.dart | 27 + packages/core/lib/src/logging.dart | 19 + packages/core/lib/src/media_source.dart | 92 ++ packages/core/lib/src/protocol.dart | 390 ++++++++ packages/core/lib/src/sync_engine.dart | 257 ++++++ packages/core/pubspec.lock | 389 ++++++++ packages/core/pubspec.yaml | 14 + packages/core/test/core_test.dart | 94 ++ packages/core/test/flutter_free_test.dart | 20 + packages/core/test/sync_engine_test.dart | 385 ++++++++ packages/server/bin/server.dart | 47 + packages/server/lib/server.dart | 4 + packages/server/lib/src/media_endpoint.dart | 213 +++++ packages/server/lib/src/room_hub.dart | 297 ++++++ packages/server/lib/src/room_server.dart | 113 +++ packages/server/lib/src/session.dart | 95 ++ packages/server/pubspec.lock | 420 +++++++++ packages/server/pubspec.yaml | 17 + packages/server/test/lifecycle_test.dart | 97 ++ packages/server/test/media_endpoint_test.dart | 172 ++++ packages/server/test/room_auth_test.dart | 391 ++++++++ packages/server/test/server_test.dart | 61 ++ packages/server/test/session_test.dart | 115 +++ tool/e2e_check.dart | 134 +++ tool/fake_viewer.dart | 145 +++ tool/pubspec.lock | 219 +++++ tool/pubspec.yaml | 15 + tool/range_logger.dart | 44 + tool/shaper.dart | 81 ++ 101 files changed, 11326 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 HANDOFF.md create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 apps/desktop/.gitignore create mode 100644 apps/desktop/.metadata create mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/analysis_options.yaml create mode 100644 apps/desktop/lib/app.dart create mode 100644 apps/desktop/lib/main.dart create mode 100644 apps/desktop/lib/src/playback/playback_controller.dart create mode 100644 apps/desktop/lib/src/screens/connect_screen.dart create mode 100644 apps/desktop/lib/src/screens/legacy_session_screen.dart create mode 100644 apps/desktop/lib/src/screens/minimal_session_screen.dart create mode 100644 apps/desktop/lib/src/screens/nosheet_session_screen.dart create mode 100644 apps/desktop/lib/src/screens/session_screen.dart create mode 100644 apps/desktop/lib/src/screens/welcome_screen.dart create mode 100644 apps/desktop/lib/src/session/health.dart create mode 100644 apps/desktop/lib/src/session/session_coordinator.dart create mode 100644 apps/desktop/lib/src/sfx/sfx.dart create mode 100644 apps/desktop/lib/src/state/connect_prefs.dart create mode 100644 apps/desktop/lib/src/state/session_state.dart create mode 100644 apps/desktop/lib/src/sync/room_diff.dart create mode 100644 apps/desktop/lib/src/sync/sync_client.dart create mode 100644 apps/desktop/lib/src/widgets/avatar_pipeline.dart create mode 100644 apps/desktop/lib/src/widgets/dialogs.dart create mode 100644 apps/desktop/lib/src/widgets/media_input.dart create mode 100644 apps/desktop/lib/src/widgets/member_avatar.dart create mode 100644 apps/desktop/lib/src/widgets/user_section.dart create mode 100644 apps/desktop/linux/.gitignore create mode 100644 apps/desktop/linux/CMakeLists.txt create mode 100644 apps/desktop/linux/flutter/CMakeLists.txt create mode 100644 apps/desktop/linux/flutter/generated_plugin_registrant.cc create mode 100644 apps/desktop/linux/flutter/generated_plugin_registrant.h create mode 100644 apps/desktop/linux/flutter/generated_plugins.cmake create mode 100644 apps/desktop/linux/runner/CMakeLists.txt create mode 100644 apps/desktop/linux/runner/main.cc create mode 100644 apps/desktop/linux/runner/my_application.cc create mode 100644 apps/desktop/linux/runner/my_application.h create mode 100644 apps/desktop/pubspec.lock create mode 100644 apps/desktop/pubspec.yaml create mode 100644 apps/desktop/test/coord_presence_test.dart create mode 100644 apps/desktop/test/media_input_test.dart create mode 100644 apps/desktop/test/room_diff_test.dart create mode 100644 apps/desktop/test/sync_playback_e2e_test.dart create mode 100644 apps/desktop/windows/.gitignore create mode 100644 apps/desktop/windows/CMakeLists.txt create mode 100644 apps/desktop/windows/flutter/CMakeLists.txt create mode 100644 apps/desktop/windows/flutter/generated_plugin_registrant.cc create mode 100644 apps/desktop/windows/flutter/generated_plugin_registrant.h create mode 100644 apps/desktop/windows/flutter/generated_plugins.cmake create mode 100644 apps/desktop/windows/runner/CMakeLists.txt create mode 100644 apps/desktop/windows/runner/Runner.rc create mode 100644 apps/desktop/windows/runner/flutter_window.cpp create mode 100644 apps/desktop/windows/runner/flutter_window.h create mode 100644 apps/desktop/windows/runner/main.cpp create mode 100644 apps/desktop/windows/runner/resource.h create mode 100644 apps/desktop/windows/runner/resources/app_icon.ico create mode 100644 apps/desktop/windows/runner/runner.exe.manifest create mode 100644 apps/desktop/windows/runner/utils.cpp create mode 100644 apps/desktop/windows/runner/utils.h create mode 100644 apps/desktop/windows/runner/win32_window.cpp create mode 100644 apps/desktop/windows/runner/win32_window.h create mode 100644 deploy/watch-together.service create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/mpv-http-contract.md create mode 100644 packages/core/lib/core.dart create mode 100644 packages/core/lib/src/config.dart create mode 100644 packages/core/lib/src/constants.dart create mode 100644 packages/core/lib/src/errors.dart create mode 100644 packages/core/lib/src/logging.dart create mode 100644 packages/core/lib/src/media_source.dart create mode 100644 packages/core/lib/src/protocol.dart create mode 100644 packages/core/lib/src/sync_engine.dart create mode 100644 packages/core/pubspec.lock create mode 100644 packages/core/pubspec.yaml create mode 100644 packages/core/test/core_test.dart create mode 100644 packages/core/test/flutter_free_test.dart create mode 100644 packages/core/test/sync_engine_test.dart create mode 100644 packages/server/bin/server.dart create mode 100644 packages/server/lib/server.dart create mode 100644 packages/server/lib/src/media_endpoint.dart create mode 100644 packages/server/lib/src/room_hub.dart create mode 100644 packages/server/lib/src/room_server.dart create mode 100644 packages/server/lib/src/session.dart create mode 100644 packages/server/pubspec.lock create mode 100644 packages/server/pubspec.yaml create mode 100644 packages/server/test/lifecycle_test.dart create mode 100644 packages/server/test/media_endpoint_test.dart create mode 100644 packages/server/test/room_auth_test.dart create mode 100644 packages/server/test/server_test.dart create mode 100644 packages/server/test/session_test.dart create mode 100644 tool/e2e_check.dart create mode 100644 tool/fake_viewer.dart create mode 100644 tool/pubspec.lock create mode 100644 tool/pubspec.yaml create mode 100644 tool/range_logger.dart create mode 100644 tool/shaper.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f60dac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci +on: + push: + pull_request: + +jobs: + core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + working-directory: packages/core + - run: dart analyze + working-directory: packages/core + - run: dart test + working-directory: packages/core + + server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + working-directory: packages/server + - run: dart analyze + working-directory: packages/server + - run: dart test + working-directory: packages/server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e29844e --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Dart / Flutter generated — applies at every level of this monorepo +.dart_tool/ +build/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +coverage/ +**/doc/api/ + +# Flutter ephemeral build outputs +**/flutter/ephemeral/ +**/Flutter/GeneratedPluginRegistrant.* +**/ios/Flutter/.last_build_id + +# Build artifacts that should never be committed +*.tar.gz +*.zip +app.*.symbols +app.*.map.json + +# Logs +*.log + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Misc +*.swp +*.pyc diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..726b90f --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,238 @@ +# HANDOFF — sync_relay_project (watch-together app) + +Date: 2026-09-12. Phases 0–4 + presence hardening + UI redesigns done, +all green (core 42, server 40, desktop 17). Black screen (§8) +DIAGNOSED + FIXED (avatar-texture teardown race; Visibility keep- +mounted). Bisect flags (legacy/minimal/nosheet) still in app.dart — +remove once stable. Prior chat may be unreliable; this file plus +`docs/` is the full state. + +## 1. What this is + +Private watch-together desktop app (Windows + Linux). Host serves a file +or shared link from their own machine; 4–8 friends watch in sync. +One binary, three modes: Client (Join), Server+Client (Host, opens +ports, self-plays via loopback), `--headless` (server only, no window). +North star: <1.5 s drift, seeks settle ~2 s, cache rides short drops, +kick kills sync+video, no relay memory growth. + +## 2. Locked architecture (do not reopen without the user) + +- `packages/core`: protocol, sync engine, media abstractions. ZERO Flutter + imports (enforced by test). `packages/server`: Relic-based room + media + server, also zero Flutter imports. `apps/desktop`: the ONE Flutter + binary embedding both. Direct-serve model: host opens ports, clients + open nothing. NO host-upload tunnel (the brief's WSS chunk protocol is + obsolete unless a VPS tier ever needs remote-host upload). +- Server HTTP+WS: **Relic ^1.2.0**. Client WS: **web_socket_channel**. + Server lib also uses **web_socket** for event types. +- PACKAGES ARE NOT LOCKED (user decision 09-11, overrides the old brief + list): pick on merit, no churn where the stack works. Current desktop + additions: media_kit_video + media_kit_libs_video (required), + file_picker (avatar/media picking), hive_ce + hive_ce_flutter + (connect-form persistence), image (avatar shrink). signals + media_kit + + Relic stay (load-bearing). +- Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1. + Reference clone (scratch, may be gone — re-clone shallow): + `git clone --depth 1 https://github.com/Syncplay/syncplay.git /tmp/opencode/syncplay-ref` + Audit already done; conscious deviations: clock never moves from + reports (theirs follows slowest watcher), no pause-on-leave, + force-for-all instead of operator-only setOthersReadiness, and + QUORUM gate (09-12, user found solo-play meaningless): HELD→PLAYING + needs in-set size ≥ 2 unless ≤1 member is fresh — lone watcher plays + alone, second watcher present ⇒ one Space waits. Late joiners outside + the set never block. +- Single room password (one secret, persisted in Hive on the user's own + box — accepted). No op roles (deferred: op flag + second hello + secret). Kick = transient nudge: revoke + close + `kicked` reason, + instant rejoin with fresh token. +- Deferred, do not build: TLS/Caddy, Jellyfin native, auto-updater, + playlist (extension shape reserved in PROTOCOL.md), op roles, sounds + (researched, wired, no assets — see §7). +- Standing rules: health reporting (every failure displays, nothing + stalls silently); proto bump + PROTOCOL.md same-commit on ANY + wire change (still v4 — quorum/timeouts are server policy, no bump). + +## 3. Repo map (root: Flutter/sync_relay_project/) + +- `packages/core/lib/src/`: constants.dart (tuning: seek 300 ms, + drift 5 s, slow 1.5 s @0.95, min_buffer 4 s, heartbeat 1 s, ping 5 s, + avatar 64 KiB, name 32, chat 500; dropAfter lives as RoomEngine ctor + default, now 30 s), errors.dart, logging.dart, config.dart, + protocol.dart (v4 schemas), media_source.dart, sync_engine.dart + (RoomEngine + quorum in evaluate's HELD branch; PLAYING branch + unchanged). +- `packages/server/lib/src/`: session.dart, media_endpoint.dart, + room_hub.dart (forgetSocket, sweepClosedSockets via `isClosed` poll, + closeAllSockets, onError→forgetSocket), room_server.dart (1 Hz + heartbeat: sweepStale + sweepClosedSockets + dropSilentSockets + + broadcast; stop() closes client sockets first). `bin/server.dart`. +- `apps/desktop/lib/src/`: sync/ (sync_client.dart: hello/intents/ + fan-out/match-by-`t`; room_diff.dart: diff + debounce + play gate), + playback/ (media_kit Player, 64 MB cache, pin props, 0.5 s seek + deadband, stop() on leave, SYNC_RELAY_VO/HWDEC env overrides), + session/ (session_coordinator.dart: THE brain — room→player, + reports, scrub/nudge→seek intents incl. HELD, seek-land retry, + idle 8/10 min, reconnect loop, 5 s silence timeout; health.dart), + sfx/ (SfxKind/Cue/Service + NoopSfxService — sounds deferred), + state/ (session_state.dart: route/mode/lastForm/prefs/host/join/ + leave; connect_prefs.dart), screens/ (welcome/connect/session), + widgets/ (user_section.dart: ConnectProfileCard; member_avatar.dart: + THE avatar — memoized, no bg on images, dark ring+fallback; + dialogs.dart: kick confirm; media_input.dart: parseMediaInput). +- `apps/desktop/test/`: room_diff_test (9), media_input_test (6), + sync_playback_e2e_test (quorum: solo Space holds, both ready plays; + needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server + clears + reconnects; needs mpv). +- `tool/`: range_logger, shaper (chaos: KB/s cap, latency, + --kill-after), fake_viewer, e2e_check (sim timeline, still ALL PASS + post-quorum). +- `docs/`: PROTOCOL.md (v4 + quorum + 15/30 s timeouts + isClosed note, + normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md. +- `deploy/`: systemd unit. No git repo anywhere (recommending `git + init` was never approved — ask before creating one; bisection is + currently impossible, which hurt on §8). + +## 4. Protocol v4 + policy (see PROTOCOL.md for normative detail) + +hello→welcome(token, memberId, mediaUrl?, room-state); intents: +ready{ready,source}/force{target:all|memberId,ready}/seek/seek-swap/ +report{position,buffered,eventId}/chat/kick{memberId}. Broadcasts: +room @1 Hz + on change (diff by eventId), feed{actor,action,…} +(toasts/log/sounds), kicked{reason}, err. Media: +GET /m/// (200/206×3 shapes/416/401/403/410/503). +Transport PLAYING/HELD; gate = non-empty set AND all in-set ready + +buffered≥4 s + fresh AND quorum (in-set≥2 unless ≤1 fresh). +Stale >15 s ⇒ out of gate; silent >30 s ⇒ closed + removed. +Closed sockets swept every second; socket errors remove immediately. +Client: 5 s room-silence ⇒ dead (clear room, warn, reconnect loop +1.5 s polite infinite + manual cancel/retry, prior-flag restore). + +## 5. Phase log (all exits met, all green as stated above) + +- P0 scaffold + CI + Flutter-free tests; binary binds/logs/exits. +- P1 Range table byte-exact, real mpv traces, shaper built. 503 tested. +- P2 sessions: secret→128-bit token, 401/403/410 gates, kick kills WS+ + media, byte counters, fake_viewer ALL PASS live, proto→3. +- P3 engine + readiness: engine tests (fake clock), e2e 15/15 sim, + Syncplay audit. Sanity: NaN guards, caps, stale/leave feeds, + 1 Hz flush, token redaction. +- P4 desktop shell→app: SyncClient framing, RoomDiff (eventId diff, + slowdown latch 0.95×/0.1 reset, seek-land check), media_kit wiring + (pinned buffer props, buffered from demuxer), Space=ready, avatars, + toasts+sheet, health display, reconnect loop. Sounds WIRED ONLY + (NoopSfxService) — see §7. +- P5 presence+sync hardening (user-reported, all reproduced live + first): stop() closes sockets; isClosed sweep (~1 s graceful); + onError forget (~2–4 s kill -9, was 56 s ghost); drop 60→30 s; + client silence-timeout + stuck-reconnect-flag fix + ready-future + leak fix; seek race fixed (2 s same-event debounce both + transports, edge-aware detector 1 s guard, 0.5 s apply deadband); + HELD seeks send intents (paused frame moves for all); arrows + nudge ∓10 s; local play gate (in-set+ready); autoplay off; + leave() stops player (was leaking audio); kick confirm; Cancel + connect; connect-form memory→Hive persistence; file/media + Browse buttons. +- P6 UI redesigns (two subagents, disjoint files — repeat this + pattern): full-bleed video + overlays, controls never autohide + (24 h hover) + Ready/Mark-everyone-ready IN the controls bar + (intents only, zero local transport buttons), center avatars-only + 64 px, slide-in toasts, animated right sheet (users/log/chat/swap/ + Leave w/ confirm), health pill only-when-unwell, ESC closes/ + asks-leave, global focus-proof keys (Space/Tab/arrows/ESC, + 350 ms throttle, ignored while typing), single media field + (parseMediaInput + tests), avatar pipeline (128 px JPEG, tap to + pick, no path fields), Discord-like corner profile (connect only; + welcome is title+Host/Join; edits apply next join — no rename + intent on the wire), dialogs for terminal states. +- UX decisions locked along the way: Space flips self-ready only; + free seeks any transport (no flag churn); join catch-up never + halts; idle 8-min warn + 10-min auto-unready; reconnect = re-hello + with flag restore; kick → dialog + connect screen; fixed 1.5 s + reconnect + cancel/retry; GMod toasts (social only) + side sheet; + avatar fallback = initial circle; "waiting for X (n/m ready)". +- Open user questions for later: warning-tone timing; bar width at + minimum window; second-ESC on leave dialog (re-asks vs cancel); + cancel-stops-host-server (kept: idle means no bound port). + +## 6. Gotchas (learned the hard way) + +1. Relic gives Range as TYPED RangeHeader — convert via unit+first. +2. Relic server-side onDone DOES NOT fire for dead peers (proven + live both directions: clean close AND kill -9 both ghosted to + timeout). Presence = isClosed heartbeat sweep + socket-onError + forget + lastSeen timeouts. NEVER depend on onDone. (Docs pattern + confirms CloseReceived is a data event.) +3. Welcome races room broadcasts — match WS messages by `t`, never + by index. +4. web_socket_channel streams are single-subscription — one listener + per socket (test double-listens throw "already been listened to"). +5. `sub.asFuture()` can resolve before `onDone` — poll with + deadlines in tests. +6. Comma-form mpv reconnect flags work here; colon-form rejected + (re-verify via set_property if touched). +7. Numeric literals corrupted once by tooling — verify with rg after + writes touching ports/consts. +8. Tool `bash` `&`-backgrounding is unreliable; use nohup + log file + in its OWN call, poll in later calls, kill via `fuser -k + PORT/tcp` (never `pkill -f`). Chain small calls, never long + `&`-chains (they hang the runner past timeout with zero output). +9. Tests must not assume threat model beyond trust-basis. +10. web_socket_channel 3.x: `channel.ready` errors unhandled on + refused connections — eat it (`unawaited(ch.ready.then(..., + onError: ...))`); stream onError is the reporting path. +11. MediaKit has NO public Player.setProperty — runtime props go via + guarded dynamic call on player.platform; cache size via public + PlayerConfiguration(bufferSize:). +12. NEVER `flutter test`-pump the session screen: media_kit Video + hangs the runner (a hung flutter_tester must be kill -9'd by PID; + pgrep carefully). Sync-path coverage lives in room_diff (pure), + lifecycle (server, raw WS), coord_presence + e2e (real mpv, + no widgets). Deleted session_overlays_test.dart for this reason. +13. No `flutter analyze` unless asked (IDE covers it) — except + deliberate per-change checks; kept clean throughout. +14. New `MediaDesktopVideoControlsThemeData` per build is fine + (stock guards subscriptions) but wasteful; leave it. +15. Env overrides for mpv experiments (no rebuild to switch): + SYNC_RELAY_VO (e.g. xv, gpu, gpu-next), SYNC_RELAY_HWDEC (e.g. no). + +## 7. Sounds (deferred, wiring ready) + +Base: Kenney Interface Sounds (CC0, confirmation_* ladder for +ascending-ready). Alt ladder: ObsydianX Pack 1 (CC0, avoid paid +packs). Soft layer: akx/Notifications (CC0 branch). Playback pick: +flutter_soloud (preload WAV, per-play pitch) — NOT added. Swap-in = +replace NoopSfxService; cues already fire with readyIndex. URLs + +mapping in 09-11 chat (unreliable — re-derive from sources above). + +## 8. Black window while playing (09-12, FIXED) + +Symptom was: room PLAYING ⇒ entire window black (audio + keys +alive, unready restored, zero errors). Cause: the waiting-avatars +center overlay unmounted its GPU textures (avatar Images) exactly +at the HELD→PLAYING flip; teardown racing mpv's frame uploads on +the shared GL context fails silently ⇒ black. Legacy chips never +unmounted and minimal never mounted, which is why they survived. +Fix: center overlay is Visibility(visible + maintainState + +maintainAnimation + maintainSize) — avatars stay mounted, paint +skipped when hidden. Verified by user with full tree. +RULE: never unmount GPU-textured widgets on a transport-gated +branch; keep mounted + skip paint. Bisect screens +(legacy/minimal/nosheet + SYNC_RELAY_UI switch in app.dart) +still wired — delete on approval. session_screen.dart may still +hold the user's commented bisect lines — restore or drop. + +## 9. Next (ordered) + +1. §8 black screen — diagnose with user, then fix. +2. 2-client LAN soak (manual, user-driven). +3. 1.4 GB real-file media test (user resupplies Jellyfin URL; + contains api_key — NEVER paste into files). +4. Cache-exhaustion long outage via shaper --kill-after (Phase 5 + chaos assertion, never run). +5. Internet test: plain TCP works in theory; needs forwarded port + or Tailscale; expect more slowdown engagement on latency. +6. Sounds swap-in (§7) after audition. +Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present, +~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv +instance. No git repo — ask before creating one. diff --git a/README.md b/README.md new file mode 100644 index 0000000..34ce89b --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# sync_relay_project + +Watch-together desktop app (working title). One binary, three modes: + +- **Client** — GUI Join, sync WS + mpv. No server code runs. +- **Server+Client (Host)** — GUI Host starts `packages/server` in-process, + listens on a port, self-connects via loopback. Host must open ports. + Clients never open ports. +- **Headless** — `watch.exe --headless`, same server package, no window. + +v1 order: links support (shared http(s) media URL, Syncplay-style sync) +first, file relay (server-proxied bytes, whitelist case) after. +Jellyfin native + auto-update are deferred as their own undertakings. + +Layout: + +``` +/packages/core protocol, sync engine, messages (pure Dart, no Flutter) +/packages/server room + media HTTP endpoint (pure Dart, no Flutter) +/apps/desktop Flutter app embedding packages/server (the ONE binary) +/tool spikes + harnesses (range_logger, shaper, fake_viewer, e2e_check) +/deploy systemd unit, notes +/docs PROTOCOL.md, ARCHITECTURE.md, mpv-http-contract.md, ROADMAP.md +``` + +Phases + status: `docs/ROADMAP.md`. Session handoff: `HANDOFF.md` (root). + +Enforcement: `core` and `server` must have zero Flutter imports (tested). diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..e8d8a3f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:lints/recommended.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/build/**" diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/apps/desktop/.metadata b/apps/desktop/.metadata new file mode 100644 index 0000000..e41849c --- /dev/null +++ b/apps/desktop/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "e3005e3402d9cfa2043114c8bc53c59d12e9b98e" + channel: "beta" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + base_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + - platform: linux + create_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + base_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + - platform: windows + create_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + base_revision: e3005e3402d9cfa2043114c8bc53c59d12e9b98e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..d99c028 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,17 @@ +# watch_together + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/desktop/analysis_options.yaml b/apps/desktop/analysis_options.yaml new file mode 100644 index 0000000..3ac6de8 --- /dev/null +++ b/apps/desktop/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - windows/** + - linux/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart new file mode 100644 index 0000000..fcc0166 --- /dev/null +++ b/apps/desktop/lib/app.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import 'src/screens/connect_screen.dart'; +import 'src/screens/legacy_session_screen.dart'; +import 'src/screens/minimal_session_screen.dart'; +import 'src/screens/nosheet_session_screen.dart'; +import 'src/screens/session_screen.dart'; +import 'src/screens/welcome_screen.dart'; +import 'src/state/session_state.dart'; + +bool get useLegacySession => + Platform.environment['SYNC_RELAY_UI'] == 'legacy'; + +bool get useMinimalSession => + Platform.environment['SYNC_RELAY_UI'] == 'minimal'; + +bool get useNosheetSession => + Platform.environment['SYNC_RELAY_UI'] == 'nosheet'; + +class WatchTogetherApp extends StatelessWidget { + const WatchTogetherApp({super.key}); + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + return MaterialApp( + title: 'Watch Together', + theme: ThemeData.dark(), + home: SignalBuilder( + builder: (context) { + return switch (session.route.value) { + AppRoute.welcome => const WelcomeScreen(), + AppRoute.connect => const ConnectScreen(), + AppRoute.session => useMinimalSession + ? const MinimalSessionScreen() + : useNosheetSession + ? const NosheetSessionScreen() + : useLegacySession + ? const LegacySessionScreen() + : const SessionScreen(), + }; + }, + ), + ); + } +} diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart new file mode 100644 index 0000000..c0126c9 --- /dev/null +++ b/apps/desktop/lib/main.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart'; + +import 'app.dart'; +import 'src/state/session_state.dart'; + +Future main(List args) async { + initLogging(); + if (args.contains('--headless')) { + await runHeadless(args); + return; + } + FlutterError.onError = (details) { + debugPrint('FLUTTER ERROR: ${details.exceptionAsString()}\n${details.stack}'); + FlutterError.presentError(details); + }; + ErrorWidget.builder = (details) => Material( + color: const Color(0xFF101010), + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Something broke rendering this view:\n${details.exceptionAsString()}', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + ), + ); + WidgetsFlutterBinding.ensureInitialized(); + await Hive.initFlutter(); + try { + await SessionState.instance.loadForm(); + } catch (e, st) { + debugPrint('load connect form failed: $e\n$st'); + } + MediaKit.ensureInitialized(); + runApp(const WatchTogetherApp()); +} + +Future runHeadless(List args) async { + final session = SessionState.instance; + await session.startHeadless(args); + await ProcessSignal.sigint.watch().first; + await session.stopServer(); +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart new file mode 100644 index 0000000..36ab876 --- /dev/null +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../sync/room_diff.dart'; + +class PlaybackController { + late final Player player; + bool _ready = false; + + Future init() async { + player = Player( + configuration: PlayerConfiguration( + bufferSize: 64 * 1024 * 1024, + vo: Platform.environment['SYNC_RELAY_VO'], + )); + _ready = true; + await _pinBufferProps(); + } + + Future _pin(String property, String value) async { + try { + final dynamic platform = player.platform; + await platform?.setProperty(property, value); + } catch (e, st) { + debugPrint('pin $property=$value failed: $e\n$st'); + } + } + + Future _pinBufferProps() async { + await _pin('demuxer-readahead-secs', '10'); + await _pin('cache-secs', '10'); + final hwdec = Platform.environment['SYNC_RELAY_HWDEC']; + if (hwdec != null && hwdec.isNotEmpty) { + await _pin('hwdec', hwdec); + } + await _pin('stream-lavf-o', + 'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4'); + } + + Future openUrl(String url) async { + if (!_ready) return; + try { + await player.open(Media(url), play: false); + } catch (e, st) { + debugPrint('open $url failed: $e\n$st'); + rethrow; + } + } + + double get positionSecs => + player.state.position.inMilliseconds / 1000.0; + + double get bufferedSecsAhead { + final ahead = + (player.state.buffer - player.state.position).inMilliseconds / 1000.0; + if (ahead.isNaN || ahead < 0) return 0; + return ahead; + } + + double get durationSecs => + player.state.duration.inMilliseconds / 1000.0; + + Future apply(List actions) async { + for (final action in actions) { + try { + switch (action) { + case SeekAction(:final position): + if ((positionSecs - position).abs() < 0.5) break; + await player.seek(Duration(milliseconds: (position * 1000).round())); + case TransportAction(:final playing): + if (playing) { + await player.play(); + } else { + await player.pause(); + } + case RateAction(:final rate): + await player.setRate(rate); + } + } catch (e, st) { + debugPrint('player action $action failed: $e\n$st'); + } + } + } + + Future dispose() async { + _ready = false; + try { + await player.dispose(); + } catch (_) {} + } + + Future stop() async { + if (!_ready) return; + try { + await player.stop(); + } catch (e, st) { + debugPrint('player stop failed: $e\n$st'); + } + } +} diff --git a/apps/desktop/lib/src/screens/connect_screen.dart b/apps/desktop/lib/src/screens/connect_screen.dart new file mode 100644 index 0000000..bb7cd72 --- /dev/null +++ b/apps/desktop/lib/src/screens/connect_screen.dart @@ -0,0 +1,351 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../session/session_coordinator.dart'; +import '../state/session_state.dart'; +import '../widgets/avatar_pipeline.dart'; +import '../widgets/media_input.dart'; +import '../widgets/user_section.dart'; + +class ConnectScreen extends StatefulWidget { + const ConnectScreen({super.key}); + + @override + State createState() => _ConnectScreenState(); +} + +class _ConnectScreenState extends State { + late final TextEditingController _host; + late final TextEditingController _port; + late final TextEditingController _secret; + late final TextEditingController _name; + late final TextEditingController _media; + late final void Function() _stopKickWatch; + String? _error; + String? _notice; + bool _busy = false; + bool _dialogOpen = false; + int _attempt = 0; + String? _lastKickShown; + + @override + void initState() { + super.initState(); + final f = SessionState.instance.lastForm; + _host = TextEditingController(text: f.host); + _port = TextEditingController(text: f.port); + _secret = TextEditingController(text: f.secret); + _name = TextEditingController(text: f.name); + _media = TextEditingController(text: f.linkUrl.isNotEmpty ? f.linkUrl : f.mediaPath); + _stopKickWatch = effect(() { + final kick = SessionState.instance.coordinator.kickReason.value; + if (kick == null || kick.isEmpty) { + _lastKickShown = null; + return; + } + if (kick == _lastKickShown) return; + _lastKickShown = kick; + final reason = kick; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showKickedDialog(reason); + }); + }); + } + + @override + void dispose() { + _stopKickWatch(); + _host.dispose(); + _port.dispose(); + _secret.dispose(); + _name.dispose(); + _media.dispose(); + super.dispose(); + } + + String _actionable(Object e) { + if (e is FormatException) return e.message; + return 'check the form and try again'; + } + + Future _buildParams(ConnectMode mode) async { + final port = int.tryParse(_port.text.trim()); + if (port == null || port <= 0 || port > 65535) { + throw const FormatException('port must be 1–65535'); + } + if (_secret.text.isEmpty) { + throw const FormatException('room password required'); + } + if (_name.text.trim().isEmpty) { + throw const FormatException('display name required'); + } + String avatar = ''; + try { + avatar = await encodeAvatar(SessionState.instance.lastForm.avatarPath); + } on FormatException catch (e) { + throw FormatException(e.message); + } catch (_) { + throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo'); + } + final parsed = parseMediaInput(_media.text); + if (mode == ConnectMode.host && parsed.mediaPath.isNotEmpty && !await File(parsed.mediaPath).exists()) { + throw const FormatException('media file not found — check the path or pick again with Browse'); + } + return ConnectParams( + host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(), + port: port, + secret: _secret.text, + name: _name.text.trim(), + avatar: avatar, + mediaPath: mode == ConnectMode.host ? parsed.mediaPath : '', + linkUrl: mode == ConnectMode.host ? parsed.linkUrl : '', + ); + } + + Future _submit(ConnectMode mode) async { + final myAttempt = ++_attempt; + final session = SessionState.instance; + setState(() { + _error = null; + _notice = null; + _busy = true; + }); + late final ConnectParams params; + try { + params = await _buildParams(mode); + } catch (e) { + if (!mounted || myAttempt != _attempt) return; + setState(() { + _error = _actionable(e); + _busy = false; + }); + return; + } + final parsed = parseMediaInput(_media.text); + final form = SessionState.instance.lastForm + ..host = _host.text.trim() + ..port = _port.text.trim() + ..secret = _secret.text + ..name = _name.text.trim() + ..mediaPath = parsed.mediaPath + ..linkUrl = parsed.linkUrl; + try { + if (mode == ConnectMode.host) { + await session.host(params, form); + } else { + await session.join(params, form); + } + } catch (e) { + if (!mounted || myAttempt != _attempt) return; + final kick = session.coordinator.kickReason.peek(); + setState(() => _busy = false); + if (kick != null && kick.isNotEmpty) { + await _showKickedDialog(kick); + } else { + await _showConnectionDialog('$e'); + } + return; + } + if (myAttempt != _attempt) { + try { + await session.leave(); + await session.stopServer(); + } catch (_) {} + return; + } + if (mounted) setState(() => _busy = false); + } + + Future _cancel() async { + _attempt++; + try { + await SessionState.instance.coordinator.cancelConnect(); + await SessionState.instance.stopServer(); + } catch (_) {} + if (!mounted) return; + setState(() { + _busy = false; + _notice = 'connection cancelled'; + }); + } + + Future _showKickedDialog(String reason) async { + if (!mounted || _dialogOpen) return; + _dialogOpen = true; + try { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('You were removed'), + content: Text(reason.isEmpty ? 'The host removed you from the room.' : reason), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + } finally { + _dialogOpen = false; + } + } + + Future _showConnectionDialog(String message) async { + if (!mounted || _dialogOpen) return; + _dialogOpen = true; + try { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Could not connect'), + content: Text(message), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + } finally { + _dialogOpen = false; + } + } + + Future _browse(TextEditingController target, {required String dialogTitle, List? allowedExtensions}) async { + try { + final files = await FilePicker.pickFiles( + dialogTitle: dialogTitle, + type: allowedExtensions == null ? FileType.any : FileType.custom, + allowedExtensions: allowedExtensions, + ); + if (files.isEmpty) return; + final path = files.first.path; + if (path != null && mounted) setState(() => target.text = path); + } catch (e) { + if (!mounted) return; + setState(() => _notice = 'file dialog unavailable (${e.runtimeType}) — paste the path manually'); + } + } + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + return Scaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome), + title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')), + ), + body: Stack( + alignment: AlignmentGeometry.center, + children: [ + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(20), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: ConnectProfileCard(name: _name), + ), + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(24), + children: [ + if (_error != null) + Card( + child: Padding(padding: const EdgeInsets.all(12), child: Text(_error!)), + ), + if (_notice != null) Text(_notice!), + SignalBuilder( + builder: (context) { + final mode = session.connectMode.value; + return SegmentedButton( + segments: const [ + ButtonSegment(value: ConnectMode.host, label: Text('Host')), + ButtonSegment(value: ConnectMode.join, label: Text('Join')), + ], + selected: {mode}, + onSelectionChanged: (s) => session.connectMode.value = s.first, + ); + }, + ), + const SizedBox(height: 16), + SignalBuilder( + builder: (context) { + if (session.connectMode.value == ConnectMode.host) { + return const SizedBox.shrink(); + } + return TextField( + controller: _host, + decoration: const InputDecoration(labelText: 'Host address'), + ); + }, + ), + TextField( + controller: _port, + decoration: const InputDecoration(labelText: 'Port'), + keyboardType: TextInputType.number, + ), + TextField( + controller: _secret, + decoration: const InputDecoration(labelText: 'Room password'), + obscureText: true, + ), + const SizedBox(height: 8), + SignalBuilder( + builder: (context) { + if (session.connectMode.value == ConnectMode.host) { + return Row( + children: [ + Expanded( + child: TextField( + controller: _media, + decoration: const InputDecoration(labelText: 'Media (file path or http(s) URL)'), + ), + ), + TextButton( + onPressed: () => _browse( + _media, + dialogTitle: 'Pick media file', + allowedExtensions: const ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'], + ), + child: const Text('Browse'), + ), + ], + ); + } + return const SizedBox.shrink(); + }, + ), + const SizedBox(height: 16), + SignalBuilder( + builder: (context) { + final mode = session.connectMode.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: _busy ? null : () => _submit(mode), + child: Text( + _busy + ? 'Connecting…' + : mode == ConnectMode.host + ? 'Start hosting' + : 'Join', + ), + ), + if (_busy) TextButton(onPressed: _cancel, child: const Text('Cancel')), + ], + ); + }, + ), + const SizedBox(height: 12), + const Text('Host opens ports and serves. Clients open nothing.', textAlign: TextAlign.center), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/legacy_session_screen.dart b/apps/desktop/lib/src/screens/legacy_session_screen.dart new file mode 100644 index 0000000..a7b7ae2 --- /dev/null +++ b/apps/desktop/lib/src/screens/legacy_session_screen.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../state/session_state.dart'; + +const _legacyControls = MaterialDesktopVideoControlsThemeData( + playAndPauseOnTap: false, + keyboardShortcuts: {}, + bottomButtonBar: [ + MaterialDesktopVolumeButton(), + MaterialDesktopPositionIndicator(), + Spacer(), + MaterialDesktopFullscreenButton(), + ], +); + +class LegacySessionScreen extends StatefulWidget { + const LegacySessionScreen({super.key}); + + @override + State createState() => _LegacySessionScreenState(); +} + +class _LegacySessionScreenState extends State { + VideoController? _video; + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _video = VideoController( + SessionState.instance.coordinator.playback.player); + } + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.space) { + SessionState.instance.coordinator.toggleReady(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + final coord = session.coordinator; + return Focus( + focusNode: _focus, + autofocus: true, + onKeyEvent: _onKey, + child: Scaffold( + appBar: AppBar( + title: SignalBuilder( + builder: (context) { + final h = coord.health.value; + final r = coord.room.value; + return Text('Room · ${r?.transport ?? '…'} · ${h.message}'); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => session.leave(), + ), + ], + ), + body: Column( + children: [ + SignalBuilder(builder: (context) { + final h = coord.health.value; + final starve = coord.starving.value; + final text = [ + h.message, + if (starve != null) starve, + ].join(' · '); + return Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text(text), + ); + }), + Expanded( + child: _video == null + ? const Center(child: Text('player starting…')) + : MaterialDesktopVideoControlsTheme( + normal: _legacyControls, + fullscreen: _legacyControls, + child: Video(controller: _video!), + ), + ), + SignalBuilder(builder: (context) { + final r = coord.room.value; + if (r == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.all(8), + child: Wrap( + spacing: 8, + children: [ + for (final m in r.members) + Chip( + avatar: CircleAvatar( + child: Text(m.name.isEmpty + ? '?' + : m.name[0].toUpperCase()), + ), + label: Text(m.name), + backgroundColor: m.ready + ? Colors.green.withValues(alpha: 0.3) + : null, + ), + ], + ), + ); + }), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SignalBuilder(builder: (context) { + final ready = + coord.selfMember.value?.ready ?? false; + return ElevatedButton( + onPressed: () => coord.toggleReady(), + child: Text(ready ? 'Ready ✓' : 'Not ready'), + ); + }), + const SizedBox(width: 12), + OutlinedButton( + onPressed: () => coord.forcePlayAll(), + child: const Text('Force play all'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/minimal_session_screen.dart b/apps/desktop/lib/src/screens/minimal_session_screen.dart new file mode 100644 index 0000000..9c76227 --- /dev/null +++ b/apps/desktop/lib/src/screens/minimal_session_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../state/session_state.dart'; + +const _minimalControls = MaterialDesktopVideoControlsThemeData( + playAndPauseOnTap: false, + keyboardShortcuts: {}, + bottomButtonBar: [ + MaterialDesktopVolumeButton(), + _ReadyButton(), + _MarkAllButton(), + MaterialDesktopPositionIndicator(), + Spacer(), + MaterialDesktopFullscreenButton(), + ], +); + +class MinimalSessionScreen extends StatefulWidget { + const MinimalSessionScreen({super.key}); + + @override + State createState() => _MinimalSessionScreenState(); +} + +class _MinimalSessionScreenState extends State { + VideoController? _video; + + @override + void initState() { + super.initState(); + _video = VideoController( + SessionState.instance.coordinator.playback.player); + HardwareKeyboard.instance.addHandler(_key); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_key); + super.dispose(); + } + + bool _key(KeyEvent event) { + if (event is! KeyDownEvent) return false; + if (SessionState.instance.route.peek() != AppRoute.session) { + return false; + } + if (event.logicalKey == LogicalKeyboardKey.space) { + SessionState.instance.coordinator.toggleReady(); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: _video == null + ? const Center(child: Text('player starting…')) + : MaterialDesktopVideoControlsTheme( + normal: _minimalControls, + fullscreen: _minimalControls, + child: Video(controller: _video!), + ), + ), + const Positioned( + top: 8, + right: 8, + child: Text('MINIMAL', + style: TextStyle(color: Colors.red, fontSize: 12)), + ), + ], + ), + ); + } +} + +class _ReadyButton extends StatelessWidget { + const _ReadyButton(); + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context) { + final ready = SessionState + .instance.coordinator.selfMember.value?.ready ?? + false; + return TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => + SessionState.instance.coordinator.toggleReady(), + child: Text(ready ? 'Ready ✓' : 'Not ready', + style: const TextStyle(fontSize: 12)), + ); + }, + ); + } +} + +class _MarkAllButton extends StatelessWidget { + const _MarkAllButton(); + + @override + Widget build(BuildContext context) { + return TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => + SessionState.instance.coordinator.forcePlayAll(), + child: const Text('Mark everyone ready', + style: TextStyle(fontSize: 12)), + ); + } +} diff --git a/apps/desktop/lib/src/screens/nosheet_session_screen.dart b/apps/desktop/lib/src/screens/nosheet_session_screen.dart new file mode 100644 index 0000000..f32781e --- /dev/null +++ b/apps/desktop/lib/src/screens/nosheet_session_screen.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../session/health.dart'; +import '../state/session_state.dart'; +import '../widgets/member_avatar.dart'; + +const _nosheetControls = MaterialDesktopVideoControlsThemeData( + playAndPauseOnTap: false, + keyboardShortcuts: {}, + visibleOnMount: true, + hideMouseOnControlsRemoval: false, + controlsHoverDuration: Duration(hours: 24), + bottomButtonBar: [ + MaterialDesktopVolumeButton(), + _ReadyButton(), + _MarkAllButton(), + MaterialDesktopPositionIndicator(), + Spacer(), + MaterialDesktopFullscreenButton(), + ], +); + +class NosheetSessionScreen extends StatefulWidget { + const NosheetSessionScreen({super.key}); + + @override + State createState() => _NosheetSessionScreenState(); +} + +class _NosheetSessionScreenState extends State { + VideoController? _video; + + @override + void initState() { + super.initState(); + _video = VideoController( + SessionState.instance.coordinator.playback.player); + HardwareKeyboard.instance.addHandler(_key); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_key); + super.dispose(); + } + + bool _key(KeyEvent event) { + if (event is! KeyDownEvent) return false; + if (SessionState.instance.route.peek() != AppRoute.session) { + return false; + } + if (event.logicalKey == LogicalKeyboardKey.space) { + SessionState.instance.coordinator.toggleReady(); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + final coord = SessionState.instance.coordinator; + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: _video == null + ? const Center(child: Text('player starting…')) + : MaterialDesktopVideoControlsTheme( + normal: _nosheetControls, + fullscreen: _nosheetControls, + child: Video(controller: _video!), + ), + ), + const Positioned( + top: 8, + right: 8, + child: Text('NOSHEET', + style: TextStyle(color: Colors.red, fontSize: 12)), + ), + Positioned( + top: 12, + left: 12, + child: SignalBuilder(builder: (context) { + final h = coord.health.value; + final starve = coord.starving.value; + final retrying = coord.reconnecting.value; + final unwell = h.severity == HealthSeverity.warn || + h.severity == HealthSeverity.error || + retrying || + starve != null; + if (!unwell) return const SizedBox.shrink(); + return Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(999), + ), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text( + [h.message, if (starve != null) starve].join(' · '), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ); + }), + ), + SignalBuilder(builder: (context) { + final r = coord.room.value; + if (r == null || r.transport != 'held') { + return const SizedBox.shrink(); + } + final waiting = [ + for (final m in r.members) + if (!m.ready) m + ]; + if (waiting.isEmpty) return const SizedBox.shrink(); + return Positioned.fill( + child: IgnorePointer( + child: Center( + child: Wrap( + spacing: -20, + children: [ + for (final m in waiting) + Opacity( + opacity: m.stale ? 0.5 : 1.0, + child: MemberAvatar( + avatarBase64: m.avatar, + name: m.name, + size: 64, + ), + ), + ], + ), + ), + ), + ); + }), + Positioned( + right: 12, + top: 56, + child: SignalBuilder(builder: (context) { + final items = coord.toasts.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final t in items.reversed.take(5)) + Card( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + child: Text(t.text), + ), + ), + ], + ); + }), + ), + ], + ), + ); + } +} + +class _ReadyButton extends StatelessWidget { + const _ReadyButton(); + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context) { + final ready = SessionState + .instance.coordinator.selfMember.value?.ready ?? + false; + return TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => + SessionState.instance.coordinator.toggleReady(), + child: Text(ready ? 'Ready ✓' : 'Not ready', + style: const TextStyle(fontSize: 12)), + ); + }, + ); + } +} + +class _MarkAllButton extends StatelessWidget { + const _MarkAllButton(); + + @override + Widget build(BuildContext context) { + return TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => + SessionState.instance.coordinator.forcePlayAll(), + child: const Text('Mark everyone ready', + style: TextStyle(fontSize: 12)), + ); + } +} diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart new file mode 100644 index 0000000..ddf05df --- /dev/null +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -0,0 +1,580 @@ +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/dialogs.dart'; +import '../widgets/member_avatar.dart'; + +class SessionScreen extends StatefulWidget { + const SessionScreen({super.key}); + + @override + State createState() => _SessionScreenState(); +} + +class _SessionScreenState extends State { + VideoController? _video; + bool _sheetOpen = false; + bool _confirmLeave = false; + DateTime? _lastKeyAt; + final _chat = TextEditingController(); + final _swapLink = TextEditingController(); + late final void Function() _stopKickWatch; + late final FocusNode _noFocus; + + @override + void initState() { + super.initState(); + final session = SessionState.instance; + _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false); + _video = VideoController(session.coordinator.playback.player); + HardwareKeyboard.instance.addHandler(_globalKey); + _stopKickWatch = effect(() { + if (session.coordinator.kickReason.value != null) { + session.route.value = AppRoute.connect; + } + }); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_globalKey); + _stopKickWatch(); + _chat.dispose(); + _swapLink.dispose(); + _noFocus.dispose(); + super.dispose(); + } + + bool get _editing { + var n = FocusManager.instance.primaryFocus; + while (n != null) { + final w = n.context?.widget; + if (w is EditableText) return true; + n = n.parent; + } + return false; + } + + bool _globalKey(KeyEvent event) { + if (event is! KeyDownEvent) return false; + if (SessionState.instance.route.peek() != AppRoute.session) return false; + if (_editing) return false; + final now = DateTime.now().toUtc(); + if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) { + return false; + } + final coord = SessionState.instance.coordinator; + if (event.logicalKey == LogicalKeyboardKey.space) { + _lastKeyAt = now; + coord.toggleReady(); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (!mounted) return false; + _lastKeyAt = now; + setState(() => _sheetOpen = !_sheetOpen); + coord.markInput(); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + _lastKeyAt = now; + coord.nudge(10); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + _lastKeyAt = now; + coord.nudge(-10); + return true; + } + if (event.logicalKey == LogicalKeyboardKey.escape) { + if (!mounted) return false; + if (_sheetOpen) { + setState(() => _sheetOpen = false); + return true; + } + _lastKeyAt = now; + setState(() => _confirmLeave = true); + return true; + } + return false; + } + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + final coord = session.coordinator; + final controlsTheme = MaterialDesktopVideoControlsThemeData( + playAndPauseOnTap: false, + keyboardShortcuts: {}, + visibleOnMount: true, + hideMouseOnControlsRemoval: false, + controlsHoverDuration: const Duration(hours: 24), + bottomButtonBar: [ + const MaterialDesktopVolumeButton(), + _ReadyControlButton(focusNode: _noFocus), + _MarkAllReadyControlButton(focusNode: _noFocus), + const MaterialDesktopPositionIndicator(), + const Spacer(), + const MaterialDesktopFullscreenButton(), + ], + ); + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: _video == null + ? const Center( + child: Text('player starting…', style: TextStyle(color: Colors.white)), + ) + : MaterialDesktopVideoControlsTheme( + normal: controlsTheme, + fullscreen: controlsTheme, + child: Video(controller: _video!), + ), + ), + Positioned( + top: 0, + right: 0, + child: Container( + color: Colors.black.withValues(alpha: 0.6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.group, color: Colors.white), + onPressed: () { + setState(() => _sheetOpen = !_sheetOpen); + coord.markInput(); + }, + ), + ], + ), + ), + ), + Positioned( + top: 12, + left: 12, + child: SignalBuilder( + builder: (context) { + final h = coord.health.value; + final starve = coord.starving.value; + final retrying = coord.reconnecting.value; + final kicked = coord.kickReason.value; + final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null; + if (!unwell) return const SizedBox.shrink(); + final color = switch (h.severity) { + HealthSeverity.ok => Colors.green, + HealthSeverity.info => Colors.grey, + HealthSeverity.warn => Colors.orange, + HealthSeverity.error => Colors.red, + }; + final IconData icon; + if (retrying) { + icon = Icons.sync; + } else if (h.severity == HealthSeverity.error) { + icon = Icons.error_outline; + } else if (h.severity == HealthSeverity.warn) { + icon = Icons.warning_amber_outlined; + } else { + icon = Icons.hourglass_empty; + } + final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · '); + final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn); + return Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Flexible( + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)), + ), + if (retrying) + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () => coord.cancelReconnect(), + child: const Text('Cancel', style: TextStyle(fontSize: 12)), + ), + if (canRetry) + TextButton( + style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + onPressed: () => coord.retry(), + child: const Text('Retry', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + }, + ), + ), + SignalBuilder( + builder: (context) { + final r = coord.room.value; + final waiting = [ + for (final m in r?.members ?? []) + if (!m.ready) m, + ]; + final show = r != null && r.transport == 'held' && waiting.isNotEmpty; + return Positioned.fill( + child: IgnorePointer( + child: Center( + child: Visibility( + visible: show, + maintainState: true, + maintainAnimation: true, + maintainSize: true, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadiusGeometry.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: .min, + spacing: 10, + crossAxisAlignment: .center, + children: [ + Text("Waiting for", style: TextTheme.of(context).labelLarge), + Wrap( + alignment: WrapAlignment.center, + spacing: -20, + runSpacing: 8, + children: [ + for (final m in waiting) + Opacity( + opacity: m.stale ? 0.5 : 1.0, + child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ), + Positioned( + right: 12, + top: 56, + child: SignalBuilder( + builder: (context) { + final items = coord.toasts.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)], + ); + }, + ), + ), + Positioned.fill( + child: IgnorePointer( + ignoring: !_sheetOpen, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _sheetOpen ? 1 : 0, + child: GestureDetector( + onTap: () => setState(() => _sheetOpen = false), + child: Container(color: Colors.black54), + ), + ), + ), + ), + Positioned( + top: 0, + bottom: 0, + right: 0, + child: IgnorePointer( + ignoring: !_sheetOpen, + child: AnimatedSlide( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + offset: _sheetOpen ? Offset.zero : const Offset(1, 0), + child: SizedBox( + width: 380, + height: double.infinity, + child: _SheetPanel( + chat: _chat, + swapLink: _swapLink, + onClose: () => setState(() => _sheetOpen = false), + onLeave: () => setState(() { + _sheetOpen = false; + _confirmLeave = true; + }), + ), + ), + ), + ), + ), + if (_confirmLeave) + Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => _confirmLeave = false), + child: Container( + color: Colors.black54, + child: Center( + child: GestureDetector( + onTap: () {}, + child: AlertDialog( + title: const Text('Leave room?'), + content: const Text('You can rejoin right away.'), + actions: [ + TextButton(onPressed: () => setState(() => _confirmLeave = false), child: const Text('Cancel')), + TextButton( + onPressed: () { + setState(() => _confirmLeave = false); + session.leave(); + }, + child: const Text('Leave'), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ReadyControlButton extends StatelessWidget { + final FocusNode focusNode; + const _ReadyControlButton({required this.focusNode}); + + @override + Widget build(BuildContext context) { + return SignalBuilder( + builder: (context) { + final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false; + return TextButton( + focusNode: focusNode, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => SessionState.instance.coordinator.toggleReady(), + child: Text(ready ? 'Ready ✓' : 'Not ready', style: const TextStyle(fontSize: 12)), + ); + }, + ); + } +} + +class _MarkAllReadyControlButton extends StatelessWidget { + final FocusNode focusNode; + const _MarkAllReadyControlButton({required this.focusNode}); + + @override + Widget build(BuildContext context) { + return TextButton( + focusNode: focusNode, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => SessionState.instance.coordinator.forcePlayAll(), + child: const Text('Mark everyone ready', style: TextStyle(fontSize: 12)), + ); + } +} + +class _ToastCard extends StatefulWidget { + final String text; + const _ToastCard({super.key, required this.text}); + + @override + State<_ToastCard> createState() => _ToastCardState(); +} + +class _ToastCardState extends State<_ToastCard> { + bool _shown = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _shown = true); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedSlide( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + offset: _shown ? Offset.zero : const Offset(1, 0), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: _shown ? 1 : 0, + child: Card( + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(widget.text)), + ), + ), + ); + } +} + +class _SheetPanel extends StatelessWidget { + final TextEditingController chat; + final TextEditingController swapLink; + final VoidCallback onClose; + final VoidCallback onLeave; + const _SheetPanel({required this.chat, required this.swapLink, required this.onClose, required this.onLeave}); + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + final coord = session.coordinator; + return Material( + elevation: 8, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: SignalBuilder( + builder: (context) { + final r = coord.room.value; + final log = coord.feedLog.value; + final theme = Theme.of(context); + final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold); + return ListView( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Text( + 'transport ${r?.transport ?? '…'} · event ${r?.eventId ?? 0} · media ${r?.mediaId ?? 0}', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + IconButton(icon: const Icon(Icons.close), onPressed: onClose), + ], + ), + const Divider(height: 20), + Text('Users (${r?.members.length ?? 0})', style: sectionStyle), + const SizedBox(height: 6), + if (r != null) + for (final m in r.members) + Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + dense: true, + leading: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 36), + title: Text(m.id == coord.sync.memberId ? '${m.name} (you)' : m.name), + subtitle: Text('ready=${m.ready} inSet=${m.inSet} buf=${m.buffered.toStringAsFixed(1)} stale=${m.stale}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => coord.forceTarget(m.id, !m.ready), + child: Text(m.ready ? 'Force unready' : 'Force ready'), + ), + TextButton( + onPressed: () async { + final ok = await showKickConfirmDialog(context, m.name); + if (ok == true) coord.kick(m.id); + }, + child: const Text('Kick'), + ), + ], + ), + ), + ), + const Divider(height: 20), + Text('Log', style: sectionStyle), + const SizedBox(height: 6), + if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall), + for (final f in log.reversed.take(30)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text('${f.actor} ${f.action} ${f.target ?? ''}', style: theme.textTheme.bodySmall), + ), + const Divider(height: 20), + Text('Chat', style: sectionStyle), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: chat, + decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true), + onSubmitted: (v) { + coord.sendChat(v); + chat.clear(); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: () { + coord.sendChat(chat.text); + chat.clear(); + }, + ), + ], + ), + const SizedBox(height: 12), + Text('Share a link', style: sectionStyle), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: swapLink, + decoration: const InputDecoration(labelText: 'Swap to link URL', border: OutlineInputBorder(), isDense: true), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.swap_horiz), + onPressed: () { + coord.swapLink(swapLink.text.trim()); + swapLink.clear(); + }, + ), + ], + ), + const Divider(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error), + icon: const Icon(Icons.logout), + label: const Text('Leave room'), + onPressed: onLeave, + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/welcome_screen.dart b/apps/desktop/lib/src/screens/welcome_screen.dart new file mode 100644 index 0000000..fb298d6 --- /dev/null +++ b/apps/desktop/lib/src/screens/welcome_screen.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../state/session_state.dart'; + +class WelcomeScreen extends StatelessWidget { + const WelcomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final session = SessionState.instance; + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Watch Together', style: TextStyle(fontSize: 24)), + const SizedBox(height: 8), + const Text( + 'Set your name + avatar on the next screen.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => session.goConnect(ConnectMode.host), + child: const Text('Host'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () => session.goConnect(ConnectMode.join), + child: const Text('Join'), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Host opens ports and serves. Clients open nothing.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/session/health.dart b/apps/desktop/lib/src/session/health.dart new file mode 100644 index 0000000..91988c0 --- /dev/null +++ b/apps/desktop/lib/src/session/health.dart @@ -0,0 +1,7 @@ +enum HealthSeverity { ok, info, warn, error } + +class HealthStatus { + final HealthSeverity severity; + final String message; + const HealthStatus(this.severity, this.message); +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart new file mode 100644 index 0000000..c5b4102 --- /dev/null +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -0,0 +1,603 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../playback/playback_controller.dart'; +import '../sfx/sfx.dart'; +import '../sync/room_diff.dart'; +import '../sync/sync_client.dart'; +import 'health.dart'; + +class ConnectParams { + final String host; + final int port; + final String secret; + final String name; + final String avatar; + final String mediaPath; + final String linkUrl; + const ConnectParams({ + required this.host, + required this.port, + required this.secret, + required this.name, + this.avatar = '', + this.mediaPath = '', + this.linkUrl = '', + }); +} + +class ToastItem { + final int id; + final String text; + final DateTime at; + const ToastItem({required this.id, required this.text, required this.at}); +} + +const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'}; + +String? roomMediaUrl(RoomBroadcast room) { + final media = room.media; + if (media == null) return null; + final url = media['url']; + if (url is String && url.isNotEmpty) return url; + return null; +} + +class SessionCoordinator { + final SfxService sfx; + final SyncClient sync = SyncClient(); + final PlaybackController playback = PlaybackController(); + final RoomDiff diff = RoomDiff(); + + final health = + signal(const HealthStatus(HealthSeverity.info, 'idle')); + final room = signal(null); + final feedLog = signal>([]); + final toasts = signal>([]); + final reconnecting = signal(false); + final kickReason = signal(null); + final starving = signal(null); + + late final Computed selfMember = computed(() { + final r = room.value; + if (r == null || sync.memberId.isEmpty) return null; + for (final m in r.members) { + if (m.id == sync.memberId) return m; + } + return null; + }); + + Timer? _ticker; + StreamSubscription? _roomSub; + StreamSubscription? _feedSub; + StreamSubscription? _kickSub; + StreamSubscription? _errSub; + StreamSubscription? _statusSub; + + bool _manualClose = false; + bool _playerReady = false; + bool _warnedIdle = false; + bool _wasPlayAllowed = false; + bool _lastReady = false; + int _mediaId = -1; + String? _openUrl; + String _wsHost = ''; + int _wsPort = 0; + double _lastPos = 0; + double? _pendingSeekTarget; + DateTime? _pendingSeekAt; + int _seekRetries = 0; + DateTime? _lastRoomAt; + DateTime _lastInput = DateTime.now().toUtc(); + int _toastId = 0; + ConnectParams? _params; + bool _everConnected = false; + + SessionCoordinator({this.sfx = const NoopSfxService()}); + + Future initPlayer() async { + if (_playerReady) return; + try { + await playback.init(); + _playerReady = true; + } catch (e, st) { + debugPrint('player init failed: $e\n$st'); + health.value = + HealthStatus(HealthSeverity.error, 'player failed to start: $e'); + rethrow; + } + } + + void markInput() { + _lastInput = DateTime.now().toUtc(); + _warnedIdle = false; + } + + Future connect(ConnectParams params) async { + await leave(silent: true); + _params = params; + _manualClose = false; + kickReason.value = null; + _wsHost = params.host; + _wsPort = params.port; + diff.reset(); + _mediaId = -1; + _openUrl = null; + _lastReady = false; + _wasPlayAllowed = false; + _everConnected = false; + markInput(); + _subscribe(); + feedLog.value = []; + toasts.value = []; + await initPlayer(); + health.value = + const HealthStatus(HealthSeverity.info, 'connecting…'); + final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync'); + try { + final welcome = await sync.connect( + wsUri: wsUri, + secret: params.secret, + name: params.name, + avatar: params.avatar, + ); + reconnecting.value = false; + _everConnected = true; + health.value = const HealthStatus(HealthSeverity.ok, 'connected'); + _lastRoomAt = DateTime.now().toUtc(); + final url = welcome.mediaUrl; + if (url != null && url.isNotEmpty) { + await _openMedia(url); + } else { + health.value = const HealthStatus( + HealthSeverity.info, 'connected — waiting for host media…'); + } + if (params.linkUrl.isNotEmpty) { + sync.sendSwap(HelloMedia( + name: params.linkUrl, size: 0, duration: 0, url: params.linkUrl)); + } + _startTicker(); + } catch (e) { + debugPrint('connect failed: $e'); + health.value = + HealthStatus(HealthSeverity.error, _friendlyError(e)); + _scheduleReconnect(); + rethrow; + } + } + + void _subscribe() { + _roomSub ??= sync.rooms.listen(_onRoom); + _feedSub ??= sync.feeds.listen(_onFeed); + _kickSub ??= sync.kicked.listen(_onKicked); + _errSub ??= sync.errors.listen((msg) { + health.value = HealthStatus(HealthSeverity.warn, msg); + }); + _statusSub ??= sync.status.listen((s) { + if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { + health.value = const HealthStatus( + HealthSeverity.warn, 'connection lost — retrying…'); + _scheduleReconnect(); + } + }); + } + + Future _openMedia(String url) async { + if (url == _openUrl) return; + _openUrl = url; + diff.reset(); + try { + await playback.openUrl(url); + health.value = + const HealthStatus(HealthSeverity.ok, 'connected'); + } catch (e) { + debugPrint('open media failed: $e'); + health.value = + HealthStatus(HealthSeverity.error, 'cannot open media: $e'); + } + } + + String _fileUrlFor(String name) { + return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}'; + } + + Future _onRoom(RoomBroadcast r) async { + final now = DateTime.now().toUtc(); + _lastRoomAt = now; + room.value = r; + if (r.mediaId != _mediaId) { + _mediaId = r.mediaId; + var url = roomMediaUrl(r); + if ((url == null || url.isEmpty) && sync.token.isNotEmpty) { + final name = (r.media?['name'] as String?) ?? ''; + if (name.isNotEmpty) url = _fileUrlFor(name); + } + if (url != null && url.isNotEmpty) { + await _openMedia(url); + } else { + _openUrl = null; + } + _pendingSeekTarget = null; + _seekRetries = 0; + } + if (!_playerReady || _openUrl == null) { + _wasPlayAllowed = false; + _updateStarving(r, 0); + return; + } + final allowed = localPlayAllowed(r, sync.memberId); + var actions = diff.diff(r, playback.positionSecs, now); + if (r.transport == 'playing' && !allowed) { + actions = [ + for (final a in actions) if (a is! TransportAction) a, + const TransportAction(false), + ]; + } + if (allowed && !_wasPlayAllowed && r.transport == 'playing') { + final edge = roomPositionAt(r, now); + actions = [ + for (final a in actions) if (a is! TransportAction) a, + SeekAction(edge), + const TransportAction(true), + ]; + _pendingSeekTarget = edge; + _pendingSeekAt = now; + } + _wasPlayAllowed = allowed; + for (final a in actions) { + if (a is SeekAction) { + _pendingSeekTarget = a.position; + _pendingSeekAt = now; + } + } + await playback.apply(actions); + _updateStarving(r, playback.bufferedSecsAhead); + } + + void _updateStarving(RoomBroadcast r, double bufferedAhead) { + if (r.transport == 'playing') { + starving.value = null; + return; + } + for (final m in r.members) { + if (m.inSet && m.buffered < RelayConstants.minBufferSecs) { + starving.value = + 'waiting for ${m.name} — ${m.buffered.toStringAsFixed(1)} s buffered'; + return; + } + } + final self = selfMember.peek(); + if (self != null && self.ready) { + final waiters = r.members + .where((m) => m.id != self.id && !m.stale && !(m.inSet && m.ready)) + .map((m) => m.name) + .toList(); + if (waiters.isNotEmpty) { + final readyCount = r.members.where((m) => m.ready).length; + starving.value = + 'waiting for ${waiters.join(', ')} ($readyCount/${r.members.length} ready)'; + return; + } + } + if (self != null && !self.ready) { + starving.value = 'press Space when ready'; + return; + } + if (r.members.any((m) => m.inSet)) { + starving.value = 'held — waiting for readiness'; + return; + } + starving.value = 'held — press Space when ready'; + } + + void _onFeed(FeedMessage f) { + feedLog.value = [...feedLog.peek(), f].length > 100 + ? [...feedLog.peek(), f].sublist(feedLog.peek().length - 99) + : [...feedLog.peek(), f]; + if (_toastActions.contains(f.action)) { + final text = f.action == 'chat' + ? '${f.actor}: ${f.reason}' + : _feedText(f); + _toastId++; + toasts.value = [ + ...toasts.peek(), + ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc()) + ]; + } + final kind = sfxForFeedAction(f.action); + if (kind != null) { + var readyIndex = 0; + if (f.action == 'ready') { + readyIndex = room.peek()?.members.where((m) => m.ready).length ?? 0; + } + unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex))); + } + } + + String _feedText(FeedMessage f) { + switch (f.action) { + case 'join': + return '${f.actor} joined'; + case 'leave': + return '${f.actor} left'; + case 'kick': + return '${f.actor} kicked ${f.target ?? ''}'; + case 'swap': + return '${f.actor} changed media'; + default: + return '${f.actor} ${f.action}'; + } + } + + Future _onKicked(String reason) async { + kickReason.value = reason; + _manualClose = true; + reconnecting.value = false; + _ticker?.cancel(); + _ticker = null; + try { + await playback.apply(const [TransportAction(false)]); + } catch (_) {} + await sync.disconnect(); + health.value = HealthStatus( + HealthSeverity.error, + reason.isEmpty ? 'kicked from room' : 'kicked: $reason'); + } + + void _startTicker() { + _ticker?.cancel(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick()); + } + + Future _tick() async { + final now = DateTime.now().toUtc(); + toasts.value = + toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); + final r = room.peek(); + if (sync.token.isNotEmpty && r != null && _playerReady) { + sync.sendReport( + position: playback.positionSecs, + buffered: playback.bufferedSecsAhead, + eventId: r.eventId, + ); + _detectUserSeek(r); + _checkSeekLanded(r); + } + if (!_manualClose && + kickReason.peek() == null && + _lastRoomAt != null && + now.difference(_lastRoomAt!).inSeconds >= 5) { + if (room.peek() != null) { + room.value = null; + starving.value = null; + _wasPlayAllowed = false; + _mediaId = -1; + _pendingSeekTarget = null; + _seekRetries = 0; + diff.reset(); + } + if (!reconnecting.peek()) { + health.value = const HealthStatus( + HealthSeverity.warn, 'connection lost — retrying…'); + _scheduleReconnect(); + } + } + final idleSecs = now.difference(_lastInput).inSeconds; + if (idleSecs >= 8 * 60 && !_warnedIdle) { + _warnedIdle = true; + _toastId++; + toasts.value = [ + ...toasts.peek(), + ToastItem( + id: _toastId, + text: 'idle — auto-unready in 2 min unless you press Space', + at: now) + ]; + unawaited(sfx.play(const SfxCue(SfxKind.warn))); + } + if (idleSecs >= 10 * 60) { + final self = selfMember.peek(); + if (self != null && self.ready) { + _lastReady = false; + sync.sendReady(false, source: 'auto'); + markInput(); + } + } + } + + void _detectUserSeek(RoomBroadcast r) { + if (_openUrl == null) { + _lastPos = playback.positionSecs; + return; + } + final now = DateTime.now().toUtc(); + final pos = playback.positionSecs; + final jumped = (pos - _lastPos).abs(); + _lastPos = pos; + if (jumped <= 2.0) return; + if ((pos - roomPositionAt(r, now)).abs() < 2.0) return; + final recentSync = _pendingSeekAt != null && + now.difference(_pendingSeekAt!).inMilliseconds < 1000; + if (recentSync) return; + markInput(); + sync.sendSeek(pos); + } + + Future _checkSeekLanded(RoomBroadcast r) async { + final target = _pendingSeekTarget; + final at = _pendingSeekAt; + if (target == null || at == null) return; + if (DateTime.now().toUtc().difference(at).inMilliseconds < 1500) return; + if (seekLanded(ownPosition: playback.positionSecs, target: target)) { + _pendingSeekTarget = null; + _pendingSeekAt = null; + _seekRetries = 0; + return; + } + if (_seekRetries < 1) { + _seekRetries++; + _pendingSeekAt = DateTime.now().toUtc(); + await playback.apply([SeekAction(target)]); + } else { + _pendingSeekTarget = null; + _pendingSeekAt = null; + _seekRetries = 0; + } + } + + void toggleReady() { + final self = selfMember.peek(); + final next = !(self?.ready ?? false); + markInput(); + _lastReady = next; + sync.sendReady(next, source: 'manual'); + } + + void forceTarget(String memberId, bool ready) { + markInput(); + sync.sendForce(memberId, ready); + } + + void forcePlayAll() { + markInput(); + sync.sendForce('all', true); + } + + void seekTo(double position) { + markInput(); + sync.sendSeek(position); + } + + void nudge(double deltaSecs) { + final r = room.peek(); + if (r == null || _openUrl == null) return; + final target = + roomPositionAt(r, DateTime.now().toUtc()) + deltaSecs; + if (target.isNaN || target < 0) return; + markInput(); + sync.sendSeek(target); + } + + void sendChat(String text) { + markInput(); + sync.sendChat(text); + } + + void kick(String memberId, {String reason = ''}) { + markInput(); + sync.sendKick(memberId, reason: reason); + } + + Future swapLink(String url) async { + markInput(); + sync.sendSwap( + HelloMedia(name: url, size: 0, duration: 0, url: url)); + } + + void _scheduleReconnect({bool manual = false}) { + if (_manualClose || kickReason.peek() != null || _params == null) return; + if (!manual && !_everConnected) return; + if (reconnecting.peek()) return; + reconnecting.value = true; + Future.delayed(const Duration(milliseconds: 1500), () async { + if (_manualClose || kickReason.peek() != null) { + reconnecting.value = false; + return; + } + final params = _params; + if (params == null) { + reconnecting.value = false; + return; + } + try { + health.value = const HealthStatus( + HealthSeverity.info, 'reconnecting…'); + final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync'); + final welcome = await sync.connect( + wsUri: wsUri, + secret: params.secret, + name: params.name, + avatar: params.avatar, + ); + reconnecting.value = false; + _everConnected = true; + health.value = const HealthStatus(HealthSeverity.ok, 'connected'); + final url = welcome.mediaUrl; + if (url != null && url.isNotEmpty && url != _openUrl) { + await _openMedia(url); + } + if (_lastReady) sync.sendReady(true, source: 'manual'); + _startTicker(); + } catch (e) { + debugPrint('reconnect failed: $e'); + reconnecting.value = false; + _scheduleReconnect(); + } + }); + } + + void cancelReconnect() { + _manualClose = true; + reconnecting.value = false; + } + + Future cancelConnect() async { + _manualClose = true; + reconnecting.value = false; + try { + await sync.disconnect(); + } catch (_) {} + health.value = const HealthStatus(HealthSeverity.info, 'cancelled'); + } + + void retry() { + if (kickReason.peek() != null || _params == null) return; + _manualClose = false; + reconnecting.value = false; + health.value = + const HealthStatus(HealthSeverity.info, 'retrying…'); + _scheduleReconnect(manual: true); + } + + String _friendlyError(Object e) { + final msg = '$e'; + if (msg.contains('proto mismatch')) return 'host runs a different version — update the app'; + if (msg.contains('wrong secret')) return 'wrong room password (401)'; + if (msg.contains('SocketException') || msg.contains('Connection refused')) { + return 'host offline — check address and port'; + } + return 'connect failed: $e'; + } + + Future leave({bool silent = false}) async { + _manualClose = true; + _ticker?.cancel(); + _ticker = null; + reconnecting.value = false; + try { + await playback.stop(); + } catch (_) {} + try { + await sync.disconnect(); + } catch (_) {} + if (!silent) { + room.value = null; + starving.value = null; + health.value = const HealthStatus(HealthSeverity.info, 'idle'); + } + } + + Future dispose() async { + await leave(silent: true); + await _roomSub?.cancel(); + await _feedSub?.cancel(); + await _kickSub?.cancel(); + await _errSub?.cancel(); + await _statusSub?.cancel(); + await sync.dispose(); + await playback.dispose(); + } +} diff --git a/apps/desktop/lib/src/sfx/sfx.dart b/apps/desktop/lib/src/sfx/sfx.dart new file mode 100644 index 0000000..87485ff --- /dev/null +++ b/apps/desktop/lib/src/sfx/sfx.dart @@ -0,0 +1,72 @@ +enum SfxKind { + ready, + unready, + forceReady, + forceUnready, + forcePlay, + forceHold, + kick, + join, + leave, + stale, + swap, + seek, + pause, + resume, + chat, + warn, +} + +class SfxCue { + final SfxKind kind; + final int readyIndex; + const SfxCue(this.kind, {this.readyIndex = 0}); +} + +SfxKind? sfxForFeedAction(String action) { + switch (action) { + case 'ready': + return SfxKind.ready; + case 'unready': + return SfxKind.unready; + case 'force-ready': + return SfxKind.forceReady; + case 'force-unready': + return SfxKind.forceUnready; + case 'force-play': + return SfxKind.forcePlay; + case 'force-hold': + return SfxKind.forceHold; + case 'kick': + return SfxKind.kick; + case 'join': + return SfxKind.join; + case 'leave': + return SfxKind.leave; + case 'stale': + return SfxKind.stale; + case 'swap': + return SfxKind.swap; + case 'seek': + return SfxKind.seek; + case 'pause': + return SfxKind.pause; + case 'resume': + return SfxKind.resume; + case 'chat': + return SfxKind.chat; + default: + return null; + } +} + +abstract class SfxService { + Future play(SfxCue cue); +} + +class NoopSfxService implements SfxService { + const NoopSfxService(); + + @override + Future play(SfxCue cue) async {} +} diff --git a/apps/desktop/lib/src/state/connect_prefs.dart b/apps/desktop/lib/src/state/connect_prefs.dart new file mode 100644 index 0000000..bd8fb5c --- /dev/null +++ b/apps/desktop/lib/src/state/connect_prefs.dart @@ -0,0 +1,21 @@ +import 'package:hive_ce/hive.dart'; + +const connectFormBoxName = 'connect_form'; + +const connectKeyHost = 'host'; +const connectKeyPort = 'port'; +const connectKeySecret = 'secret'; +const connectKeyName = 'name'; +const connectKeyAvatarPath = 'avatarPath'; +const connectKeyMediaPath = 'mediaPath'; +const connectKeyLinkUrl = 'linkUrl'; + +Future openConnectFormBox() => Hive.openBox(connectFormBoxName); + +Box? connectFormBoxIfOpen() => + Hive.isBoxOpen(connectFormBoxName) ? Hive.box(connectFormBoxName) : null; + +String readString(Box box, String key, String fallback) { + final v = box.get(key); + return v is String ? v : fallback; +} diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart new file mode 100644 index 0000000..d63c08f --- /dev/null +++ b/apps/desktop/lib/src/state/session_state.dart @@ -0,0 +1,159 @@ +import 'package:flutter/widgets.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; + +import '../sfx/sfx.dart'; +import '../session/health.dart'; +import '../session/session_coordinator.dart'; +import '../widgets/avatar_pipeline.dart'; +import 'connect_prefs.dart'; + +enum AppRoute { welcome, connect, session } + +enum ConnectMode { host, join } + +class ConnectForm { + String host; + String port; + String secret; + String name; + String avatarPath; + String mediaPath; + String linkUrl; + ConnectForm({ + this.host = '127.0.0.1', + this.port = '8080', + this.secret = '', + this.name = '', + this.avatarPath = '', + this.mediaPath = '', + this.linkUrl = '', + }); +} + +class SessionState { + static final SessionState instance = SessionState._(); + SessionState._(); + + final route = signal(AppRoute.welcome); + final connectMode = signal(ConnectMode.host); + final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); + final room = signal(null); + final SfxService sfx = const NoopSfxService(); + late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); + final lastForm = ConnectForm(); + RoomServer? _server; + + Future loadForm() async { + final box = await openConnectFormBox(); + lastForm + ..host = readString(box, connectKeyHost, '127.0.0.1') + ..port = readString(box, connectKeyPort, '8080') + ..secret = readString(box, connectKeySecret, '') + ..name = readString(box, connectKeyName, '') + ..avatarPath = readString(box, connectKeyAvatarPath, '') + ..mediaPath = readString(box, connectKeyMediaPath, '') + ..linkUrl = readString(box, connectKeyLinkUrl, ''); + } + + Future saveForm() async { + final box = connectFormBoxIfOpen() ?? await openConnectFormBox(); + await box.putAll({ + connectKeyHost: lastForm.host, + connectKeyPort: lastForm.port, + connectKeySecret: lastForm.secret, + connectKeyName: lastForm.name, + connectKeyAvatarPath: lastForm.avatarPath, + connectKeyMediaPath: lastForm.mediaPath, + connectKeyLinkUrl: lastForm.linkUrl, + }); + } + + Future loadAvatar(String path) => encodeAvatar(path); + + void goConnect(ConnectMode mode) { + connectMode.value = mode; + route.value = AppRoute.connect; + } + + Future host(ConnectParams params, ConnectForm form) async { + _rememberForm(form); + status.value = const HealthStatus(HealthSeverity.info, 'starting server…'); + await stopServer(); + final server = RoomServer(AppConfig( + port: params.port, + secret: params.secret, + name: 'room', + mediaPath: params.mediaPath, + )); + try { + await server.start(); + } catch (e, st) { + debugPrint('server start failed: $e\n$st'); + status.value = + HealthStatus(HealthSeverity.error, 'cannot bind port ${params.port}: $e'); + rethrow; + } + _server = server; + status.value = + HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}'); + await coordinator.connect(ConnectParams( + host: '127.0.0.1', + port: server.boundPort, + secret: params.secret, + name: params.name, + avatar: params.avatar, + linkUrl: params.linkUrl, + )); + route.value = AppRoute.session; + try { + await saveForm(); + } catch (e, st) { + debugPrint('save connect form failed: $e\n$st'); + } + } + + Future join(ConnectParams params, ConnectForm form) async { + _rememberForm(form); + await coordinator.connect(params); + route.value = AppRoute.session; + try { + await saveForm(); + } catch (e, st) { + debugPrint('save connect form failed: $e\n$st'); + } + } + + void _rememberForm(ConnectForm form) { + lastForm + ..host = form.host + ..port = form.port + ..secret = form.secret + ..name = form.name + ..avatarPath = form.avatarPath + ..mediaPath = form.mediaPath + ..linkUrl = form.linkUrl; + } + + Future startHeadless(List args) async { + int port = 8080; + for (var i = 0; i < args.length - 1; i++) { + if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port; + } + _server = RoomServer(AppConfig(port: port, secret: 'change-me', name: 'room')); + await _server!.start(); + } + + Future stopServer() async { + await _server?.stop(); + _server = null; + } + + Future leave() async { + await coordinator.leave(); + await stopServer(); + status.value = const HealthStatus(HealthSeverity.info, 'idle'); + route.value = AppRoute.connect; + } +} diff --git a/apps/desktop/lib/src/sync/room_diff.dart b/apps/desktop/lib/src/sync/room_diff.dart new file mode 100644 index 0000000..d644bfc --- /dev/null +++ b/apps/desktop/lib/src/sync/room_diff.dart @@ -0,0 +1,142 @@ +import 'package:sync_relay_core/core.dart'; + +sealed class PlayerAction { + const PlayerAction(); +} + +class SeekAction extends PlayerAction { + final double position; + const SeekAction(this.position); +} + +class TransportAction extends PlayerAction { + final bool playing; + const TransportAction(this.playing); +} + +class RateAction extends PlayerAction { + final double rate; + const RateAction(this.rate); +} + +double roomPositionAt(RoomBroadcast room, DateTime now) { + if (room.transport != 'playing') return room.position; + return room.position + now.difference(room.serverTime).inMilliseconds / 1000.0; +} + +bool seekLanded({required double ownPosition, required double target}) { + if ((ownPosition - target).abs() > RelayConstants.driftSeekThresholdSecs) { + return false; + } + return true; +} + +bool localPlayAllowed(RoomBroadcast room, String memberId) { + if (memberId.isEmpty) return false; + for (final m in room.members) { + if (m.id == memberId) return m.inSet && m.ready; + } + return false; +} + +class RoomDiff { + static const debounceWindow = Duration(seconds: 2); + + int _lastEventId = -1; + String _lastTransport = ''; + int _lastMediaId = -1; + bool _slowing = false; + DateTime? _deviatingSince; + int _deviatingEvent = -1; + + bool get slowing => _slowing; + + List diff(RoomBroadcast room, double ownPosition, DateTime now) { + final out = []; + final roomPos = roomPositionAt(room, now); + + if (room.mediaId != _lastMediaId && _lastMediaId != -1) { + _lastEventId = room.eventId; + _lastTransport = room.transport; + _lastMediaId = room.mediaId; + _slowing = false; + _deviatingSince = null; + out.add(const SeekAction(0)); + out.add(const TransportAction(false)); + out.add(const RateAction(1.0)); + return out; + } + + if (_lastEventId == -1) { + _lastEventId = room.eventId; + _lastTransport = room.transport; + _lastMediaId = room.mediaId; + _deviatingSince = null; + out.add(SeekAction(roomPos)); + out.add(TransportAction(room.transport == 'playing')); + return out; + } + + if (room.eventId != _lastEventId) { + _lastEventId = room.eventId; + _slowing = false; + _deviatingSince = null; + out.add(const RateAction(1.0)); + out.add(SeekAction(roomPos)); + if (room.transport != _lastTransport) { + _lastTransport = room.transport; + out.add(TransportAction(room.transport == 'playing')); + } + return out; + } + + if (room.transport != _lastTransport) { + _lastTransport = room.transport; + _slowing = false; + _deviatingSince = null; + out.add(const RateAction(1.0)); + out.add(TransportAction(room.transport == 'playing')); + out.add(SeekAction(roomPos)); + return out; + } + + final drift = ownPosition - roomPos; + if (_slowing) { + if (drift.abs() < 0.1) { + _slowing = false; + out.add(const RateAction(1.0)); + } + return out; + } + switch (correctionFor(ownPosition: ownPosition, roomPosition: roomPos)) { + case Correction.seek: + if (_deviatingEvent != room.eventId || _deviatingSince == null) { + _deviatingEvent = room.eventId; + _deviatingSince = now; + break; + } + if (now.difference(_deviatingSince!).compareTo(debounceWindow) < 0) { + break; + } + _deviatingSince = null; + out.add(SeekAction(roomPos)); + case Correction.slowDown: + _deviatingSince = null; + _slowing = true; + out.add(const RateAction(RelayConstants.slowDownRate)); + case Correction.none: + _deviatingSince = null; + break; + } + return out; + } + + void reset() { + _lastEventId = -1; + _lastTransport = ''; + _lastMediaId = -1; + _slowing = false; + _deviatingSince = null; + _deviatingEvent = -1; + } +} diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart new file mode 100644 index 0000000..2f31d61 --- /dev/null +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:sync_relay_core/core.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +enum SyncStatus { idle, connecting, open, kicked, closed, error } + +class SyncClient { + WebSocketChannel? _channel; + StreamSubscription? _sub; + final StreamController _rooms = StreamController.broadcast(); + final StreamController _feeds = StreamController.broadcast(); + final StreamController _kicked = StreamController.broadcast(); + final StreamController _errors = StreamController.broadcast(); + final StreamController _status = StreamController.broadcast(); + + Stream get rooms => _rooms.stream; + Stream get feeds => _feeds.stream; + Stream get kicked => _kicked.stream; + Stream get errors => _errors.stream; + Stream get status => _status.stream; + + String token = ''; + String memberId = ''; + String? mediaUrl; + RoomBroadcast? lastRoom; + Completer? _pendingWelcome; + + Future connect({ + required Uri wsUri, + required String secret, + required String name, + String avatar = '', + HelloMedia? media, + }) async { + await disconnect(); + _status.add(SyncStatus.connecting); + final ch = WebSocketChannel.connect(wsUri); + unawaited(ch.ready.then((_) {}, onError: (_) {})); + _channel = ch; + final welcomeCompleter = Completer(); + _sub = ch.stream.listen( + (raw) => _onText('$raw', welcomeCompleter), + onError: (Object e, StackTrace st) { + if (!welcomeCompleter.isCompleted) { + welcomeCompleter.completeError(e, st); + } + _status.add(SyncStatus.error); + }, + onDone: () { + if (!welcomeCompleter.isCompleted) { + welcomeCompleter.completeError(StateError('closed before welcome')); + } + _status.add(SyncStatus.closed); + }, + cancelOnError: false, + ); + final hello = HelloMessage( + proto: RelayConstants.protoVersion, + secret: secret, + user: HelloUser(name: name, avatar: avatar), + media: media ?? const HelloMedia(name: '', size: 0, duration: 0), + ); + ch.sink.add(jsonEncode(hello.toJson())); + _pendingWelcome = welcomeCompleter; + try { + final welcome = await welcomeCompleter.future.timeout( + const Duration(seconds: 10), + ); + _pendingWelcome = null; + token = welcome.token; + memberId = welcome.memberId; + mediaUrl = welcome.mediaUrl; + if (welcome.state != null) { + try { + lastRoom = RoomBroadcast.fromJson(welcome.state!); + _rooms.add(lastRoom!); + } catch (_) {} + } + _status.add(SyncStatus.open); + return welcome; + } catch (e) { + _pendingWelcome = null; + await disconnect(); + _status.add(SyncStatus.error); + rethrow; + } + } + + void _onText(String raw, Completer welcomeCompleter) { + Map decoded; + try { + final parsed = jsonDecode(raw); + if (parsed is! Map) return; + decoded = Map.from(parsed); + } catch (_) { + return; + } + final t = decoded['t']; + if (!welcomeCompleter.isCompleted) { + if (t == 'welcome') { + try { + welcomeCompleter.complete(WelcomeMessage.fromJson(decoded)); + } catch (e) { + welcomeCompleter.completeError(e); + } + } else if (t == 'err') { + welcomeCompleter.completeError( + ProtocolException('${decoded['message'] ?? 'rejected'}'), + ); + } + return; + } + switch (t) { + case 'room': + try { + final room = RoomBroadcast.fromJson(decoded); + lastRoom = room; + _rooms.add(room); + } catch (_) {} + case 'feed': + try { + _feeds.add(FeedMessage.fromJson(decoded)); + } catch (_) {} + case 'kicked': + try { + final msg = KickedMessage.fromJson(decoded); + _kicked.add(msg.reason); + _status.add(SyncStatus.kicked); + } catch (_) { + _kicked.add(''); + } + case 'err': + _errors.add('${decoded['message'] ?? 'error'}'); + case 'chat': + try { + _feeds.add( + FeedMessage( + actor: '${decoded['from'] ?? '?'}', + action: 'chat', + reason: (decoded['text'] as String?) ?? '', + at: DateTime.now().toUtc(), + ), + ); + } catch (_) {} + } + } + + void _send(Map json) { + final ch = _channel; + if (ch == null) return; + try { + ch.sink.add(jsonEncode(json)); + } catch (_) {} + } + + void sendReady(bool ready, {String source = 'manual'}) { + _send(ReadyIntent(ready: ready, source: source).toJson()); + } + + void sendForce(String target, bool ready) { + _send(ForceIntent(target: target, ready: ready).toJson()); + } + + void sendSeek(double position) { + if (position.isNaN || position < 0) return; + _send(SeekIntent(position: position).toJson()); + } + + void sendSwap(HelloMedia media) { + _send(SwapIntent(media: media).toJson()); + } + + void sendReport({ + required double position, + required double buffered, + required int eventId, + }) { + final safeBuffered = buffered.isNaN || buffered < 0 ? 0.0 : buffered; + _send( + ReportMessage( + position: position, + buffered: safeBuffered, + eventId: eventId, + ).toJson(), + ); + } + + void sendChat(String text) { + final clean = text.length > RelayConstants.maxChatLength + ? text.substring(0, RelayConstants.maxChatLength) + : text; + _send(ChatMessage(text: clean).toJson()); + } + + void sendKick(String target, {String reason = ''}) { + _send(KickRequest(target: target, reason: reason).toJson()); + } + + Future disconnect() async { + final pending = _pendingWelcome; + _pendingWelcome = null; + if (pending != null && !pending.isCompleted) { + pending.completeError(StateError('cancelled')); + } try { + await _sub?.cancel(); + } catch (_) {} + _sub = null; + try { + await _channel?.sink.close(); + } catch (_) {} + _channel = null; + token = ''; + memberId = ''; + mediaUrl = null; + lastRoom = null; + } + + Future dispose() async { + await disconnect(); + await _rooms.close(); + await _feeds.close(); + await _kicked.close(); + await _errors.close(); + await _status.close(); + } +} diff --git a/apps/desktop/lib/src/widgets/avatar_pipeline.dart b/apps/desktop/lib/src/widgets/avatar_pipeline.dart new file mode 100644 index 0000000..a382031 --- /dev/null +++ b/apps/desktop/lib/src/widgets/avatar_pipeline.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +Future encodeAvatar(String path) async { + final trimmed = path.trim(); + if (trimmed.isEmpty) return ''; + final file = File(trimmed); + if (!await file.exists()) { + throw const FormatException( + 'avatar file not found — check the path or pick again with Browse'); + } + late final List raw; + try { + raw = await file.readAsBytes(); + } catch (_) { + throw const FormatException( + 'could not read that avatar file — pick it again with Browse'); + } + if (raw.isEmpty) { + throw const FormatException( + 'that avatar file is empty — pick a PNG, JPG or WebP photo'); + } + final decoded = img.decodeImage(Uint8List.fromList(raw)); + if (decoded == null) { + throw const FormatException( + 'could not read that image — pick a PNG, JPG or WebP photo'); + } + final side = + decoded.width < decoded.height ? decoded.width : decoded.height; + final x = (decoded.width - side) ~/ 2; + final y = (decoded.height - side) ~/ 2; + final cropped = + img.copyCrop(decoded, x: x, y: y, width: side, height: side); + final resized = img.copyResize(cropped, width: 128, height: 128); + final jpg = img.encodeJpg(resized, quality: 85); + if (jpg.length > 64 * 1024) { + throw const FormatException( + 'that photo is still too large after shrinking — pick a smaller one'); + } + return base64Encode(jpg); +} diff --git a/apps/desktop/lib/src/widgets/dialogs.dart b/apps/desktop/lib/src/widgets/dialogs.dart new file mode 100644 index 0000000..f0f19c6 --- /dev/null +++ b/apps/desktop/lib/src/widgets/dialogs.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +Future showKickConfirmDialog( + BuildContext context, String memberName) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Remove $memberName?'), + content: const Text('They can come straight back in.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ), + ); +} diff --git a/apps/desktop/lib/src/widgets/media_input.dart b/apps/desktop/lib/src/widgets/media_input.dart new file mode 100644 index 0000000..8562878 --- /dev/null +++ b/apps/desktop/lib/src/widgets/media_input.dart @@ -0,0 +1,8 @@ +({String mediaPath, String linkUrl}) parseMediaInput(String input) { + final trimmed = input.trim(); + final lower = trimmed.toLowerCase(); + if (lower.startsWith('http://') || lower.startsWith('https://')) { + return (mediaPath: '', linkUrl: trimmed); + } + return (mediaPath: trimmed, linkUrl: ''); +} diff --git a/apps/desktop/lib/src/widgets/member_avatar.dart b/apps/desktop/lib/src/widgets/member_avatar.dart new file mode 100644 index 0000000..85555f8 --- /dev/null +++ b/apps/desktop/lib/src/widgets/member_avatar.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +class MemberAvatar extends StatelessWidget { + final String avatarBase64; + final String name; + final double size; + + static final Map _images = {}; + + const MemberAvatar({ + super.key, + required this.avatarBase64, + required this.name, + this.size = 40, + }); + + static MemoryImage? _providerFor(String raw) { + if (raw.isEmpty) return null; + var hit = _images[raw]; + if (hit != null) return hit; + try { + hit = MemoryImage(base64Decode(raw)); + } catch (_) { + return null; + } + if (_images.length > 64) _images.clear(); + _images[raw] = hit; + return hit; + } + + @override + Widget build(BuildContext context) { + final image = _providerFor(avatarBase64); + final decoration = BoxDecoration( + shape: BoxShape.circle, + ); + if (image != null) { + return Container( + decoration: decoration, + child: ClipOval( + child: Image( + image: ResizeImage(height: (size * MediaQuery.of(context).devicePixelRatio).round(), image), + width: size, + height: size, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, __, ___) => _fallback(), + ), + ), + ); + } + return Container(decoration: decoration, child: _fallback()); + } + + Widget _fallback() => CircleAvatar( + radius: size / 2, + backgroundColor: Colors.grey.shade800, + child: Text( + name.isEmpty ? '?' : name[0].toUpperCase(), + style: TextStyle(fontSize: size * 0.4, color: Colors.white), + ), + ); +} diff --git a/apps/desktop/lib/src/widgets/user_section.dart b/apps/desktop/lib/src/widgets/user_section.dart new file mode 100644 index 0000000..e10fa19 --- /dev/null +++ b/apps/desktop/lib/src/widgets/user_section.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../state/session_state.dart'; +import 'avatar_pipeline.dart'; + +/// Profile card for the connect screen. +/// +/// Lives in a fixed corner card outside the scrolling form, so Host/Join +/// field changes below never move it. Name edits write back to +/// [SessionState.lastForm] on every change (persisted on submit); avatar +/// changes by tapping the circle, validated through [encodeAvatar] before +/// the path is stored, with the encoded bytes shown as the preview. +class ConnectProfileCard extends StatefulWidget { + final TextEditingController name; + const ConnectProfileCard({super.key, required this.name}); + + @override + State createState() => _ConnectProfileCardState(); +} + +class _ConnectProfileCardState extends State { + late String _avatarPath; + Uint8List? _preview; + String? _error; + bool _picking = false; + + @override + void initState() { + super.initState(); + _avatarPath = SessionState.instance.lastForm.avatarPath; + _refreshPreview(); + } + + Future _refreshPreview() async { + String b64; + try { + b64 = await encodeAvatar(_avatarPath); + } catch (_) { + return; + } + if (!mounted) return; + setState(() => _preview = b64.isEmpty ? null : base64Decode(b64)); + } + + Future _persistProfile() async { + try { + await SessionState.instance.saveForm(); + } catch (_) { + if (!mounted) return; + setState(() => _error = 'could not save profile — try again'); + } + } + + Future _pickAvatar() async { + if (_picking) return; + setState(() { + _picking = true; + _error = null; + }); + try { + final List files; + try { + files = await FilePicker.pickFiles( + dialogTitle: 'Pick avatar image', + type: FileType.custom, + allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'], + ); + } catch (_) { + if (!mounted) return; + setState(() => _error = 'file dialog unavailable — try again'); + return; + } + if (files.isEmpty) return; + final path = files.first.path; + if (path == null || !mounted) return; + late final String b64; + try { + b64 = await encodeAvatar(path); + } on FormatException catch (e) { + if (!mounted) return; + setState(() => _error = e.message); + return; + } catch (_) { + if (!mounted) return; + setState(() => _error = + 'could not read that image — pick a PNG, JPG or WebP photo'); + return; + } + _avatarPath = path; + SessionState.instance.lastForm.avatarPath = path; + await _persistProfile(); + if (!mounted) return; + setState(() => _preview = b64.isEmpty ? null : base64Decode(b64)); + } finally { + if (mounted) setState(() => _picking = false); + } + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _pickAvatar, + child: Tooltip( + message: 'Change avatar', + child: CircleAvatar( + radius: 22, + backgroundColor: Colors.grey.shade800, + backgroundImage: _preview != null ? MemoryImage(_preview!) : null, + child: _preview != null + ? null + : _picking + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : ListenableBuilder( + listenable: widget.name, + builder: (context, _) { + final t = widget.name.text.trim(); + return Text(t.isEmpty ? '?' : t[0].toUpperCase()); + }, + ), + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 180, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: widget.name, + decoration: const InputDecoration( + labelText: 'Display name', + isDense: true, + ), + onChanged: (_) { + SessionState.instance.lastForm.name = widget.name.text.trim(); + }, + onSubmitted: (_) => _persistProfile(), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_error!, style: const TextStyle(fontSize: 11)), + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/linux/.gitignore b/apps/desktop/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/apps/desktop/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/desktop/linux/CMakeLists.txt b/apps/desktop/linux/CMakeLists.txt new file mode 100644 index 0000000..09ac0f9 --- /dev/null +++ b/apps/desktop/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "watch_together") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.watch_together") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/desktop/linux/flutter/CMakeLists.txt b/apps/desktop/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/apps/desktop/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/desktop/linux/flutter/generated_plugin_registrant.cc b/apps/desktop/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e4b9cf8 --- /dev/null +++ b/apps/desktop/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); +} diff --git a/apps/desktop/linux/flutter/generated_plugin_registrant.h b/apps/desktop/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/apps/desktop/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/desktop/linux/flutter/generated_plugins.cmake b/apps/desktop/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..04c38ab --- /dev/null +++ b/apps/desktop/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_linux + media_kit_video +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/desktop/linux/runner/CMakeLists.txt b/apps/desktop/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/apps/desktop/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/desktop/linux/runner/main.cc b/apps/desktop/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/apps/desktop/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/desktop/linux/runner/my_application.cc b/apps/desktop/linux/runner/my_application.cc new file mode 100644 index 0000000..1642d3e --- /dev/null +++ b/apps/desktop/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "watch_together"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "watch_together"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/apps/desktop/linux/runner/my_application.h b/apps/desktop/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/apps/desktop/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock new file mode 100644 index 0000000..9093fba --- /dev/null +++ b/apps/desktop/pubspec.lock @@ -0,0 +1,848 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_hooks: + dependency: transitive + description: + name: flutter_hooks + sha256: "8ae1f090e5f4ef5cfa6670ce1ab5dddadd33f3533a7f9ba19d9f958aa2a89f42" + url: "https://pub.dev" + source: hosted + version: "0.21.3+1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hive_ce: + dependency: "direct main" + description: + name: hive_ce + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" + url: "https://pub.dev" + source: hosted + version: "2.19.3" + hive_ce_flutter: + dependency: "direct main" + description: + name: hive_ce_flutter + sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + isolate_channel: + dependency: transitive + description: + name: isolate_channel + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 + url: "https://pub.dev" + source: hosted + version: "0.6.1" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: transitive + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_libs_ios_video: + dependency: transitive + description: + name: media_kit_libs_ios_video + sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_linux: + dependency: transitive + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_macos_video: + dependency: transitive + description: + name: media_kit_libs_macos_video + sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + media_kit_libs_video: + dependency: "direct main" + description: + name: media_kit_libs_video + sha256: "2b235b5dac79c6020e01eef5022c6cc85fedc0df1738aadc6ea489daa12a92a9" + url: "https://pub.dev" + source: hosted + version: "1.0.7" + media_kit_libs_windows_video: + dependency: transitive + description: + name: media_kit_libs_windows_video + sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab + url: "https://pub.dev" + source: hosted + version: "1.0.11" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + preact_signals: + dependency: transitive + description: + name: preact_signals + sha256: "18f45e7f91a4dfad8bd0138efa6b85770bb25e13278b863fdaafd28a56579b16" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + relic: + dependency: transitive + description: + name: relic + sha256: d917b188dd1b6838c630d1904a1e26b040e6647406f2aab01b10fdf0ca051c12 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_core: + dependency: transitive + description: + name: relic_core + sha256: c2f7638d56fdf144a035d17eb8e93fb30fe53bd105fd81c31393f8be4f43ecdc + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_io: + dependency: transitive + description: + name: relic_io + sha256: d64a9f309943200ec7a45b2a127cbc5abf4dd6603159a179396d66b932a15f1f + url: "https://pub.dev" + source: hosted + version: "1.2.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "494b982d5edb71030650ea463d939670e91b232b588323dc75229d2c5f23e7b7" + url: "https://pub.dev" + source: hosted + version: "2.0.6" + signals: + dependency: "direct main" + description: + name: signals + sha256: "3b6be91d04808b542612a525eaeb1875e50be153671e618a42f74e0607088ecd" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + signals_core: + dependency: transitive + description: + name: signals_core + sha256: "04d7ccb0265264191a4e2666050031129f47c2bbf4ac3f31f6586889a9903b62" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + signals_flutter: + dependency: transitive + description: + name: signals_flutter + sha256: f0285fd2f98d720724df879c258dc41725f260f8e5b1184655ef64ba679197ad + url: "https://pub.dev" + source: hosted + version: "7.1.0" + signals_hooks: + dependency: transitive + description: + name: signals_hooks + sha256: af6c2c4be47f06214292fedab6f6e943433c02086c956eeaffe45c4a23a57413 + url: "https://pub.dev" + source: hosted + version: "7.1.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_relay_core: + dependency: "direct main" + description: + path: "../../packages/core" + relative: true + source: path + version: "0.1.0" + sync_relay_server: + dependency: "direct main" + description: + path: "../../packages/server" + relative: true + source: path + version: "0.1.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384" + url: "https://pub.dev" + source: hosted + version: "1.7.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.14.0-0 <4.0.0" + flutter: ">=3.44.0" diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml new file mode 100644 index 0000000..a9cbfd6 --- /dev/null +++ b/apps/desktop/pubspec.yaml @@ -0,0 +1,30 @@ +name: watch_together +description: Watch-together desktop app (working title, repo sync_relay_project). One binary. +publish_to: none +version: 0.1.0 +environment: + sdk: '>=3.14.0-0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + signals: ^7.0.0 + media_kit: ^1.1.0 + media_kit_video: ^2.0.1 + media_kit_libs_video: ^1.0.0 + path_provider: ^2.1.0 + web_socket_channel: ^3.0.0 + sync_relay_core: + path: ../../packages/core + sync_relay_server: + path: ../../packages/server + file_picker: ^12.3.0 + hive_ce: ^2.19.3 + hive_ce_flutter: ^2.3.4 + image: ^4.9.2 +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 +flutter: + uses-material-design: true diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart new file mode 100644 index 0000000..a83d9e8 --- /dev/null +++ b/apps/desktop/test/coord_presence_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:watch_together/src/session/session_coordinator.dart'; + +Future _poll( + FutureOr Function() cond, + String what, { + Duration timeout = const Duration(seconds: 15), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 200)); + } + fail('timed out waiting for $what'); +} + +void main() { + test( + 'dead server clears room state and reconnects instead of freezing', + timeout: const Timeout(Duration(minutes: 2)), + () async { + MediaKit.ensureInitialized(); + final server = RoomServer( + const AppConfig(port: 0, secret: 'presence', name: 'presence')); + await server.start(); + final coord = SessionCoordinator(); + try { + await coord.connect(ConnectParams( + host: '127.0.0.1', + port: server.boundPort, + secret: 'presence', + name: 'watcher', + )); + await coord.playback.player.setVolume(0); + await _poll(() => coord.room.peek() != null, 'room state to arrive'); + expect(coord.reconnecting.peek(), isFalse); + + await server.stop(); + await _poll( + () => coord.reconnecting.peek() == true, + 'reconnect loop to engage', + ); + await _poll( + () => coord.room.peek() == null, + 'stale room (ghost members) to clear', + ); + coord.cancelReconnect(); + expect(coord.reconnecting.peek(), isFalse); + } finally { + await coord.dispose(); + } + }, + ); +} diff --git a/apps/desktop/test/media_input_test.dart b/apps/desktop/test/media_input_test.dart new file mode 100644 index 0000000..7c8dc58 --- /dev/null +++ b/apps/desktop/test/media_input_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/widgets/media_input.dart'; + +void main() { + test('http url routes to linkUrl', () { + expect(parseMediaInput('http://example.com/v.mp4'), + (mediaPath: '', linkUrl: 'http://example.com/v.mp4')); + }); + + test('https url routes to linkUrl', () { + expect(parseMediaInput('https://example.com/v.mp4'), + (mediaPath: '', linkUrl: 'https://example.com/v.mp4')); + }); + + test('scheme match is case-insensitive and trimmed', () { + expect(parseMediaInput(' HTTPS://example.com/v.mp4 '), + (mediaPath: '', linkUrl: 'HTTPS://example.com/v.mp4')); + }); + + test('file path routes to mediaPath', () { + expect(parseMediaInput('/home/u/Videos/chiyo.mp4'), + (mediaPath: '/home/u/Videos/chiyo.mp4', linkUrl: '')); + }); + + test('empty input stays empty', () { + expect(parseMediaInput(' '), (mediaPath: '', linkUrl: '')); + }); + + test('non-http scheme stays a path', () { + expect(parseMediaInput('ftp://example.com/v.mp4'), + (mediaPath: 'ftp://example.com/v.mp4', linkUrl: '')); + }); +} diff --git a/apps/desktop/test/room_diff_test.dart b/apps/desktop/test/room_diff_test.dart new file mode 100644 index 0000000..767e540 --- /dev/null +++ b/apps/desktop/test/room_diff_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/sync/room_diff.dart'; + +RoomBroadcast _room({ + int eventId = 0, + String transport = 'held', + double position = 10.0, + int mediaId = 1, +}) { + final now = DateTime.now().toUtc(); + return RoomBroadcast( + eventId: eventId, + transport: transport, + position: position, + serverTime: now, + mediaId: mediaId, + members: const [], + ); +} + +RoomBroadcast _roomWithMembers(List members, + {String transport = 'playing'}) { + final now = DateTime.now().toUtc(); + return RoomBroadcast( + eventId: 3, + transport: transport, + position: 10.0, + serverTime: now, + mediaId: 1, + members: members, + ); +} + +void main() { + test('initial diff seeks to room edge and sets transport', () { + final diff = RoomDiff(); + final actions = diff.diff(_room(), 0, DateTime.now().toUtc()); + expect(actions.whereType().single.position, closeTo(10.0, 1.0)); + expect(actions.whereType().single.playing, isFalse); + }); + + test('same event small drift does nothing', () { + final diff = RoomDiff(); + final now = DateTime.now().toUtc(); + diff.diff(_room(), 0, now); + final actions = diff.diff(_room(), 10.5, now); + expect(actions, isEmpty); + }); + + test('ahead drift latches slowdown, resets inside 0.1', () { + final diff = RoomDiff(); + final now = DateTime.now().toUtc(); + diff.diff(_room(), 0, now); + var actions = diff.diff(_room(), 13.0, now); + expect(actions.whereType().single.rate, 0.95); + actions = diff.diff(_room(), 12.0, now); + expect(actions, isEmpty); + actions = diff.diff(_room(), 10.05, now); + expect(actions.whereType().single.rate, 1.0); + }); + + test('eventId jump seeks and resets rate', () { + final diff = RoomDiff(); + final now = DateTime.now().toUtc(); + diff.diff(_room(), 0, now); + diff.diff(_room(), 13.0, now); + final actions = + diff.diff(_room(eventId: 1, position: 90.0), 13.0, now); + expect(actions.whereType().single.position, closeTo(90.0, 1.0)); + expect(actions.whereType().single.rate, 1.0); + }); + + test('media swap seeks zero and pauses', () { + final diff = RoomDiff(); + final now = DateTime.now().toUtc(); + diff.diff(_room(transport: 'playing'), 10.0, now); + final actions = diff.diff( + _room(eventId: 2, transport: 'held', position: 0, mediaId: 2), + 50.0, + now); + expect(actions.whereType().single.position, 0); + expect(actions.whereType().single.playing, isFalse); + }); + + group('seek debounce while playing', () { + RoomBroadcast playing({int eventId = 5, double position = 100.0}) => _room( + eventId: eventId, transport: 'playing', position: position); + + test('fresh big drift is swallowed first, seeks after window', () { + final diff = RoomDiff(); + final t0 = DateTime.now().toUtc(); + diff.diff(playing(), 100.0, t0); + var actions = diff.diff(playing(), 120.0, t0); + expect(actions.whereType(), isEmpty, + reason: 'user scrub must not be yanked back before detector runs'); + actions = diff.diff( + playing(), 120.0, t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100))); + expect(actions.whereType().single.position, + closeTo(102.0, 3.0), + reason: 'genuine drift still converges after the window'); + }); + + test('new eventId seeks immediately (echo of promoted seek)', () { + final diff = RoomDiff(); + final t0 = DateTime.now().toUtc(); + diff.diff(playing(), 100.0, t0); + diff.diff(playing(), 120.0, t0); + final actions = diff.diff(playing(eventId: 6, position: 120.0), 120.0, t0); + expect(actions.whereType().single.position, + closeTo(120.0, 2.0)); + }); + + test('held transport debounces the same way (detector promotes)', () { + final diff = RoomDiff(); + final t0 = DateTime.now().toUtc(); + diff.diff(_room(), 10.0, t0); + var actions = diff.diff(_room(), 60.0, t0); + expect(actions.whereType(), isEmpty); + actions = diff.diff(_room(), 60.0, + t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100))); + expect(actions.whereType().single.position, + closeTo(10.0, 3.0)); + }); + }); + + group('local play gate', () { + const ready = MemberView( + id: 'm1', + name: 'a', + ready: true, + inSet: true, + buffered: 9, + stale: false); + const catchingUp = MemberView( + id: 'm2', + name: 'b', + ready: false, + inSet: false, + buffered: 0, + stale: false); + const unreadyInSet = MemberView( + id: 'm3', + name: 'c', + ready: false, + inSet: true, + buffered: 9, + stale: false); + + test('only in-set ready members may follow transport to playing', () { + final room = _roomWithMembers([ready, catchingUp, unreadyInSet]); + expect(localPlayAllowed(room, 'm1'), isTrue); + expect(localPlayAllowed(room, 'm2'), isFalse); + expect(localPlayAllowed(room, 'm3'), isFalse); + expect(localPlayAllowed(room, 'nope'), isFalse); + expect(localPlayAllowed(room, ''), isFalse); + }); + }); +} diff --git a/apps/desktop/test/sync_playback_e2e_test.dart b/apps/desktop/test/sync_playback_e2e_test.dart new file mode 100644 index 0000000..4593b25 --- /dev/null +++ b/apps/desktop/test/sync_playback_e2e_test.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:watch_together/src/session/session_coordinator.dart'; + +const _port = 18080; +const _secret = 'e2e-secret'; +const _mediaPath = '/home/foxyz/Videos/chiyo.mp4'; + +Future _poll( + FutureOr Function() cond, + String what, { + Duration timeout = const Duration(seconds: 30), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 200)); + } + fail('timed out waiting for $what'); +} + +Future _quiet(SessionCoordinator c) async { + await c.playback.player.setVolume(0); + try { + final dynamic platform = c.playback.player.platform; + await platform?.setProperty('loop-file', 'inf'); + } catch (_) {} +} + +void main() { + test( + 'two app coordinators split play on readiness over real server', + timeout: const Timeout(Duration(minutes: 4)), + () async { + MediaKit.ensureInitialized(); + final server = RoomServer(const AppConfig( + port: _port, + secret: _secret, + name: 'room', + mediaPath: _mediaPath, + )); + await server.start(); + + final host = SessionCoordinator(); + final viewer = SessionCoordinator(); + try { + await host.connect(const ConnectParams( + host: '127.0.0.1', + port: _port, + secret: _secret, + name: 'host', + )); + await viewer.connect(const ConnectParams( + host: '127.0.0.1', + port: _port, + secret: _secret, + name: 'viewer', + )); + await _quiet(host); + await _quiet(viewer); + + expect(host.playback.player.state.playing, isFalse, + reason: 'media must open paused, not autoplay'); + expect(viewer.playback.player.state.playing, isFalse); + + await _poll(() => host.room.peek() != null, 'host room state'); + host.toggleReady(); + await Future.delayed(const Duration(seconds: 5)); + expect(host.room.peek()?.transport, 'held', + reason: 'one Space with company present must not solo-play'); + expect(host.playback.player.state.playing, isFalse); + expect(viewer.playback.player.state.playing, isFalse); + + viewer.toggleReady(); + await _poll( + () => + host.room.peek()?.transport == 'playing' && + viewer.playback.player.state.playing && + host.playback.player.state.playing, + 'both players playing once quorum readies', + ); + } finally { + await host.dispose(); + await viewer.dispose(); + await server.stop(); + } + }, + ); +} diff --git a/apps/desktop/windows/.gitignore b/apps/desktop/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/apps/desktop/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/apps/desktop/windows/CMakeLists.txt b/apps/desktop/windows/CMakeLists.txt new file mode 100644 index 0000000..733de55 --- /dev/null +++ b/apps/desktop/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(watch_together LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "watch_together") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/apps/desktop/windows/flutter/CMakeLists.txt b/apps/desktop/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/apps/desktop/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/apps/desktop/windows/flutter/generated_plugin_registrant.cc b/apps/desktop/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e402a65 --- /dev/null +++ b/apps/desktop/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi")); + MediaKitVideoPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); +} diff --git a/apps/desktop/windows/flutter/generated_plugin_registrant.h b/apps/desktop/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/apps/desktop/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/desktop/windows/flutter/generated_plugins.cmake b/apps/desktop/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..e0c93a1 --- /dev/null +++ b/apps/desktop/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_windows_video + media_kit_video +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/desktop/windows/runner/CMakeLists.txt b/apps/desktop/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/apps/desktop/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/desktop/windows/runner/Runner.rc b/apps/desktop/windows/runner/Runner.rc new file mode 100644 index 0000000..fb8e923 --- /dev/null +++ b/apps/desktop/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "watch_together" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "watch_together" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "watch_together.exe" "\0" + VALUE "ProductName", "watch_together" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/apps/desktop/windows/runner/flutter_window.cpp b/apps/desktop/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/apps/desktop/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/apps/desktop/windows/runner/flutter_window.h b/apps/desktop/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/apps/desktop/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/desktop/windows/runner/main.cpp b/apps/desktop/windows/runner/main.cpp new file mode 100644 index 0000000..d6f7981 --- /dev/null +++ b/apps/desktop/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"watch_together", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/apps/desktop/windows/runner/resource.h b/apps/desktop/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/apps/desktop/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/apps/desktop/windows/runner/resources/app_icon.ico b/apps/desktop/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 zcmeHQc|26z|35SKE&G-*mXah&B~fFkXr)DEO&hIfqby^T&>|8^_Ub8Vp#`BLl3lbZ zvPO!8k!2X>cg~Elr=IVxo~J*a`+9wR=A83c-k-DFd(XM&UI1VKCqM@V;DDtJ09WB} zRaHKiW(GT00brH|0EeTeKVbpbGZg?nK6-j827q-+NFM34gXjqWxJ*a#{b_apGN<-L_m3#8Z26atkEn& ze87Bvv^6vVmM+p+cQ~{u%=NJF>#(d;8{7Q{^rWKWNtf14H}>#&y7$lqmY6xmZryI& z($uy?c5-+cPnt2%)R&(KIWEXww>Cnz{OUpT>W$CbO$h1= z#4BPMkFG1Y)x}Ui+WXr?Z!w!t_hjRq8qTaWpu}FH{MsHlU{>;08goVLm{V<&`itk~ zE_Ys=D(hjiy+5=?=$HGii=Y5)jMe9|wWoD_K07(}edAxh`~LBorOJ!Cf@f{_gNCC| z%{*04ViE!#>@hc1t5bb+NO>ncf@@Dv01K!NxH$3Eg1%)|wLyMDF8^d44lV!_Sr}iEWefOaL z8f?ud3Q%Sen39u|%00W<#!E=-RpGa+H8}{ulxVl4mwpjaU+%2pzmi{3HM)%8vb*~-M9rPUAfGCSos8GUXp02|o~0BTV2l#`>>aFV&_P$ejS;nGwSVP8 zMbOaG7<7eKD>c12VdGH;?2@q7535sa7MN*L@&!m?L`ASG%boY7(&L5imY#EQ$KrBB z4@_tfP5m50(T--qv1BJcD&aiH#b-QC>8#7Fx@3yXlonJI#aEIi=8&ChiVpc#N=5le zM*?rDIdcpawoc5kizv$GEjnveyrp3sY>+5_R5;>`>erS%JolimF=A^EIsAK zsPoVyyUHCgf0aYr&alx`<)eb6Be$m&`JYSuBu=p8j%QlNNp$-5C{b4#RubPb|CAIS zGE=9OFLP7?Hgc{?k45)84biT0k&-C6C%Q}aI~q<(7BL`C#<6HyxaR%!dFx7*o^laG z=!GBF^cwK$IA(sn9y6>60Rw{mYRYkp%$jH z*xQM~+bp)G$_RhtFPYx2HTsWk80+p(uqv9@I9)y{b$7NK53rYL$ezbmRjdXS?V}fj zWxX_feWoLFNm3MG7pMUuFPs$qrQWO9!l2B(SIuy2}S|lHNbHzoE+M2|Zxhjq9+Ws8c{*}x^VAib7SbxJ*Q3EnY5lgI9 z=U^f3IW6T=TWaVj+2N%K3<%Un;CF(wUp`TC&Y|ZjyFu6co^uqDDB#EP?DV5v_dw~E zIRK*BoY9y-G_ToU2V_XCX4nJ32~`czdjT!zwme zGgJ0nOk3U4@IE5JwtM}pwimLjk{ln^*4HMU%Fl4~n(cnsLB}Ja-jUM>xIB%aY;Nq8 z)Fp8dv1tkqKanv<68o@cN|%thj$+f;zGSO7H#b+eMAV8xH$hLggtt?O?;oYEgbq@= zV(u9bbd12^%;?nyk6&$GPI%|+<_mEpJGNfl*`!KV;VfmZWw{n{rnZ51?}FDh8we_L z8OI9nE31skDqJ5Oa_ybn7|5@ui>aC`s34p4ZEu6-s!%{uU45$Zd1=p$^^dZBh zu<*pDDPLW+c>iWO$&Z_*{VSQKg7=YEpS3PssPn1U!lSm6eZIho*{@&20e4Y_lRklKDTUCKI%o4Pc<|G^Xgu$J^Q|B87U;`c1zGwf^-zH*VQ^x+i^OUWE0yd z;{FJq)2w!%`x7yg@>uGFFf-XJl4H`YtUG%0slGKOlXV`q?RP>AEWg#x!b{0RicxGhS!3$p7 zij;{gm!_u@D4$Ox%>>bPtLJ> zwKtYz?T_DR1jN>DkkfGU^<#6sGz|~p*I{y`aZ>^Di#TC|Z!7j_O1=Wo8thuit?WxR zh9_S>kw^{V^|g}HRUF=dcq>?q(pHxw!8rx4dC6vbQVmIhmICF#zU!HkHpQ>9S%Uo( zMw{eC+`&pb=GZRou|3;Po1}m46H6NGd$t<2mQh}kaK-WFfmj_66_17BX0|j-E2fe3Jat}ijpc53 zJV$$;PC<5aW`{*^Z6e5##^`Ed#a0nwJDT#Qq~^e8^JTA=z^Kl>La|(UQ!bI@#ge{Dzz@61p-I)kc2?ZxFt^QQ}f%ldLjO*GPj(5)V9IyuUakJX=~GnTgZ4$5!3E=V#t`yOG4U z(gphZB6u2zsj=qNFLYShhg$}lNpO`P9xOSnO*$@@UdMYES*{jJVj|9z-}F^riksLK zbsU+4-{281P9e2UjY6tse^&a)WM1MFw;p#_dHhWI7p&U*9TR0zKdVuQed%6{otTsq z$f~S!;wg#Bd9kez=Br{m|66Wv z#g1xMup<0)H;c2ZO6su_ii&m8j&+jJz4iKnGZ&wxoQX|5a>v&_e#6WA!MB_4asTxLRGQCC5cI(em z%$ZfeqP>!*q5kU>a+BO&ln=4Jm>Ef(QE8o&RgLkk%2}4Tf}U%IFP&uS7}&|Q-)`5< z+e>;s#4cJ-z%&-^&!xsYx777Wt(wZY9(3(avmr|gRe4cD+a8&!LY`1^T?7x{E<=kdY9NYw>A;FtTvQ=Y&1M%lyZPl$ss1oY^Sl8we}n}Aob#6 zl4jERwnt9BlSoWb@3HxYgga(752Vu6Y)k4yk9u~Kw>cA5&LHcrvn1Y-HoIuFWg~}4 zEw4bR`mXZQIyOAzo)FYqg?$5W<;^+XX%Uz61{-L6@eP|lLH%|w?g=rFc;OvEW;^qh z&iYXGhVt(G-q<+_j}CTbPS_=K>RKN0&;dubh0NxJyDOHFF;<1k!{k#7b{|Qok9hac z;gHz}6>H6C6RnB`Tt#oaSrX0p-j-oRJ;_WvS-qS--P*8}V943RT6kou-G=A+7QPGQ z!ze^UGxtW3FC0$|(lY9^L!Lx^?Q8cny(rR`es5U;-xBhphF%_WNu|aO<+e9%6LuZq zt(0PoagJG<%hyuf;te}n+qIl_Ej;czWdc{LX^pS>77s9t*2b4s5dvP_!L^3cwlc)E!(!kGrg~FescVT zZCLeua3f4;d;Tk4iXzt}g}O@nlK3?_o91_~@UMIl?@77Qc$IAlLE95#Z=TES>2E%z zxUKpK{_HvGF;5%Q7n&vA?`{%8ohlYT_?(3A$cZSi)MvIJygXD}TS-3UwyUxGLGiJP znblO~G|*uA^|ac8E-w#}uBtg|s_~s&t>-g0X%zIZ@;o_wNMr_;{KDg^O=rg`fhDZu zFp(VKd1Edj%F zWHPl+)FGj%J1BO3bOHVfH^3d1F{)*PL&sRX`~(-Zy3&9UQX)Z;c51tvaI2E*E7!)q zcz|{vpK7bjxix(k&6=OEIBJC!9lTkUbgg?4-yE{9+pFS)$Ar@vrIf`D0Bnsed(Cf? zObt2CJ>BKOl>q8PyFO6w)+6Iz`LW%T5^R`U_NIW0r1dWv6OY=TVF?N=EfA(k(~7VBW(S;Tu5m4Lg8emDG-(mOSSs=M9Q&N8jc^Y4&9RqIsk(yO_P(mcCr}rCs%1MW1VBrn=0-oQN(Xj!k%iKV zb%ricBF3G4S1;+8lzg5PbZ|$Se$)I=PwiK=cDpHYdov2QO1_a-*dL4KUi|g&oh>(* zq$<`dQ^fat`+VW?m)?_KLn&mp^-@d=&7yGDt<=XwZZC=1scwxO2^RRI7n@g-1o8ps z)&+et_~)vr8aIF1VY1Qrq~Xe``KJrQSnAZ{CSq3yP;V*JC;mmCT6oRLSs7=GA?@6g zUooM}@tKtx(^|aKK8vbaHlUQqwE0}>j&~YlN3H#vKGm@u)xxS?n9XrOWUfCRa< z`20Fld2f&;gg7zpo{Adh+mqNntMc-D$N^yWZAZRI+u1T1zWHPxk{+?vcS1D>08>@6 zLhE@`gt1Y9mAK6Z4p|u(5I%EkfU7rKFSM=E4?VG9tI;a*@?6!ey{lzN5=Y-!$WFSe z&2dtO>^0@V4WRc#L&P%R(?@KfSblMS+N+?xUN$u3K4Ys%OmEh+tq}fnU}i>6YHM?< zlnL2gl~sF!j!Y4E;j3eIU-lfa`RsOL*Tt<%EFC0gPzoHfNWAfKFIKZN8}w~(Yi~=q z>=VNLO2|CjkxP}RkutxjV#4fWYR1KNrPYq5ha9Wl+u>ipsk*I(HS@iLnmGH9MFlTU zaFZ*KSR0px>o+pL7BbhB2EC1%PJ{67_ z#kY&#O4@P=OV#-79y_W>Gv2dxL*@G7%LksNSqgId9v;2xJ zrh8uR!F-eU$NMx@S*+sk=C~Dxr9Qn7TfWnTupuHKuQ$;gGiBcU>GF5sWx(~4IP3`f zWE;YFO*?jGwYh%C3X<>RKHC-DZ!*r;cIr}GLOno^3U4tFSSoJp%oHPiSa%nh=Zgn% z14+8v@ygy0>UgEN1bczD6wK45%M>psM)y^)IfG*>3ItX|TzV*0i%@>L(VN!zdKb8S?Qf7BhjNpziA zR}?={-eu>9JDcl*R=OP9B8N$IcCETXah9SUDhr{yrld{G;PnCWRsPD7!eOOFBTWUQ=LrA_~)mFf&!zJX!Oc-_=kT<}m|K52 z)M=G#;p;Rdb@~h5D{q^K;^fX-m5V}L%!wVC2iZ1uu401Ll}#rocTeK|7FAeBRhNdQ zCc2d^aQnQp=MpOmak60N$OgS}a;p(l9CL`o4r(e-nN}mQ?M&isv-P&d$!8|1D1I(3-z!wi zTgoo)*Mv`gC?~bm?S|@}I|m-E2yqPEvYybiD5azInexpK8?9q*$9Yy9-t%5jU8~ym zgZDx>!@ujQ=|HJnwp^wv-FdD{RtzO9SnyfB{mH_(c!jHL*$>0o-(h(eqe*ZwF6Lvu z{7rkk%PEqaA>o+f{H02tzZ@TWy&su?VNw43! z-X+rN`6llvpUms3ZiSt)JMeztB~>9{J8SPmYs&qohxdYFi!ra8KR$35Zp9oR)eFC4 zE;P31#3V)n`w$fZ|4X-|%MX`xZDM~gJyl2W;O$H25*=+1S#%|53>|LyH za@yh+;325%Gq3;J&a)?%7X%t@WXcWL*BaaR*7UEZad4I8iDt7^R_Fd`XeUo256;sAo2F!HcIQKk;h})QxEsPE5BcKc7WyerTchgKmrfRX z!x#H_%cL#B9TWAqkA4I$R^8{%do3Y*&(;WFmJ zU7Dih{t1<{($VtJRl9|&EB?|cJ)xse!;}>6mSO$o5XIx@V|AA8ZcoD88ZM?C*;{|f zZVmf94_l1OmaICt`2sTyG!$^UeTHx9YuUP!omj(r|7zpm5475|yXI=rR>>fteLI+| z)MoiGho0oEt=*J(;?VY0QzwCqw@cVm?d7Y!z0A@u#H?sCJ*ecvyhj& z-F77lO;SH^dmf?L>3i>?Z*U}Em4ZYV_CjgfvzYsRZ+1B!Uo6H6mbS<-FFL`ytqvb& zE7+)2ahv-~dz(Hs+f})z{*4|{)b=2!RZK;PWwOnO=hG7xG`JU5>bAvUbdYd_CjvtHBHgtGdlO+s^9ca^Bv3`t@VRX2_AD$Ckg36OcQRF zXD6QtGfHdw*hx~V(MV-;;ZZF#dJ-piEF+s27z4X1qi5$!o~xBnvf=uopcn7ftfsZc zy@(PuOk`4GL_n(H9(E2)VUjqRCk9kR?w)v@xO6Jm_Mx})&WGEl=GS0#)0FAq^J*o! zAClhvoTsNP*-b~rN{8Yym3g{01}Ep^^Omf=SKqvN?{Q*C4HNNAcrowIa^mf+3PRy! z*_G-|3i8a;+q;iP@~Of_$(vtFkB8yOyWt2*K)vAn9El>=D;A$CEx6b*XF@4y_6M+2 zpeW`RHoI_p(B{%(&jTHI->hmNmZjHUj<@;7w0mx3&koy!2$@cfX{sN19Y}euYJFn& z1?)+?HCkD0MRI$~uB2UWri})0bru_B;klFdwsLc!ne4YUE;t41JqfG# zZJq6%vbsdx!wYeE<~?>o4V`A3?lN%MnKQ`z=uUivQN^vzJ|C;sdQ37Qn?;lpzg})y z)_2~rUdH}zNwX;Tp0tJ78+&I=IwOQ-fl30R79O8@?Ub8IIA(6I`yHn%lARVL`%b8+ z4$8D-|MZZWxc_)vu6@VZN!HsI$*2NOV&uMxBNzIbRgy%ob_ zhwEH{J9r$!dEix9XM7n&c{S(h>nGm?el;gaX0@|QnzFD@bne`el^CO$yXC?BDJ|Qg z+y$GRoR`?ST1z^e*>;!IS@5Ovb7*RlN>BV_UC!7E_F;N#ky%1J{+iixp(dUJj93aK zzHNN>R-oN7>kykHClPnoPTIj7zc6KM(Pnlb(|s??)SMb)4!sMHU^-ntJwY5Big7xv zb1Ew`Xj;|D2kzGja*C$eS44(d&RMU~c_Y14V9_TLTz0J#uHlsx`S6{nhsA0dWZ#cG zJ?`fO50E>*X4TQLv#nl%3GOk*UkAgt=IY+u0LNXqeln3Z zv$~&Li`ZJOKkFuS)dJRA>)b_Da%Q~axwA_8zNK{BH{#}#m}zGcuckz}riDE-z_Ms> zR8-EqAMcfyGJCtvTpaUVQtajhUS%c@Yj}&6Zz;-M7MZzqv3kA7{SuW$oW#=0az2wQ zg-WG@Vb4|D`pl~Il54N7Hmsauc_ne-a!o5#j3WaBBh@Wuefb!QJIOn5;d)%A#s+5% zuD$H=VNux9bE-}1&bcYGZ+>1Fo;3Z@e&zX^n!?JK*adSbONm$XW9z;Q^L>9U!}Toj2WdafJ%oL#h|yWWwyAGxzfrAWdDTtaKl zK4`5tDpPg5>z$MNv=X0LZ0d6l%D{(D8oT@+w0?ce$DZ6pv>{1&Ok67Ix1 zH}3=IEhPJEhItCC8E=`T`N5(k?G=B4+xzZ?<4!~ ze~z6Wk9!CHTI(0rLJ4{JU?E-puc;xusR?>G?;4vt;q~iI9=kDL=z0Rr%O$vU`30X$ zDZRFyZ`(omOy@u|i6h;wtJlP;+}$|Ak|k2dea7n?U1*$T!sXqqOjq^NxLPMmk~&qI zYg0W?yK8T(6+Ea+$YyspKK?kP$+B`~t3^Pib_`!6xCs32!i@pqXfFV6PmBIR<-QW= zN8L{pt0Vap0x`Gzn#E@zh@H)0FfVfA_Iu4fjYZ+umO1LXIbVc$pY+E234u)ttcrl$ z>s92z4vT%n6cMb>=XT6;l0+9e(|CZG)$@C7t7Z7Ez@a)h)!hyuV&B5K%%)P5?Lk|C zZZSVzdXp{@OXSP0hoU-gF8s8Um(#xzjP2Vem zec#-^JqTa&Y#QJ>-FBxd7tf`XB6e^JPUgagB8iBSEps;92KG`!#mvVcPQ5yNC-GEG zTiHEDYfH+0O15}r^+ z#jxj=@x8iNHWALe!P3R67TwmhItn**0JwnzSV2O&KE8KcT+0hWH^OPD1pwiuyx=b@ zNf5Jh0{9X)8;~Es)$t@%(3!OnbY+`@?i{mGX7Yy}8T_*0a6g;kaFPq;*=px5EhO{Cp%1kI<0?*|h8v!6WnO3cCJRF2-CRrU3JiLJnj@6;L)!0kWYAc_}F{2P))3HmCrz zQ&N&gE70;`!6*eJ4^1IR{f6j4(-l&X!tjHxkbHA^Zhrnhr9g{exN|xrS`5Pq=#Xf& zG%P=#ra-TyVFfgW%cZo5OSIwFL9WtXAlFOa+ubmI5t*3=g#Y zF%;70p5;{ZeFL}&}yOY1N1*Q;*<(kTB!7vM$QokF)yr2FlIU@$Ph58$Bz z0J?xQG=MlS4L6jA22eS42g|9*9pX@$#*sUeM(z+t?hr@r5J&D1rx}2pW&m*_`VDCW zUYY@v-;bAO0HqoAgbbiGGC<=ryf96}3pouhy3XJrX+!!u*O_>Si38V{uJmQ&USptX zKp#l(?>%^7;2%h(q@YWS#9;a!JhKlkR#Vd)ERILlgu!Hr@jA@V;sk4BJ-H#p*4EqC zDGjC*tl=@3Oi6)Bn^QwFpul18fpkbpg0+peH$xyPBqb%`$OUhPKyWb32o7clB*9Z< zN=i~NLjavrLtwgJ01bufP+>p-jR2I95|TpmKpQL2!oV>g(4RvS2pK4*ou%m(h6r3A zX#s&`9LU1ZG&;{CkOK!4fLDTnBys`M!vuz>Q&9OZ0hGQl!~!jSDg|~s*w52opC{sB ze|Cf2luD(*G13LcOAGA!s2FjSK8&IE5#W%J25w!vM0^VyQM!t)inj&RTiJ!wXzFgz z3^IqzB7I0L$llljsGq})thBy9UOyjtFO_*hYM_sgcMk>44jeH0V1FDyELc{S1F-;A zS;T^k^~4biG&V*Irq}O;e}j$$+E_#G?HKIn05iP3j|87TkGK~SqG!-KBg5+mN(aLm z8ybhIM`%C19UX$H$KY6JgXbY$0AT%rEpHC;u`rQ$Y=rxUdsc5*Kvc8jaYaO$^)cI6){P6K0r)I6DY4Wr4&B zLQUBraey#0HV|&c4v7PVo3n$zHj99(TZO^3?Ly%C4nYvJTL9eLBLHsM3WKKD>5!B` zQ=BsR3aR6PD(Fa>327E2HAu5TM~Wusc!)>~(gM)+3~m;92Jd;FnSib=M5d6;;5{%R zb4V7DEJ0V!CP-F*oU?gkc>ksUtAYP&V4ND5J>J2^jt*vcFflQWCrB&fLdT%O59PVJ zhid#toR=FNgD!q3&r8#wEBr`!wzvQu5zX?Q>nlSJ4i@WC*CN*-xU66F^V5crWevQ9gsq$I@z1o(a=k7LL~ z7m_~`o;_Ozha1$8Q}{WBehvAlO4EL60y5}8GDrZ< zXh&F}71JbW2A~8KfEWj&UWV#4+Z4p`b{uAj4&WC zha`}X@3~+Iz^WRlOHU&KngK>#j}+_o@LdBC1H-`gT+krWX3-;!)6?{FBp~%20a}FL zFP9%Emqcwa#(`=G>BBZ0qZDQhmZKJg_g8<=bBFKWr!dyg(YkpE+|R*SGpDVU!+VlU zFC54^DLv}`qa%49T>nNiA9Q7Ips#!Xx90tCU2gvK`(F+GPcL=J^>No{)~we#o@&mUb6c$ zCc*<|NJBk-#+{j9xkQ&ujB zI~`#kN~7W!f*-}wkG~Ld!JqZ@tK}eeSnsS5J1fMFXm|`LJx&}5`@dK3W^7#Wnm+_P zBZkp&j1fa2Y=eIjJ0}gh85jt43kaIXXv?xmo@eHrka!Z|vQv12HN#+!I5E z`(fbuW>gFiJL|uXJ!vKt#z3e3HlVdboH7;e#i3(2<)Fg-I@BR!qY#eof3MFZ&*Y@l zI|KJf&ge@p2Dq09Vu$$Qxb7!}{m-iRk@!)%KL)txi3;~Z4Pb}u@GsW;ELiWeG9V51 znX#}B&4Y2E7-H=OpNE@q{%hFLxwIpBF2t{vPREa8_{linXT;#1vMRWjOzLOP$-hf( z>=?$0;~~PnkqY;~K{EM6Vo-T(0K{A0}VUGmu*hR z{tw3hvBN%N3G3Yw`X5Te+F{J`(3w1s3-+1EbnFQKcrgrX1Jqvs@ADGe%M0s$EbK$$ zK)=y=upBc6SjGYAACCcI=Y*6Fi8_jgwZlLxD26fnQfJmb8^gHRN5(TemhX@0e=vr> zg`W}6U>x6VhoA3DqsGGD9uL1DhB3!OXO=k}59TqD@(0Nb{)Ut_luTioK_>7wjc!5C zIr@w}b`Fez3)0wQfKl&bae7;PcTA7%?f2xucM0G)wt_KO!Ewx>F~;=BI0j=Fb4>pp zv}0R^xM4eti~+^+gE$6b81p(kwzuDti(-K9bc|?+pJEl@H+jSYuxZQV8rl8 zjp@M{#%qItIUFN~KcO9Hed*`$5A-2~pAo~K&<-Q+`9`$CK>rzqAI4w~$F%vs9s{~x zg4BP%Gy*@m?;D6=SRX?888Q6peF@_4Z->8wAH~Cn!R$|Hhq2cIzFYqT_+cDourHbY z0qroxJnrZ4Gh+Ay+F`_c%+KRT>y3qw{)89?=hJ@=KO=@ep)aBJ$c!JHfBMJpsP*3G za7|)VJJ8B;4?n{~ldJF7%jmb`-ftIvNd~ekoufG(`K(3=LNc;HBY& z(lp#q8XAD#cIf}k49zX_i`*fO+#!zKA&%T3j@%)R+#yag067CU%yUEe47>wzGU8^` z1EXFT^@I!{J!F8!X?S6ph8J=gUi5tl93*W>7}_uR<2N2~e}FaG?}KPyugQ=-OGEZs z!GBoyYY+H*ANn4?Z)X4l+7H%`17i5~zRlRIX?t)6_eu=g2Q`3WBhxSUeea+M-S?RL zX9oBGKn%a!H+*hx4d2(I!gsi+@SQK%<{X22M~2tMulJoa)0*+z9=-YO+;DFEm5eE1U9b^B(Z}2^9!Qk`!A$wUE z7$Ar5?NRg2&G!AZqnmE64eh^Anss3i!{}%6@Et+4rr!=}!SBF8eZ2*J3ujCWbl;3; z48H~goPSv(8X61fKKdpP!Z7$88NL^Z?j`!^*I?-P4X^pMxyWz~@$(UeAcTSDd(`vO z{~rc;9|GfMJcApU3k}22a!&)k4{CU!e_ny^Y3cO;tOvOMKEyWz!vG(Kp*;hB?d|R3`2X~=5a6#^o5@qn?J-bI8Ppip{-yG z!k|VcGsq!jF~}7DMr49Wap-s&>o=U^T0!Lcy}!(bhtYsPQy z4|EJe{12QL#=c(suQ89Mhw9<`bui%nx7Nep`C&*M3~vMEACmcRYYRGtANq$F%zh&V zc)cEVeHz*Z1N)L7k-(k3np#{GcDh2Q@ya0YHl*n7fl*ZPAsbU-a94MYYtA#&!c`xGIaV;yzsmrjfieTEtqB_WgZp2*NplHx=$O{M~2#i_vJ{ps-NgK zQsxKK_CBM2PP_je+Xft`(vYfXXgIUr{=PA=7a8`2EHk)Ym2QKIforz# tySWtj{oF3N9@_;i*Fv5S)9x^z=nlWP>jpp-9)52ZmLVA=i*%6g{{fxOO~wEK literal 0 HcmV?d00001 diff --git a/apps/desktop/windows/runner/runner.exe.manifest b/apps/desktop/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/apps/desktop/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/apps/desktop/windows/runner/utils.cpp b/apps/desktop/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/apps/desktop/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/apps/desktop/windows/runner/utils.h b/apps/desktop/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/apps/desktop/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/apps/desktop/windows/runner/win32_window.cpp b/apps/desktop/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/apps/desktop/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/apps/desktop/windows/runner/win32_window.h b/apps/desktop/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/apps/desktop/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/deploy/watch-together.service b/deploy/watch-together.service new file mode 100644 index 0000000..7d3f740 --- /dev/null +++ b/deploy/watch-together.service @@ -0,0 +1,11 @@ +[Unit] +Description=watch-together headless server +After=network.target + +[Service] +ExecStart=/opt/watch-together/server --port 8080 +Restart=on-failure +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c5882d8 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,77 @@ +# ARCHITECTURE + +One binary, three modes. `packages/server` (Relic) is embedded, not a separate product. + +- **Client**: GUI Join, sync WS client (`web_socket_channel` in `packages/core`) + mpv. No server code runs. +- **Server+Client (Host)**: GUI Host starts `packages/server` in-process, binds port, self-connects via loopback. Host opens ports, runs traffic. Clients open nothing outbound-only. +- **Headless**: `--headless`, same server package, no window/player init. + +Server side: **Relic** (HTTP + built-in WS). Client side WS: **`web_socket_channel`** +(Relic has no client; server WS handling stays in Relic). + +Media order (locked): links first (shared http(s) URL, Syncplay-style sync), +file relay after (server-proxied bytes for the whitelist case). + +## Media paths (links vs file relay) + +Two ways to watch, same sync engine, same Range endpoint: + +1. **Links (primary, shipped):** host shares an http(s) media URL + (e.g. a Jellyfin Download URL). Viewers stream it directly; the + server is sync-authority only (`media.url`, no `mediaUrl`). This + covers most sessions — proxying is usually unnecessary. +2. **File relay (secondary, local-file half shipped):** host serves + bytes from their own machine through the server's token-gated + Range endpoint `GET /m///`. Viewers (and the + host via loopback) play the server URL. This is the fallback for + the whitelist case: viewers cannot reach the source, but the + server/host can (e.g. Jellyfin allowlists the host, not the + viewers). No server storage — bytes stream file→socket, config + + logs only, never to disk. + +Backends behind `MediaSource` (`packages/core/lib/src/media_source.dart`): +`FileMediaSource` (local file — SHIPPED, wired into +`mountSessionMediaRoute`); `HttpSource` (server fetches a remote URL +and re-serves it — DEFERRED, Phase 8). `resolveMediaRequest` stays +framework-agnostic so both ride the same tested Range table. + +## Deferred: remote-host upload tunnel (NOT active protocol) + +If a VPS tier ever needs a *remote* host to push bytes to a server +it cannot serve from disk, the reserved design is the brief's WSS +tunnel: host dials out, JSON control frames +(`hello`/`welcome`+mediaUrl, `open`{id,start,end}, `ack`, `cancel`, +`eof`/`err`) plus binary `[u32 BE reqId][payload]` chunks — +chunk 256 KiB, credit window 8 (≈2 MiB in flight), max 16 concurrent +`open`, relay acks only after socket flush (real backpressure). +`RelayConstants.chunkBytes/creditWindowChunks/maxConcurrentOpen` +are reserved for this. No code exists; direct-serve covers v1 +(host IS the server, opens ports itself). + +Host playback goes through loopback like everyone else's +(`http://127.0.0.1:/…`) — same code path as remote viewers, so +server bugs surface on the host screen first. Deployment ladder, +cheapest first: host's own PC (LAN, or internet with port-forward) → +same binary `--headless` on a friend's always-on box → cheap VPS +(CGNAT escape; TLS/Caddy deferred with this tier). + +Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1 +(audit done; deviations: clock never moves from reports, no +pause-on-leave, force-for-all, quorum gate — see HANDOFF §2). +Additive namespaced extensions only, `proto` bump + `PROTOCOL.md` same-commit. + +No server storage: config + logs only. Media never touches disk. + +## Health reporting (standing rule) + +Every failure must display. Nothing stalls silently. + +- Server `/health` returns machine-readable status: ok, room, viewer + count, uptime, media, bytes served, version. The app reads it. +- App surfaces all failure states in UI with cause + what-to-do: + connecting/reconnecting, wrong secret (401), kicked (403), session + gone (410), host offline / buffering (503), starvation + ("waiting for X — N.N s buffered"). Backed by a structured status + signal (severity + message), not bare strings. +- Errors log via `debugPrint` + stack trace at the boundary, then + convert to a displayable state. No swallowed exceptions. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..8a1a00c --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,115 @@ +# PROTOCOL — v4 (Phase 3: sync engine + readiness) + +`proto = 4`. Bump history: v1 hello/state/event shapes; v2 `token` in +welcome; v3 `kicked`/`kick`; v4 readiness intents, room fan-out, feed, +public member ids (tokens never leave their owner — media URLs carry +only the requester's own token). + +## Transport vs readiness (the core split) + +Room transport is PLAYING or HELD. Seeks move transport position only: +while PLAYING anyone seeks freely, everyone follows, catch-up buffering +is tolerated with no flag changes. Readiness gates only the +HELD→PLAYING transition. Starvation pauses transport without touching +flags; recovery auto-resumes on buffers, no Space needed. + +## Message catalog + +Client→server intents (all post-hello text frames): + +``` +{"t":"ready","ready":true,"source":"manual|auto"} Space flip / idle timer +{"t":"force","target":"all|","ready":false} +{"t":"seek","position":123.4} doSeek event, room jumps +{"t":"swap","media":{"name":..,"size":..,"duration":..,"url":..}} +{"t":"report","position":123.1,"buffered":6.2,"eventId":41} +{"t":"chat","text":"…"} +{"t":"kick","target":"","reason":"…"} v3, now by member id +``` + +Server→client: + +``` +{"t":"room",{eventId,transport:"playing"|"held",position,serverTime, + mediaId,media:{…}|null, + members:[{id,name,avatar,ready,inSet,buffered,stale}]}} +{"t":"feed",{actor,action,target?,reason?,at}} toasts + sheet log +{"t":"welcome",{token,memberId,mediaUrl?,state}} v2+4, state now a room +{"t":"kicked",{reason}} {"t":"err",{message}} +``` + +`room` broadcasts at 1 Hz and on every change. Clients diff by +`eventId`: position jump ⇒ seek, transport change ⇒ play/pause. +`feed` actions: ready, unready, force-ready, force-unready, force-play, +force-hold, kick, join, leave, stale, swap, seek, pause, resume, chat — +every one carries actor + server timestamp. +Force-play-all is `force` with `target:"all",ready:true`; anyone not +ready Spaces back. Sounds key off feed actions. + +## Membership set (kills join-cascade + ghost-quorum) + +- Join: `inSet=false, ready=false`. Catch up in background (open media, + seek to room edge, buffer). Flipping ready joins the set. +- Unready while in set: stays in set, room re-evaluates → HELD. +- Disconnect: removed from set, never counts toward quorum. +- Swap: all sets cleared, all flags false. +- Gate to PLAYING: set non-empty AND every in-set member + ready + buffered ≥ 4 s + fresh (not stale) AND quorum: in-set + size ≥ 2, unless no more than one member is fresh. A lone + watcher can play alone; the moment a second watcher is present, + one Space waits for the other. Empty set ⇒ HELD. + +## Clock rules (Syncplay port) + +Server owns the advancing clock: +`positionAt(now) = position + (playing ? now-serverTime : 0)`. +Plain reports are informational and can never move it. Only seek, swap, +and gate transitions write position/serverTime/eventId. Reports tagged +with `eventId < current` have their position ignored (no ping-pong); +buffered is still accepted. Pause (HELD) freezes position immediately; +resume sets serverTime=now — laggards seek forward, never drag back. + +Tiered client correction on room state: |drift| > 5 s ⇒ seek; +ahead > 1.5 s ⇒ 0.95× until level; inside deadzone ⇒ nothing. + +## Join state machine + +connect → hello → welcome → open media (server mediaUrl or shared +url) → seek to room edge → buffer → not-ready catch-up → Space → +in set → HELD→PLAYING when gate passes. Reconnect: re-hello (tokens +die with the socket), prior flag restored, buffer gate holds playback +until truly caught up. + +## Swap state machine + +anyone sends swap → HELD, mediaId++, clock reset (0/now), sets +cleared, flags false → all clients re-run Join → auto-resume when the +gate passes. No authority check in v1 (same trust basis as force). + +## Join / auth (v2–v3, unchanged) + +proto must equal server proto; secret is the room password; avatar +≤64 KiB; failures get `err` + close. `sid="default"`, map keyed for +later multi-session. mediaUrl only when the server holds bytes. + +## Media endpoint (Phase 1, unchanged) + +`GET /m///` Range table. 410/401/403/503 gates. + +## Heartbeat (Phase 2 baseline, feeds the gate) + +5 s WS pings; per-token lastSeen on every frame; silent >15 s ⇒ out +of the gate (`stale` feed); silent >30 s ⇒ socket closed, member +removed (`leave`), client reconnects via re-hello. No limbo sockets. +Closed sockets are swept every second (`isClosed` poll — transport +`onDone` is unreliable, so presence never depends on it). +Client-side reconnect loop (fixed polite interval + manual escape) +is app work. + +## Playlist (deferred, space reserved) + +Not v1. Shape when it lands: server-held `queue:[media…]` alongside +the current media; anyone appends links (`queue-add`), anyone skips +(`queue-skip`); reaching EOF auto-advances exactly like a swap +(HELD, mediaId++, sets cleared, Join rerun, gate resumes). No code +until then — `mediaId` + swap already carry it. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..ff1f3ac --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,94 @@ +# ROADMAP — sync_relay_project (canonical phase tracker) + +This is the durable source of truth for phases. Chat is ephemeral; +this file + `HANDOFF.md` (root) + `PROTOCOL.md` / `ARCHITECTURE.md` +carry state across sessions. Update this file when a phase changes +state. Proto bumps still require PROTOCOL.md same-commit. + +Model: direct-serve (host opens ports, clients open nothing). The +brief's old WSS host-upload tunnel is obsolete unless a VPS tier +needs remote-host upload. Server = Relic, client WS = +web_socket_channel. Protocol still v4. + +## Status legend + +- DONE: exit criteria met, tests green at the time. +- PARTIAL: shipped + tested, exits still open. +- TODO: not started or explicitly deferred. + +## Phase 0 — Scaffold (DONE) + +Monorepo, path deps, CI (`dart analyze` + `dart test` on core/server), +logging/config/errors, Flutter-free enforcement tests, relay binary +binds/logs/exits, stub ARCHITECTURE.md. + +## Phase 1 — mpv HTTP contract (DONE) + +Relic `resolveMediaRequest` + `mountMediaRoute`, full Range table +(200 / 206×3 shapes / 416 / 401 / 403 / 410 / 503), byte-exact tests, +real mpv traces in `mpv-http-contract.md`, `tool/range_logger.dart` ++ `tool/shaper.dart` (KB/s cap, latency, --kill-after). + +## Phase 2 — Sessions + media endpoint (DONE) + +Secret → 128-bit token, 401/403/410 gates, kick kills WS+media, +byte counters, `tool/fake_viewer.dart` ALL PASS live. Proto v2→v3 +for `kicked`/`kick`. Media: local-file relay SHIPPED +(`FileMediaSource` + token-gated Range route); links path SHIPPED +(server distributes shared URL, viewers stream direct); +`HttpSource` (server-side URL fetch) DEFERRED to Phase 8; WSS +upload tunnel NOT BUILT (spec reserved in ARCHITECTURE.md). + +## Phase 3 — Sync engine + readiness (DONE) + +`RoomEngine` (injectable clock): server-owned clock, reports never +move it, stale-eventId guard, free seeks, PLAYING/HELD gate, quorum +(in-set≥2 unless ≤1 fresh), swap reset, force with actor feed. +22+ engine tests, `tool/e2e_check.dart` 15/15 sim. Syncplay audit +done (conscious deviations documented in HANDOFF §2). Proto v4. +Follow-ups landed: NaN guards, name/chat caps, stale vs leave feed, +1 Hz feed flush, silent-drop (15 s stale / 30 s drop + close). + +## Phase 4 — Desktop app (PARTIAL) + +Shipped: SyncClient framing, RoomDiff (eventId diff, 0.95× latch), +media_kit wiring, Space=ready, avatars, toasts+sheet, health display, +reconnect loop. Counts at last green: core 42, server 40, desktop 17. +Open exits (manual, user-driven): remove bisect flags in +`apps/desktop/lib/app.dart`; 2-client LAN soak; 1.4 GB real-file test +(user resupplies URL, never paste api_key into files); +cache-exhaustion long outage; internet test (forwarded port/Tailscale). + +## Phase 5 — Resilience (PARTIAL) + +Shipped: isClosed sweep, onError forget, stop() closes sockets, +silence-timeout + reconnect loop. Never run: shaper chaos suite +(host drop 5 s / 30 s, viewer stall, 20-seek storm), OOM / stuck-room +/ drift-recovery assertions, CI wiring. + +## Phase 6 — Identity & social (PARTIAL) + +Shipped: names (32 cap), avatars (64 KiB, picker + crop), kick nudge ++ reason, feed attribution. Open: real chat pane/history (schema +only), sounds swap-in (wiring + NoopSfxService ready; pick +flutter_soloud or equivalent, Kenney/ObsydianX/akx sources in +HANDOFF §7). + +## Phase 7 — Deployment & updates (TODO, deferred) + +Thin `bin/server.dart` compiles; systemd unit exists. Missing: +Windows zip, Linux tar/AppDir, TLS/Caddy doc, update manifest + +verifier. Needs explicit user go + code-signing decision. + +## Phase 8 — Optional quality (TODO, deferred) + +Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md), +sidecar subtitles, local-file fallback (viewer plays matching +size+duration file locally, still synced — saves host uplink), +`HttpSource` (host pastes a direct-stream URL, server fetches + +re-serves — makes Jellyfin usable through the whitelist today, no +native integration). No code until approved. + +## Deferred backlog (needs explicit approval) + +Jellyfin native, P2P/WebRTC, multi-session, transcode, Android. diff --git a/docs/mpv-http-contract.md b/docs/mpv-http-contract.md new file mode 100644 index 0000000..72950f2 --- /dev/null +++ b/docs/mpv-http-contract.md @@ -0,0 +1,70 @@ +# mpv HTTP contract (Phase 1, verified against Relic endpoint) + +Spike: `tool/range_logger.dart` (Relic, `mountMediaRoute` + `onLog` hook). +Player: stock mpv CLI headless (`--vo=null --ao=null`). Server logs +`METHOD path range= -> status bytes=`. + +## Real traces + +mp4, 570692 B, full play (`--frames=300`): + +```text +get /m/file range=- -> 200 bytes=570692 +get /m/file range=bytes=0- -> 206 bytes=570692 +``` + +One no-Range open (mpv probes full length), then a single open-ended +stream to EOF. Small file fits demuxer cache; one connection does it all. + +mkv (ffmpeg remux of same file), 562197 B, play + `--start=8` seek: + +```text +get /m/file range=bytes=0- -> 206 bytes=562197 +get /m/file range=bytes=0- -> 206 bytes=562197 +get /m/file range=bytes=562093- -> 206 bytes=104 +get /m/file range=bytes=243694- -> 206 bytes=318503 +``` + +Two `bytes=0-` opens (demuxer probe + playback), a 104 B tail read at +`size-104` (Cues-at-EOF probe — mpv expresses it open-ended, not as +`bytes=-n`; the server answers both identically), then a mid-file open +at a cluster boundary for the seek/start position. Seeks always arrive +as a fresh `bytes=N-` connection, never as a mutation of the old one. + +Outage: server SIGINT-killed 5 s mid-play (looping file, comma-form +reconnect flags), restarted. mpv kept playing from cache, no error, no +reconnect storm; no new Range request inside the post-restart window. +Short drops are ridden out from cache — matches the north-star case. +Cache-exhaustion reconnect (long drop) is a Phase 5 chaos assertion; +`tool/shaper.dart` (KB/s cap + latency + `--kill-after`) exists for it. + +## Server behavior table (all byte-exact tested, 16 server tests) + +| Request | Response | +|---|---| +| no Range | 200, exact `Content-Length`, full bytes | +| `bytes=a-b` | 206, `Content-Range: a-b/size`, exact length | +| `bytes=a-` | 206 open-ended to EOF, exact length | +| `bytes=-n` | 206 tail `size-n..EOF` (Cues probe) | +| unsatisfiable start | 416 + `Content-Range: bytes */size` | +| host offline | 503 + `Retry-After: 2` (mpv retries with reconnect flags) | +| bad token / kicked / gone | 401 / 403 / 410 | + +Always: `Accept-Ranges: bytes`; `Content-Type` from extension +(mkv `video/x-matroska`, mp4 `video/mp4`, webm, avi, mov, ts, mka/mp3). + +## Relic notes + +- Range arrives as typed `RangeHeader` (`req.headers.range`), not a raw + string. Multi-range takes the first entry; unit is preserved so + non-`bytes` units still 416. `resolveMediaRequest` in + `packages/server` stays framework-agnostic and is the tested truth. +- Reconnect flag syntax is build-sensitive: this mpv rejected the + colon-joined `--stream-lavf-o=a=1:b=1` form and accepted commas. + The exact media_kit property set is re-verified in Phase 4 through + mpv `set_property`, not CLI flags. + +## Remaining manual (human + media_kit, Phase 4) + +1.4 GB real file through the endpoint with the pinned buffer props; +long-outage reconnect with cache exhaustion via shaper. diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart new file mode 100644 index 0000000..96b151d --- /dev/null +++ b/packages/core/lib/core.dart @@ -0,0 +1,7 @@ +export 'src/constants.dart'; +export 'src/errors.dart'; +export 'src/logging.dart'; +export 'src/config.dart'; +export 'src/protocol.dart'; +export 'src/media_source.dart'; +export 'src/sync_engine.dart'; diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart new file mode 100644 index 0000000..4610fc4 --- /dev/null +++ b/packages/core/lib/src/config.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import 'errors.dart'; + +class AppConfig { + final int port; + final String secret; + final String name; + final String mediaPath; + final String logLevel; + final String advertiseHost; + + const AppConfig({ + required this.port, + required this.secret, + required this.name, + this.mediaPath = '', + this.logLevel = 'INFO', + this.advertiseHost = '127.0.0.1', + }); + + factory AppConfig.defaults() => + const AppConfig(port: 8080, secret: 'change-me', name: 'room'); + + factory AppConfig.fromMap(Map map) { + try { + return AppConfig( + port: (map['port'] as num?)?.toInt() ?? 8080, + secret: (map['secret'] as String?) ?? 'change-me', + name: (map['name'] as String?) ?? 'room', + mediaPath: (map['mediaPath'] as String?) ?? '', + logLevel: (map['logLevel'] as String?) ?? 'INFO', + advertiseHost: (map['advertiseHost'] as String?) ?? '127.0.0.1', + ); + } catch (e) { + throw ConfigException('invalid config map: $e'); + } + } + + Map toMap() => { + 'port': port, + 'secret': secret, + 'name': name, + 'mediaPath': mediaPath, + 'logLevel': logLevel, + 'advertiseHost': advertiseHost, + }; + + static Future loadFile(String path) async { + final file = File(path); + if (!await file.exists()) { + throw ConfigException('config file not found: $path'); + } + final text = await file.readAsString(); + try { + if (path.endsWith('.json')) { + final decoded = jsonDecode(text); + if (decoded is! Map) throw const ConfigException('root must be a map'); + return AppConfig.fromMap(Map.from(decoded)); + } + final yamlMap = loadYaml(text); + if (yamlMap is! YamlMap) throw const ConfigException('root must be a map'); + return AppConfig.fromMap( + Map.from(yamlMap.map((k, v) => MapEntry('$k', v)))); + } catch (e) { + if (e is ConfigException) rethrow; + throw ConfigException('failed to parse $path: $e'); + } + } + + static String defaultPath(String appName) { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; + return p.join(home, '.config', appName, 'config.yaml'); + } +} diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart new file mode 100644 index 0000000..383e4ae --- /dev/null +++ b/packages/core/lib/src/constants.dart @@ -0,0 +1,21 @@ +abstract class RelayConstants { + static const int protoVersion = 4; + + static const int chunkBytes = 256 * 1024; + static const int creditWindowChunks = 8; + static const int maxConcurrentOpen = 16; + + static const int seekDebounceMs = 300; + static const double driftSeekThresholdSecs = 5.0; + static const double slowDownThresholdSecs = 1.5; + static const double slowDownRate = 0.95; + static const double minBufferSecs = 4.0; + static const double resumeHysteresisSecs = 4.0; + + static const int stateHeartbeatSecs = 1; + static const int pingIntervalSecs = 5; + + static const int maxAvatarBytes = 64 * 1024; + static const int maxNameLength = 32; + static const int maxChatLength = 500; +} diff --git a/packages/core/lib/src/errors.dart b/packages/core/lib/src/errors.dart new file mode 100644 index 0000000..619880f --- /dev/null +++ b/packages/core/lib/src/errors.dart @@ -0,0 +1,27 @@ +abstract class SyncRelayException implements Exception { + final String message; + const SyncRelayException(this.message); + + @override + String toString() => '$runtimeType: $message'; +} + +class ConfigException extends SyncRelayException { + const ConfigException(super.message); +} + +class ProtocolException extends SyncRelayException { + const ProtocolException(super.message); +} + +class AuthException extends SyncRelayException { + const AuthException(super.message); +} + +class MediaException extends SyncRelayException { + const MediaException(super.message); +} + +class TunnelException extends SyncRelayException { + const TunnelException(super.message); +} diff --git a/packages/core/lib/src/logging.dart b/packages/core/lib/src/logging.dart new file mode 100644 index 0000000..b3093cb --- /dev/null +++ b/packages/core/lib/src/logging.dart @@ -0,0 +1,19 @@ +import 'package:logging/logging.dart'; + +Logger createLogger(String name) => Logger(name); + +void initLogging({Level level = Level.INFO}) { + Logger.root.level = level; + Logger.root.onRecord.listen((r) { + // ignore: avoid_print + print('${r.time.toIso8601String()} ${r.level.name} ${r.loggerName}: ${r.message}'); + if (r.error != null) { + // ignore: avoid_print + print(' error: ${r.error}'); + } + if (r.stackTrace != null) { + // ignore: avoid_print + print('${r.stackTrace}'); + } + }); +} diff --git a/packages/core/lib/src/media_source.dart b/packages/core/lib/src/media_source.dart new file mode 100644 index 0000000..d1eb513 --- /dev/null +++ b/packages/core/lib/src/media_source.dart @@ -0,0 +1,92 @@ +import 'dart:io'; +import 'dart:typed_data'; + +abstract class MediaSource { + Future get size; + String get displayName; + Future read(int start, int length); + Future close(); +} + +class FileMediaSource implements MediaSource { final File file; + RandomAccessFile? _handle; + + FileMediaSource(this.file); + + @override + String get displayName => file.path.split(Platform.pathSeparator).last; + + @override + Future get size async => file.length(); + + @override + Future read(int start, int length) async { + _handle ??= await file.open(); + await _handle!.setPosition(start); + return _handle!.read(length); + } + + @override + Future close() async { + await _handle?.close(); + _handle = null; + } +} + +class RangeRequest { + final int start; + final int? end; + final int size; + const RangeRequest({required this.start, required this.end, required this.size}); + + int get contentLength => (end ?? (size - 1)) - start + 1; + + String get contentRange => 'bytes $start-${end ?? size - 1}/$size'; + + static RangeRequest? parse(String? header, int size) { + if (header == null || header.isEmpty) return null; + final value = header.trim(); + if (!value.startsWith('bytes=')) return null; + final spec = value.substring(6); + if (spec.startsWith('-')) { + final suffix = int.tryParse(spec.substring(1)); + if (suffix == null || suffix <= 0) return null; + final start = (size - suffix).clamp(0, size); + return RangeRequest(start: start, end: null, size: size); + } + final parts = spec.split('-'); + if (parts.length != 2) return null; + final start = int.tryParse(parts[0].trim()); + if (start == null || start < 0 || start >= size) return null; + if (parts[1].trim().isEmpty) return RangeRequest(start: start, end: null, size: size); + final end = int.tryParse(parts[1].trim()); + if (end == null || end < start) return null; + return RangeRequest(start: start, end: end.clamp(0, size - 1), size: size); + } +} + +String contentTypeForFile(String filename) { + final dot = filename.lastIndexOf('.'); + final ext = dot < 0 ? '' : filename.substring(dot).toLowerCase(); + switch (ext) { + case '.mkv': + return 'video/x-matroska'; + case '.mp4': + case '.m4v': + return 'video/mp4'; + case '.webm': + return 'video/webm'; + case '.avi': + return 'video/x-msvideo'; + case '.mov': + return 'video/quicktime'; + case '.ts': + return 'video/mp2t'; + case '.mp3': + return 'audio/mpeg'; + case '.mka': + return 'audio/x-matroska'; + default: + return 'application/octet-stream'; + } +} diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart new file mode 100644 index 0000000..93c5e32 --- /dev/null +++ b/packages/core/lib/src/protocol.dart @@ -0,0 +1,390 @@ +import 'constants.dart'; +import 'errors.dart'; + +enum MessageKind { + hello, + welcome, + state, + event, + chat, + buffered, + kicked, + kick, + eof, + err, + open, + ack, + cancel, +} + +class HelloUser { + final String name; + final String avatar; + const HelloUser({required this.name, this.avatar = ''}); + + Map toJson() => {'name': name, 'avatar': avatar}; + + factory HelloUser.fromJson(Map json) => + HelloUser(name: (json['name'] as String?) ?? '', avatar: (json['avatar'] as String?) ?? ''); +} + +class HelloMedia { + final String name; + final int size; + final double duration; + final String? url; + const HelloMedia({required this.name, required this.size, required this.duration, this.url}); + + Map toJson() => {'name': name, 'size': size, 'duration': duration, if (url != null) 'url': url}; + + factory HelloMedia.fromJson(Map json) => HelloMedia( + name: (json['name'] as String?) ?? '', + size: (json['size'] as num?)?.toInt() ?? 0, + duration: (json['duration'] as num?)?.toDouble() ?? 0.0, + url: json['url'] as String?, + ); +} + +class HelloMessage { + final int proto; + final String secret; + final HelloUser user; + final HelloMedia media; + const HelloMessage({required this.proto, required this.secret, required this.user, required this.media}); + + Map toJson() => { + 't': 'hello', + 'proto': proto, + 'secret': secret, + 'user': user.toJson(), + 'media': media.toJson(), + }; + + factory HelloMessage.fromJson(Map json) { + if (json['t'] != 'hello') throw const ProtocolException('not a hello message'); + final user = json['user']; + final media = json['media']; + if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media'); + return HelloMessage( + proto: (json['proto'] as num?)?.toInt() ?? RelayConstants.protoVersion, + secret: (json['secret'] as String?) ?? '', + user: HelloUser.fromJson(Map.from(user)), + media: HelloMedia.fromJson(Map.from(media)), + ); + } +} + +class RoomState { + final double position; + final bool paused; + final int mediaId; + final DateTime serverTime; + const RoomState({required this.position, required this.paused, required this.mediaId, required this.serverTime}); + + Map toJson() => { + 'position': position, + 'paused': paused, + 'mediaId': mediaId, + 'serverTime': serverTime.toIso8601String(), + }; + + factory RoomState.fromJson(Map json) => RoomState( + position: (json['position'] as num?)?.toDouble() ?? 0.0, + paused: (json['paused'] as bool?) ?? true, + mediaId: (json['mediaId'] as num?)?.toInt() ?? 0, + serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(), + ); +} + +class WelcomeMessage { + final String token; + final String memberId; + final String? mediaUrl; + final Map? state; + const WelcomeMessage({required this.token, this.memberId = '', this.mediaUrl, this.state}); + + Map toJson() => { + 't': 'welcome', + 'token': token, + 'memberId': memberId, + if (mediaUrl != null) 'mediaUrl': mediaUrl, + if (state != null) 'state': state, + }; + + factory WelcomeMessage.fromJson(Map json) { + if (json['t'] != 'welcome') throw const ProtocolException('not a welcome message'); + final state = json['state']; + return WelcomeMessage( + token: (json['token'] as String?) ?? '', + memberId: (json['memberId'] as String?) ?? '', + mediaUrl: json['mediaUrl'] as String?, + state: state is Map ? Map.from(state) : null, + ); + } +} + +class KickedMessage { + final String reason; + const KickedMessage({this.reason = ''}); + + Map toJson() => {'t': 'kicked', 'reason': reason}; + + factory KickedMessage.fromJson(Map json) { + if (json['t'] != 'kicked') throw const ProtocolException('not a kicked message'); + return KickedMessage(reason: (json['reason'] as String?) ?? ''); + } +} + +class KickRequest { + final String target; + final String reason; + const KickRequest({required this.target, this.reason = ''}); + + Map toJson() => {'t': 'kick', 'target': target, 'reason': reason}; + + factory KickRequest.fromJson(Map json) { + if (json['t'] != 'kick') throw const ProtocolException('not a kick request'); + return KickRequest( + target: (json['target'] as String?) ?? '', + reason: (json['reason'] as String?) ?? '', + ); + } +} + +class ReadyIntent { + final bool ready; + final String source; + const ReadyIntent({required this.ready, this.source = 'manual'}); + + Map toJson() => {'t': 'ready', 'ready': ready, 'source': source}; + + factory ReadyIntent.fromJson(Map json) { + if (json['t'] != 'ready') throw const ProtocolException('not a ready intent'); + return ReadyIntent( + ready: (json['ready'] as bool?) ?? false, + source: (json['source'] as String?) ?? 'manual', + ); + } +} + +class ForceIntent { + final String target; + final bool ready; + const ForceIntent({required this.target, required this.ready}); + + Map toJson() => {'t': 'force', 'target': target, 'ready': ready}; + + factory ForceIntent.fromJson(Map json) { + if (json['t'] != 'force') throw const ProtocolException('not a force intent'); + return ForceIntent( + target: (json['target'] as String?) ?? '', + ready: (json['ready'] as bool?) ?? false, + ); + } +} + +class SeekIntent { + final double position; + const SeekIntent({required this.position}); + + Map toJson() => {'t': 'seek', 'position': position}; + + factory SeekIntent.fromJson(Map json) { + if (json['t'] != 'seek') throw const ProtocolException('not a seek intent'); + return SeekIntent(position: (json['position'] as num?)?.toDouble() ?? 0.0); + } +} + +class SwapIntent { + final HelloMedia media; + const SwapIntent({required this.media}); + + Map toJson() => {'t': 'swap', 'media': media.toJson()}; + + factory SwapIntent.fromJson(Map json) { + if (json['t'] != 'swap') throw const ProtocolException('not a swap intent'); + final media = json['media']; + if (media is! Map) throw const ProtocolException('swap missing media'); + return SwapIntent(media: HelloMedia.fromJson(Map.from(media))); + } +} + +class ReportMessage { + final double position; + final double buffered; + final int eventId; + const ReportMessage({required this.position, required this.buffered, required this.eventId}); + + Map toJson() => + {'t': 'report', 'position': position, 'buffered': buffered, 'eventId': eventId}; + + factory ReportMessage.fromJson(Map json) { + if (json['t'] != 'report') throw const ProtocolException('not a report'); + return ReportMessage( + position: (json['position'] as num?)?.toDouble() ?? 0.0, + buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0, + eventId: (json['eventId'] as num?)?.toInt() ?? 0, + ); + } +} + +class ChatMessage { + final String text; + const ChatMessage({required this.text}); + + Map toJson() => {'t': 'chat', 'text': text}; + + factory ChatMessage.fromJson(Map json) { + if (json['t'] != 'chat') throw const ProtocolException('not a chat message'); + return ChatMessage(text: (json['text'] as String?) ?? ''); + } +} + +class MemberView { + final String id; + final String name; + final String avatar; + final bool ready; + final bool inSet; + final double buffered; + final bool stale; + const MemberView({ + required this.id, + required this.name, + this.avatar = '', + required this.ready, + required this.inSet, + required this.buffered, + required this.stale, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'avatar': avatar, + 'ready': ready, + 'inSet': inSet, + 'buffered': buffered, + 'stale': stale, + }; + + factory MemberView.fromJson(Map json) => MemberView( + id: (json['id'] as String?) ?? '', + name: (json['name'] as String?) ?? '', + avatar: (json['avatar'] as String?) ?? '', + ready: (json['ready'] as bool?) ?? false, + inSet: (json['inSet'] as bool?) ?? false, + buffered: (json['buffered'] as num?)?.toDouble() ?? 0.0, + stale: (json['stale'] as bool?) ?? false, + ); +} + +class RoomBroadcast { + final int eventId; + final String transport; + final double position; + final DateTime serverTime; + final int mediaId; + final Map? media; + final List members; + const RoomBroadcast({ + required this.eventId, + required this.transport, + required this.position, + required this.serverTime, + required this.mediaId, + this.media, + required this.members, + }); + + Map toJson() => { + 't': 'room', + 'eventId': eventId, + 'transport': transport, + 'position': position, + 'serverTime': serverTime.toIso8601String(), + 'mediaId': mediaId, + if (media != null) 'media': media, + 'members': members.map((m) => m.toJson()).toList(), + }; + + factory RoomBroadcast.fromJson(Map json) { + if (json['t'] != 'room') throw const ProtocolException('not a room broadcast'); + final members = json['members']; + final media = json['media']; + return RoomBroadcast( + eventId: (json['eventId'] as num?)?.toInt() ?? 0, + transport: (json['transport'] as String?) ?? 'held', + position: (json['position'] as num?)?.toDouble() ?? 0.0, + serverTime: DateTime.tryParse('${json['serverTime']}') ?? DateTime.now().toUtc(), + mediaId: (json['mediaId'] as num?)?.toInt() ?? 0, + media: media is Map ? Map.from(media) : null, + members: members is List + ? members + .whereType() + .map((m) => MemberView.fromJson(Map.from(m))) + .toList() + : const [], + ); + } +} + +class FeedMessage { + final String actor; + final String action; + final String? target; + final String reason; + final DateTime at; + const FeedMessage({ + required this.actor, + required this.action, + this.target, + this.reason = '', + required this.at, + }); + + Map toJson() => { + 't': 'feed', + 'actor': actor, + 'action': action, + if (target != null) 'target': target, + 'reason': reason, + 'at': at.toIso8601String(), + }; + + factory FeedMessage.fromJson(Map json) { + if (json['t'] != 'feed') throw const ProtocolException('not a feed message'); + return FeedMessage( + actor: (json['actor'] as String?) ?? '', + action: (json['action'] as String?) ?? '', + target: json['target'] as String?, + reason: (json['reason'] as String?) ?? '', + at: DateTime.tryParse('${json['at']}') ?? DateTime.now().toUtc(), + ); + } +} + +class SyncEvent { final String kind; + final double position; + final bool doSeek; + final int ignoringOnTheFly; + const SyncEvent({required this.kind, required this.position, this.doSeek = false, this.ignoringOnTheFly = 0}); + + Map toJson() => { + 't': 'event', + 'kind': kind, + 'position': position, + 'doSeek': doSeek, + 'ignoringOnTheFly': ignoringOnTheFly, + }; + + factory SyncEvent.fromJson(Map json) { + if (json['t'] != 'event') throw const ProtocolException('not an event message'); + return SyncEvent( + kind: (json['kind'] as String?) ?? '', + position: (json['position'] as num?)?.toDouble() ?? 0.0, + doSeek: (json['doSeek'] as bool?) ?? false, + ignoringOnTheFly: (json['ignoringOnTheFly'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart new file mode 100644 index 0000000..a5911c2 --- /dev/null +++ b/packages/core/lib/src/sync_engine.dart @@ -0,0 +1,257 @@ +import 'constants.dart'; +import 'protocol.dart'; + +typedef RoomClock = DateTime Function(); + +enum Transport { playing, held } + +enum Correction { none, slowDown, seek } + +Correction correctionFor({required double ownPosition, required double roomPosition}) { + final drift = ownPosition - roomPosition; + if (drift.abs() > RelayConstants.driftSeekThresholdSecs) return Correction.seek; + if (drift > RelayConstants.slowDownThresholdSecs) return Correction.slowDown; + return Correction.none; +} + +class EngineMember { + final String id; + final String token; + String name; + String avatar; + bool ready = false; + bool inSet = false; + double buffered = 0; + DateTime lastSeen; + EngineMember({ + required this.id, + required this.token, + required this.name, + this.avatar = '', + required DateTime now, + }) : lastSeen = now; + + MemberView view({required bool stale}) => MemberView( + id: id, + name: name, + avatar: avatar, + ready: ready, + inSet: inSet, + buffered: buffered, + stale: stale, + ); +} + +class EngineFeed { + final List pending = []; + void add(FeedMessage message) => pending.add(message); + List drain() { + final out = List.from(pending); + pending.clear(); + return out; + } +} + +class RoomEngine { + final RoomClock clock; + final Duration staleAfter; + final Duration dropAfter; + final EngineFeed feed = EngineFeed(); + final Map members = {}; + + int eventId = 0; + Transport transport = Transport.held; + double position = 0; + DateTime serverTime; + int mediaId = 1; + HelloMedia? media; + int _nextId = 1; + + RoomEngine({RoomClock? clock, Duration? staleAfter, Duration? dropAfter}) + : clock = clock ?? (() => DateTime.now().toUtc()), + staleAfter = staleAfter ?? const Duration(seconds: 15), + dropAfter = dropAfter ?? const Duration(seconds: 30), + serverTime = (clock ?? (() => DateTime.now().toUtc()))(); + + DateTime get now => clock(); + + double positionAt([DateTime? at]) { + final t = at ?? now; + if (transport != Transport.playing) return position; + return position + t.difference(serverTime).inMilliseconds / 1000.0; + } + + bool isStale(EngineMember member, [DateTime? at]) => + (at ?? now).difference(member.lastSeen) > staleAfter; + + String applyJoin(String token, String name, String avatar) { + final id = 'm${_nextId++}'; + members[token] = + EngineMember(id: id, token: token, name: name, avatar: avatar, now: now); + feed.add(FeedMessage(actor: name, action: 'join', at: now)); + return id; + } + + String? applyLeave(String token) { + final removed = members.remove(token); + if (removed == null) return null; + feed.add(FeedMessage(actor: removed.name, action: 'leave', at: now)); + evaluate(); + return removed.id; + } + + EngineMember? byId(String id) { + for (final member in members.values) { + if (member.id == id) return member; + } + return null; + } + + void applyReady(String token, bool ready, {String source = 'manual'}) { + final member = members[token]; + if (member == null) return; + member.lastSeen = now; + if (member.ready == ready && member.inSet) { + evaluate(); + return; + } + member.ready = ready; + if (ready) member.inSet = true; + feed.add(FeedMessage( + actor: member.name, + action: ready ? 'ready' : 'unready', + reason: source, + at: now)); + evaluate(); + } + + void applyForce(String actorToken, String target, bool ready) { + final actor = members[actorToken]; + final actorName = actor?.name ?? '?'; + if (target == 'all') { + for (final member in members.values) { + member.ready = ready; + if (ready) member.inSet = true; + } + } else { + final member = byId(target); + if (member == null) return; + member.ready = ready; + if (ready) member.inSet = true; + feed.add(FeedMessage( + actor: actorName, + action: ready ? 'force-ready' : 'force-unready', + target: member.name, + at: now)); + evaluate(); + return; + } + feed.add(FeedMessage( + actor: actorName, + action: ready ? 'force-play' : 'force-hold', + at: now)); + evaluate(); + } + + void applySeek(String token, double seekPosition) { + final member = members[token]; + if (member == null) return; + if (seekPosition.isNaN || seekPosition < 0) return; + member.lastSeen = now; + position = seekPosition; + serverTime = now; + eventId++; + feed.add(FeedMessage(actor: member.name, action: 'seek', at: now)); + } + + void applySwap(String actorToken, HelloMedia next) { + final actor = members[actorToken]; + mediaId++; + media = next; + position = 0; + serverTime = now; + eventId++; + transport = Transport.held; + for (final member in members.values) { + member.ready = false; + member.inSet = false; + member.buffered = 0; + } + feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now)); + } + + void applyReport(String token, double reportPosition, double buffered, int reportEventId) { + final member = members[token]; + if (member == null) return; + member.lastSeen = now; + member.buffered = buffered.isNaN || buffered < 0 ? 0 : buffered; + if (reportEventId < eventId) return; + evaluate(); + } + + void sweepStale() { + var changed = false; + for (final member in members.values) { + if (isStale(member) && member.inSet) { + member.inSet = false; + changed = true; + feed.add(FeedMessage(actor: member.name, action: 'stale', reason: 'no signal', at: now)); + } + } + if (changed) evaluate(); + } + + List sweepDropped() { + final t = now; + final dropped = []; + for (final member in members.values.toList()) { + if (t.difference(member.lastSeen) > dropAfter) { + members.remove(member.token); + dropped.add(member.token); + feed.add(FeedMessage(actor: member.name, action: 'leave', reason: 'silent too long', at: t)); + } + } + if (dropped.isNotEmpty) evaluate(); + return dropped; + } + + void evaluate() { + final t = now; + if (transport == Transport.playing) { + final blocked = members.values + .where((m) => m.inSet) + .any((m) => !m.ready || m.buffered < RelayConstants.minBufferSecs || isStale(m, t)); + if (blocked) { + position = positionAt(t); + serverTime = t; + eventId++; + transport = Transport.held; + feed.add(FeedMessage(actor: 'server', action: 'pause', at: t)); + } + return; + } + final inSet = members.values.where((m) => m.inSet).toList(); + if (inSet.isEmpty) return; + final freshCount = + members.values.where((m) => !isStale(m, t)).length; + final quorum = inSet.length >= 2 || freshCount <= 1; + final allGood = inSet.every( + (m) => m.ready && m.buffered >= RelayConstants.minBufferSecs && !isStale(m, t)); + if (allGood && quorum) { + serverTime = t; + eventId++; + transport = Transport.playing; + feed.add(FeedMessage(actor: 'server', action: 'resume', at: t)); + } + } + + RoomBroadcast broadcast() => RoomBroadcast( + eventId: eventId, + transport: transport == Transport.playing ? 'playing' : 'held', + position: position, + serverTime: serverTime, + mediaId: mediaId, + media: media?.toJson(), + members: members.values.map((m) => m.view(stale: isStale(m))).toList(), + ); +} diff --git a/packages/core/pubspec.lock b/packages/core/pubspec.lock new file mode 100644 index 0000000..176d789 --- /dev/null +++ b/packages/core/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 + url: "https://pub.dev" + source: hosted + version: "107.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 + url: "https://pub.dev" + source: hosted + version: "14.3.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 + url: "https://pub.dev" + source: hosted + version: "1.32.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" + url: "https://pub.dev" + source: hosted + version: "0.6.20" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml new file mode 100644 index 0000000..cc425b3 --- /dev/null +++ b/packages/core/pubspec.yaml @@ -0,0 +1,14 @@ +name: sync_relay_core +description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: + logging: ^1.2.0 + yaml: ^3.1.0 + path: ^1.9.0 + crypto: ^3.0.0 + web_socket_channel: ^3.0.0 +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/packages/core/test/core_test.dart b/packages/core/test/core_test.dart new file mode 100644 index 0000000..8647e0a --- /dev/null +++ b/packages/core/test/core_test.dart @@ -0,0 +1,94 @@ +import 'package:sync_relay_core/core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AppConfig', () { + test('defaults are sane', () { + final c = AppConfig.defaults(); + expect(c.port, 8080); + expect(c.secret, isNotEmpty); + }); + + test('fromMap tolerates missing keys', () { + final c = AppConfig.fromMap({}); + expect(c.port, 8080); + expect(c.name, 'room'); + }); + + test('round-trips through map', () { + const c = AppConfig( + port: 1234, + secret: 's', + name: 'n', + mediaPath: 'm', + advertiseHost: '1.2.3.4'); + final back = AppConfig.fromMap(c.toMap()); + expect(back.port, 1234); + expect(back.secret, 's'); + expect(back.mediaPath, 'm'); + expect(back.advertiseHost, '1.2.3.4'); + }); + }); + + group('protocol', () { + test('hello round-trips', () { + const msg = HelloMessage( + proto: 1, + secret: 'shh', + user: HelloUser(name: 'alice'), + media: HelloMedia(name: 'Ep01.mkv', size: 1500000000, duration: 1400.0), + ); + final back = HelloMessage.fromJson(Map.from(msg.toJson())); + expect(back.user.name, 'alice'); + expect(back.media.size, 1500000000); + }); + + test('hello rejects wrong type', () { + expect(() => HelloMessage.fromJson({'t': 'bye'}), throwsA(isA())); + }); + + test('room state round-trips', () { + final s = RoomState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1)); + final back = RoomState.fromJson(Map.from(s.toJson())); + expect(back.position, 12.5); + expect(back.paused, isFalse); + expect(back.mediaId, 3); + }); + + test('sync event carries doSeek flag', () { + const e = SyncEvent(kind: 'seek', position: 42.0, doSeek: true, ignoringOnTheFly: 7); + final back = SyncEvent.fromJson(Map.from(e.toJson())); + expect(back.doSeek, isTrue); + expect(back.ignoringOnTheFly, 7); + }); + }); + + group('RangeRequest', () { + test('open-ended range', () { + final r = RangeRequest.parse('bytes=100-', 1000)!; + expect(r.start, 100); + expect(r.end, isNull); + expect(r.contentLength, 900); + }); + + test('closed range', () { + final r = RangeRequest.parse('bytes=0-99', 1000)!; + expect(r.contentLength, 100); + expect(r.contentRange, 'bytes 0-99/1000'); + }); + + test('suffix range maps to tail (mkv cues probe)', () { + final r = RangeRequest.parse('bytes=-500', 1000)!; + expect(r.start, 500); + expect(r.contentLength, 500); + }); + + test('missing header returns null (full 200)', () { + expect(RangeRequest.parse(null, 1000), isNull); + }); + + test('out-of-range start rejected', () { + expect(RangeRequest.parse('bytes=5000-6000', 1000), isNull); + }); + }); +} diff --git a/packages/core/test/flutter_free_test.dart b/packages/core/test/flutter_free_test.dart new file mode 100644 index 0000000..73ae12d --- /dev/null +++ b/packages/core/test/flutter_free_test.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test('core has zero flutter imports', () async { + final libDir = Directory('lib'); + final offenders = []; + await for (final entity in libDir.list(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final content = await entity.readAsString(); + if (content.contains('package:flutter/') || + content.contains('dart:ui') || + content.contains('package:signals/signals_flutter')) { + offenders.add(entity.path); + } + } + expect(offenders, isEmpty, reason: 'Flutter imports in core: $offenders'); + }); +} diff --git a/packages/core/test/sync_engine_test.dart b/packages/core/test/sync_engine_test.dart new file mode 100644 index 0000000..1623fee --- /dev/null +++ b/packages/core/test/sync_engine_test.dart @@ -0,0 +1,385 @@ +import 'package:sync_relay_core/core.dart'; +import 'package:test/test.dart'; + +void main() { + late DateTime t; + late RoomEngine engine; + DateTime clock() => t; + + setUp(() { + t = DateTime.utc(2026, 1, 1); + engine = RoomEngine(clock: clock); + }); + + String join(String token, String name) => engine.applyJoin(token, name, ''); + + void buffer(String token, double secs) => + engine.applyReport(token, engine.positionAt(), secs, engine.eventId); + + group('membership + gate', () { + test('join lands out-of-set, room stays held', () { + join('a', 'alice'); + expect(engine.transport, Transport.held); + expect(engine.members['a']!.inSet, isFalse); + final room = engine.broadcast(); + expect(room.members.single.inSet, isFalse); + }); + + test('single ready + buffered auto-plays', () { + join('a', 'alice'); + engine.applyReady('a', true); + expect(engine.members['a']!.inSet, isTrue); + expect(engine.transport, Transport.held); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + }); + + test('late joiner does not halt a playing room', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + join('b', 'bob'); + expect(engine.transport, Transport.playing); + expect(engine.members['b']!.inSet, isFalse); + }); + + test('single unready in playing set pauses, stays in set', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + engine.applyReady('b', false); + expect(engine.transport, Transport.held); + expect(engine.members['b']!.inSet, isTrue); + }); + + test('disconnect removes from quorum, room resumes', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + engine.applyLeave('b'); + expect(engine.transport, Transport.playing); + expect(engine.members.containsKey('b'), isFalse); + }); + + test('stale ghosts excluded from gate', () { join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + t = t.add(const Duration(seconds: 60)); + engine.sweepStale(); + expect(engine.transport, Transport.playing); + expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').stale, isTrue); + }); + + test('silent past drop window removed with leave feed', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + t = t.add(const Duration(seconds: 30)); + expect(engine.sweepDropped(), isEmpty); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 61)); + buffer('a', 4.0); + final dropped = engine.sweepDropped(); + expect(dropped, ['b']); + expect(engine.members.containsKey('b'), isFalse); + expect(engine.transport, Transport.playing); + final leaves = engine.feed.drain().where((f) => f.action == 'leave').toList(); + expect(leaves.single.actor, 'bob'); + expect(leaves.single.reason, 'silent too long'); + }); + }); + + group('quorum gate', () { + test('one ready of two fresh stays held', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + buffer('a', 6.0); + expect(engine.transport, Transport.held); + expect(engine.members['a']!.inSet, isTrue); + }); + + test('second ready opens the gate', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + buffer('a', 6.0); + expect(engine.transport, Transport.held); + engine.applyReady('b', true); + buffer('b', 6.0); + expect(engine.transport, Transport.playing); + }); + + test('stale other exempts the lone watcher', () { + join('a', 'alice'); + join('b', 'bob'); + t = t.add(const Duration(seconds: 16)); + engine.applyReady('a', true); + buffer('a', 6.0); + expect(engine.transport, Transport.playing); + }); + + test('late joiner outside the set never blocks', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 6.0); + buffer('b', 6.0); + expect(engine.transport, Transport.playing); + join('c', 'cara'); + expect(engine.transport, Transport.playing); + }); + }); + + group('clock ownership', () { + test('clock advances only while playing', () { + expect(engine.positionAt(), 0); + t = t.add(const Duration(seconds: 10)); + expect(engine.positionAt(), 0); + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 10)); + expect(engine.positionAt(), closeTo(10.0, 0.001)); + }); + + test('lagging report cannot drag clock backward or forward', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 100)); + expect(engine.positionAt(), closeTo(100.0, 0.01)); + engine.applyReport('a', 5.0, 4.0, engine.eventId); + expect(engine.positionAt(), closeTo(100.0, 0.01)); + engine.applyReport('a', 5000.0, 4.0, engine.eventId); + expect(engine.positionAt(), closeTo(100.0, 0.01)); + }); + + test('stale eventId report position ignored, buffer kept', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 50)); + engine.applySeek('a', 200.0); + final eid = engine.eventId; + engine.applyReport('a', 9999.0, 7.0, eid - 1); + expect(engine.positionAt(), closeTo(200.0, 0.01)); + expect(engine.members['a']!.buffered, 7.0); + }); + + test('free seek while playing keeps transport, jumps edge', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 30)); + final before = engine.eventId; + engine.applySeek('a', 600.0); + expect(engine.transport, Transport.playing); + expect(engine.positionAt(), closeTo(600.0, 0.01)); + expect(engine.eventId, greaterThan(before)); + expect(engine.members['a']!.ready, isTrue); + }); + + test('unpause uses room clock, laggards seek forward', () { + join('a', 'alice'); + join('b', 'bob'); + engine.applyReady('a', true); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + t = t.add(const Duration(seconds: 60)); + engine.applyReady('b', false); + final frozen = engine.positionAt(); + expect(frozen, closeTo(60.0, 0.01)); + t = t.add(const Duration(seconds: 30)); + expect(engine.positionAt(), closeTo(60.0, 0.01)); + engine.applyReady('b', true); + buffer('a', 4.0); + buffer('b', 4.0); + expect(engine.transport, Transport.playing); + expect(engine.positionAt(), closeTo(60.0, 0.01)); + }); + }); + + group('starvation without flag churn', () { + test('zero buffer pauses, refill resumes, flags untouched', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + engine.feed.drain(); + engine.applyReport('a', engine.positionAt(), 0.0, engine.eventId); + expect(engine.transport, Transport.held); + expect(engine.members['a']!.ready, isTrue); + final feeds = engine.feed.drain().map((f) => f.action).toList(); + expect(feeds, contains('pause')); + expect(feeds, isNot(contains('unready'))); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + }); + }); + + group('swap resets everything', () { + test('swap bumps mediaId, clears sets, holds', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + engine.applySwap('a', + const HelloMedia(name: 'Ep02.mkv', size: 9, duration: 9.0)); + expect(engine.mediaId, 2); + expect(engine.transport, Transport.held); + expect(engine.positionAt(), 0); + expect(engine.members['a']!.inSet, isFalse); + expect(engine.members['a']!.ready, isFalse); + }); + }); + + group('force with actor feed', () { + test('force-play-all readies everyone, attributed', () { + join('a', 'alice'); + join('b', 'bob'); + engine.feed.drain(); + engine.applyForce('a', 'all', true); + expect(engine.members['b']!.ready, isTrue); + expect(engine.members['b']!.inSet, isTrue); + final feeds = engine.feed.drain(); + expect( + feeds.any((f) => f.action == 'force-play' && f.actor == 'alice'), + isTrue); + }); + + test('force single by member id', () { + join('a', 'alice'); + final idB = join('b', 'bob'); + engine.feed.drain(); + engine.applyForce('a', idB, true); + expect(engine.members['b']!.ready, isTrue); + final feeds = engine.feed.drain(); + expect( + feeds.any((f) => + f.action == 'force-ready' && + f.actor == 'alice' && + f.target == 'bob'), + isTrue); + }); + + test('force unknown id is a no-op', () { + join('a', 'alice'); + engine.feed.drain(); + engine.applyForce('a', 'm99', true); + expect(engine.feed.drain(), isEmpty); + }); + }); + + group('flap stability', () { + test('rapid unready/ready converges to playing, pairs stay balanced', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + engine.feed.drain(); + for (var i = 0; i < 10; i++) { + engine.applyReady('a', false); + engine.applyReady('a', true); + } + buffer('a', 4.0); + expect(engine.transport, Transport.playing); + expect(engine.members['a']!.ready, isTrue); + final actions = engine.feed.drain().map((f) => f.action).toList(); + expect(actions.where((a) => a == 'pause').length, + actions.where((a) => a == 'resume').length); + }); + }); + + group('input sanitizing', () { + test('NaN/negative seek ignored, edge + eventId untouched', () { + join('a', 'alice'); + engine.applyReady('a', true); + buffer('a', 4.0); + t = t.add(const Duration(seconds: 20)); + final eid = engine.eventId; + engine.applySeek('a', double.nan); + engine.applySeek('a', -5.0); + expect(engine.positionAt(), closeTo(20.0, 0.01)); + expect(engine.eventId, eid); + expect(engine.feed.drain().where((f) => f.action == 'seek'), isEmpty); + }); + + test('NaN buffered counts as zero, gate holds', () { + join('a', 'alice'); + engine.applyReady('a', true); + engine.applyReport('a', 0, double.nan, engine.eventId); + expect(engine.members['a']!.buffered, 0); + expect(engine.transport, Transport.held); + }); + }); + + group('tiered correction', () { + test('big drift seeks either direction', () { + expect(correctionFor(ownPosition: 100, roomPosition: 0), Correction.seek); + expect(correctionFor(ownPosition: 0, roomPosition: 100), Correction.seek); + }); + + test('ahead beyond 1.5s slows to 0.95x', () { + expect(correctionFor(ownPosition: 3, roomPosition: 0), Correction.slowDown); + expect(correctionFor(ownPosition: 1.6, roomPosition: 0), Correction.slowDown); + }); + + test('deadzone does nothing', () { + expect(correctionFor(ownPosition: 1.0, roomPosition: 0), Correction.none); + expect(correctionFor(ownPosition: 0, roomPosition: 3.0), Correction.none); + expect(correctionFor(ownPosition: 5.1, roomPosition: 0), Correction.seek); + expect(correctionFor(ownPosition: 5.0, roomPosition: 0), Correction.slowDown); + }); + }); + + group('broadcast shape', () { + test('room JSON round-trips with member views', () { + join('a', 'alice'); + final room = engine.broadcast(); + final back = + RoomBroadcast.fromJson(Map.from(room.toJson())); + expect(back.transport, 'held'); + expect(back.members.single.name, 'alice'); + expect(back.members.single.id, 'm1'); + }); + + test('intent schemas round-trip', () { + const r = ReadyIntent(ready: true, source: 'auto'); + expect( + ReadyIntent.fromJson(Map.from(r.toJson())).source, + 'auto'); + const s = SeekIntent(position: 42.5); + expect( + SeekIntent.fromJson(Map.from(s.toJson())).position, + 42.5); + const c = ChatMessage(text: 'hi'); + expect( + ChatMessage.fromJson(Map.from(c.toJson())).text, + 'hi'); + const rp = ReportMessage(position: 1, buffered: 2, eventId: 3); + final rpBack = + ReportMessage.fromJson(Map.from(rp.toJson())); + expect(rpBack.eventId, 3); + }); + }); +} diff --git a/packages/server/bin/server.dart b/packages/server/bin/server.dart new file mode 100644 index 0000000..5fde438 --- /dev/null +++ b/packages/server/bin/server.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:logging/logging.dart' as pkg_logging; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; + +Future main(List args) async { + final parser = ArgParser() + ..addOption('port', abbr: 'p', defaultsTo: '8080') + ..addOption('secret', defaultsTo: 'change-me') + ..addOption('name', defaultsTo: 'room') + ..addOption('media', defaultsTo: '') + ..addOption('advertise-host', defaultsTo: '127.0.0.1') + ..addOption('config') + ..addFlag('help', abbr: 'h', negatable: false); + final parsed = parser.parse(args); + if (parsed['help'] as bool) { + // ignore: avoid_print + print(parser.usage); + return; + } + + initLogging(level: pkg_logging.Level.INFO); + AppConfig config; + final configPath = parsed['config'] as String?; + if (configPath != null) { + config = await AppConfig.loadFile(configPath); + } else { + config = AppConfig( + port: int.tryParse('${parsed['port']}') ?? 8080, + secret: '${parsed['secret']}', + name: '${parsed['name']}', + mediaPath: '${parsed['media']}', + advertiseHost: '${parsed['advertise-host']}', + ); + } + + final server = RoomServer(config); + await server.start(); + + // ignore: avoid_print + print('READY port=${server.boundPort} room=${config.name}'); + + await ProcessSignal.sigint.watch().first; + await server.stop(); +} diff --git a/packages/server/lib/server.dart b/packages/server/lib/server.dart new file mode 100644 index 0000000..8b479f5 --- /dev/null +++ b/packages/server/lib/server.dart @@ -0,0 +1,4 @@ +export 'src/media_endpoint.dart'; +export 'src/room_hub.dart'; +export 'src/room_server.dart'; +export 'src/session.dart'; diff --git a/packages/server/lib/src/media_endpoint.dart b/packages/server/lib/src/media_endpoint.dart new file mode 100644 index 0000000..21d0ba1 --- /dev/null +++ b/packages/server/lib/src/media_endpoint.dart @@ -0,0 +1,213 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:relic/relic.dart'; +import 'package:sync_relay_core/core.dart'; + +import 'session.dart'; + +class MediaLog { + final String method; + final String path; + final String? rangeHeader; + final int status; + final int bytesWritten; + final int durationMs; + const MediaLog({ + required this.method, + required this.path, + required this.rangeHeader, + required this.status, + required this.bytesWritten, + required this.durationMs, + }); + + @override + String toString() => + '$method $path range=${rangeHeader ?? '-'} -> $status bytes=$bytesWritten ${durationMs}ms'; +} + +class MediaResolution { + final int status; + final Map headers; + final int start; + final int? end; + const MediaResolution({ + required this.status, + required this.headers, + required this.start, + required this.end, + }); + + int contentLength(int size) => (end ?? (size - 1)) - start + 1; +} + +MediaResolution resolveMediaRequest({ + required String? rangeHeader, + required int size, + required String filename, + MediaGate gate = MediaGate.ok, +}) { + Map base(String contentType) => { + 'accept-ranges': 'bytes', + 'content-type': contentType, + }; + final contentType = contentTypeForFile(filename); + switch (gate) { + case MediaGate.gone: + return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0); + case MediaGate.unauthorized: + return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0); + case MediaGate.forbidden: + return MediaResolution(status: 403, headers: base(contentType), start: 0, end: 0); + case MediaGate.offline: + return MediaResolution( + status: 503, + headers: {...base(contentType), 'retry-after': '2'}, + start: 0, + end: 0); + case MediaGate.ok: + break; + } + if (rangeHeader == null || rangeHeader.trim().isEmpty) { + return MediaResolution( + status: 200, + headers: {...base(contentType), 'content-length': '$size'}, + start: 0, + end: null, + ); + } + final parsed = RangeRequest.parse(rangeHeader, size); + if (parsed == null) { + return MediaResolution( + status: 416, + headers: {...base(contentType), 'content-range': 'bytes */$size'}, + start: 0, + end: 0, + ); + } + final end = parsed.end ?? size - 1; + return MediaResolution( + status: 206, + headers: { + ...base(contentType), + 'content-range': 'bytes ${parsed.start}-$end/$size', + 'content-length': '${end - parsed.start + 1}', + }, + start: parsed.start, + end: parsed.end, + ); +} + +void mountMediaRoute(RelicApp app, File file, + {String route = '/m/file', void Function(MediaLog log)? onLog}) { + app.get(route, (final req) async { + final stopwatch = Stopwatch()..start(); + final size = await file.length(); + final name = file.path.split(Platform.pathSeparator).last; + final typed = req.headers.range; + String? rangeHeader; + if (typed != null && typed.ranges.isNotEmpty) { + final r = typed.ranges.first; + rangeHeader = '${typed.unit}=${r.start?.toString() ?? ''}-${r.end?.toString() ?? ''}'; + } + final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: name); + void log(int bytes) => onLog?.call(MediaLog( + method: req.method.name, + path: route, + rangeHeader: rangeHeader, + status: res.status, + bytesWritten: bytes, + durationMs: stopwatch.elapsedMilliseconds, + )); + if (res.status != 200 && res.status != 206) { + log(0); + return Response( + res.status, + body: Body.fromString('status ${res.status}'), + headers: Headers.build((h) { + res.headers.forEach((k, v) => h[k] = [v]); + }), + ); + } + final end = res.end ?? size - 1; + final byteCount = end - res.start + 1; + final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c)); + log(byteCount); + return Response( + res.status, + body: Body.fromDataStream(stream, contentLength: byteCount), + headers: Headers.build((h) { + res.headers.forEach((k, v) => h[k] = [v]); + }), + ); + }); +} + +Response _gatedResponse(MediaResolution res) => Response( + res.status, + body: Body.fromString('status ${res.status}'), + headers: Headers.build((h) { + res.headers.forEach((k, v) => h[k] = [v]); + }), + ); + +void mountSessionMediaRoute( + RelicApp app, + SessionMap sessions, { + String pattern = '/m/:sid/:token/:name', + void Function(String token, MediaLog log)? onLog, +}) { + app.get(pattern, (final req) async { + final stopwatch = Stopwatch()..start(); + final sid = req.pathParameters.raw[#sid] ?? ''; + final token = req.pathParameters.raw[#token] ?? ''; + final session = sessions.get(sid); + if (session == null) { + return _gatedResponse(resolveMediaRequest( + rangeHeader: null, size: 1, filename: 'x', gate: MediaGate.gone)); + } + final gate = session.gate(token); + if (gate != MediaGate.ok) { + return _gatedResponse( + resolveMediaRequest(rangeHeader: null, size: 1, filename: 'x', gate: gate)); + } + final media = session.media; + final file = media?.file; + if (media == null || file == null) { + return _gatedResponse(resolveMediaRequest( + rangeHeader: null, size: 1, filename: 'x', gate: MediaGate.offline)); + } + final size = media.size; + final typed = req.headers.range; + String? rangeHeader; + if (typed != null && typed.ranges.isNotEmpty) { + final r = typed.ranges.first; + rangeHeader = '${typed.unit}=${r.start?.toString() ?? ''}-${r.end?.toString() ?? ''}'; + } + final res = resolveMediaRequest(rangeHeader: rangeHeader, size: size, filename: media.name); + if (res.status != 200 && res.status != 206) { + return _gatedResponse(res); + } + final end = res.end ?? size - 1; + final byteCount = end - res.start + 1; + session.addBytes(token, byteCount); + final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token; + onLog?.call(token, MediaLog( + method: req.method.name, + path: '/m/$sid/$shortToken/${media.name}', + rangeHeader: rangeHeader, + status: res.status, + bytesWritten: byteCount, + durationMs: stopwatch.elapsedMilliseconds, + )); + final stream = file.openRead(res.start, end + 1).map((c) => Uint8List.fromList(c)); + return Response( + res.status, + body: Body.fromDataStream(stream, contentLength: byteCount), + headers: Headers.build((h) { + res.headers.forEach((k, v) => h[k] = [v]); + }), + ); + }); +} diff --git a/packages/server/lib/src/room_hub.dart b/packages/server/lib/src/room_hub.dart new file mode 100644 index 0000000..6e4d4bb --- /dev/null +++ b/packages/server/lib/src/room_hub.dart @@ -0,0 +1,297 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:relic/relic.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:web_socket/web_socket.dart'; + +import 'session.dart'; + +final _hubLog = createLogger('room_hub'); + +class RoomHub { + final SessionMap sessions; + final String expectedSecret; + final RoomEngine engine; + final Map sockets = {}; + + RoomHub(this.sessions, this.expectedSecret, {RoomEngine? engine}) + : engine = engine ?? RoomEngine(); + + Session get session => sessions.getOrCreate('default', expectedSecret); + + void broadcastRoom() { + final raw = jsonEncode(engine.broadcast().toJson()); + for (final ws in sockets.values.toList()) { + ws.trySendText(raw); + } + } + + void broadcastFeed() { + for (final feed in engine.feed.drain()) { + final raw = jsonEncode(feed.toJson()); + for (final ws in sockets.values.toList()) { + ws.trySendText(raw); + } + } + } + + void dropSilentSockets() { + for (final token in engine.sweepDropped()) { + final ws = sockets[token]; + if (ws != null) unawaited(ws.tryClose()); + forgetSocket(token); + } + } + + void sweepClosedSockets() { + for (final entry in sockets.entries.toList()) { + var closed = false; + try { + closed = entry.value.isClosed; + } catch (_) { + closed = true; + } + if (closed) forgetSocket(entry.key); + } + } + + Future closeAllSockets() async { + final all = sockets.values.toList(); + sockets.clear(); + for (final ws in all) { + try { + await ws.tryClose(); + } catch (_) {} + } + } + + void broadcastChat(String from, String text) { + final clean = text.length > RelayConstants.maxChatLength + ? text.substring(0, RelayConstants.maxChatLength) + : text; + final raw = jsonEncode( + ChatMessage(text: clean).toJson()..addAll({'from': from})); + for (final ws in sockets.values.toList()) { + ws.trySendText(raw); + } + engine.feed.add( + FeedMessage(actor: from, action: 'chat', at: engine.now)); + broadcastFeed(); + } + + void changed() { + broadcastRoom(); + broadcastFeed(); + } + + void forgetSocket(String token) { + engine.applyLeave(token); + final viewer = session.tokens[token]; + if (viewer != null && !viewer.kicked) session.tokens.remove(token); + sockets.remove(token); + changed(); + } + + Future kickById(String targetId, String reason) async { + final member = engine.byId(targetId); + if (member == null) throw const AuthException('unknown kick target'); + await session.kick(member.token, reason: reason); + } +} + +void wsErr(RelicWebSocket ws, String message) { + ws.trySendText(jsonEncode({'t': 'err', 'message': message})); +} + +void mountSyncRoute( + RelicApp app, + RoomHub hub, + String Function(String sid, String token, SessionMedia? media) urlBuilder, +) { + app.get('/sync', (final req) { + return WebSocketUpgrade((final ws) async { + ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs); + final firstText = Completer(); + String? token; + var welcomed = false; + late StreamSubscription sub; + Future close() async { + try { + await sub.cancel(); + } catch (_) {} + await ws.tryClose(); + } + + void handleIntent(String text) { + final session = hub.session; + final viewer = token == null ? null : session.tokens[token]; + viewer?.touch(); + Map decoded; + try { + final parsed = jsonDecode(text); + if (parsed is! Map) return; + decoded = Map.from(parsed); + } catch (_) { + return; + } + final type = decoded['t']; + try { + switch (type) { + case 'ready': + if (token == null) return; + final intent = ReadyIntent.fromJson(decoded); + hub.engine.applyReady(token, intent.ready, source: intent.source); + hub.changed(); + case 'force': + if (token == null) return; + final intent = ForceIntent.fromJson(decoded); + hub.engine.applyForce(token, intent.target, intent.ready); + hub.changed(); + case 'seek': + if (token == null) return; + final intent = SeekIntent.fromJson(decoded); + hub.engine.applySeek(token, intent.position); + hub.changed(); + case 'swap': + if (token == null) return; + final intent = SwapIntent.fromJson(decoded); + hub.engine.applySwap(token, intent.media); + final current = session.media; + if (current?.file != null && + current!.name == intent.media.name && + current.size == intent.media.size) { + session.media = SessionMedia( + name: current.name, + size: current.size, + duration: intent.media.duration, + url: intent.media.url, + file: current.file, + mediaId: hub.engine.mediaId); + } else { + session.media = SessionMedia( + name: intent.media.name, + size: intent.media.size, + duration: intent.media.duration, + url: intent.media.url, + mediaId: hub.engine.mediaId); + } + hub.changed(); + case 'report': + if (token == null) return; + final report = ReportMessage.fromJson(decoded); + hub.engine.applyReport( + token, report.position, report.buffered, report.eventId); + hub.broadcastRoom(); + case 'chat': + final chat = ChatMessage.fromJson(decoded); + hub.broadcastChat(viewer?.name ?? '?', chat.text); + case 'kick': + final kickReq = KickRequest.fromJson(decoded); + final member = hub.engine.byId(kickReq.target); + if (member == null) { + wsErr(ws, 'unknown kick target'); + return; + } + final by = viewer?.name ?? '?'; + unawaited(hub.session.kick(member.token, + reason: kickReq.reason.isEmpty + ? 'kicked by $by' + : kickReq.reason)); + } + } catch (_) { + return; + } + } + + sub = ws.events.listen( + (event) { + if (event is! TextDataReceived) return; + if (!firstText.isCompleted) { + firstText.complete(event.text); + } else if (welcomed) { + handleIntent(event.text); + } + }, + onError: (_) { + if (token != null) hub.forgetSocket(token); + }, + onDone: () { + if (!firstText.isCompleted) { + firstText.completeError(StateError('closed before hello')); + } else if (token != null) { + hub.forgetSocket(token); + } + }, + ); + String raw; + try { + raw = await firstText.future.timeout(const Duration(seconds: 10)); + } catch (_) { + await close(); + return; + } + HelloMessage hello; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) throw const ProtocolException('hello must be an object'); + hello = HelloMessage.fromJson(Map.from(decoded)); + } catch (e) { + _hubLog.warning('reject: bad hello: $e'); + wsErr(ws, 'bad hello: $e'); + await close(); + return; + } + if (hello.proto != RelayConstants.protoVersion) { + _hubLog.warning( + 'reject ${hello.user.name}: proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}'); + wsErr(ws, 'proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}'); + await close(); + return; + } + final session = hub.session; + if (hello.secret != session.secret) { + _hubLog.warning('reject ${hello.user.name}: wrong secret'); + wsErr(ws, 'wrong secret'); + await close(); + return; + } + try { + token = session.issueToken(hello.user.name, hello.user.avatar); + } catch (e) { + _hubLog.warning('reject ${hello.user.name}: $e'); + wsErr(ws, 'rejected: $e'); + await close(); + return; + } + final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar); + final activeToken = token; + hub.sockets[activeToken] = ws; + session.tokens[activeToken]!.onKick = (reason) async { + ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson())); + await Future.delayed(const Duration(milliseconds: 200)); + await ws.tryClose(); + hub.forgetSocket(activeToken); + }; + final media = session.media; + final welcome = WelcomeMessage( + token: activeToken, + memberId: memberId, + mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media) : media?.url, + state: hub.engine.broadcast().toJson(), + ); + try { + ws.sendText(jsonEncode(welcome.toJson())); + } catch (_) { + await close(); + return; + } + welcomed = true; + _hubLog.info('join ${hello.user.name} ($memberId)'); + hub.changed(); + try { + await sub.asFuture(); + } catch (_) {} + }); + }); +} diff --git a/packages/server/lib/src/room_server.dart b/packages/server/lib/src/room_server.dart new file mode 100644 index 0000000..c788b55 --- /dev/null +++ b/packages/server/lib/src/room_server.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart' as pkg_logging; +import 'package:relic/relic.dart'; +import 'package:sync_relay_core/core.dart'; + +import 'media_endpoint.dart'; +import 'room_hub.dart'; +import 'session.dart'; + +class RoomServer { + final AppConfig config; + final pkg_logging.Logger _log = createLogger('room_server'); + final RelicApp _app = RelicApp(); + final SessionMap sessions = SessionMap(); + late final RoomHub hub; + RelicServer? _server; + DateTime? _startedAt; + Timer? _heartbeat; + bool _routesMounted = false; + + RoomServer(this.config) { + hub = RoomHub(sessions, config.secret); + } + + bool get isRunning => _server != null; + int get boundPort => _server?.port ?? config.port; + Session get session => sessions.getOrCreate('default', config.secret); + + Future setLocalFile(String path) async { + final file = File(path); + if (!await file.exists()) return false; + final name = file.path.split(Platform.pathSeparator).last; + final size = await file.length(); + session.media = SessionMedia(name: name, size: size, duration: 0, file: file); + hub.engine.media = HelloMedia(name: name, size: size, duration: 0); + return true; + } + + void _mountRoutes() { + if (_routesMounted) return; + _routesMounted = true; + _app.get('/health', (final req) { + final s = sessions.get('default'); + final viewers = s?.tokens.length ?? 0; + final bytes = s?.totalBytesServed ?? 0; + final uptime = _startedAt == null ? 0 : DateTime.now().difference(_startedAt!).inSeconds; + return Response.ok( + body: Body.fromString(jsonEncode({ + 'ok': true, + 'room': config.name, + 'proto': RelayConstants.protoVersion, + 'viewers': viewers, + 'bytesServed': bytes, + 'uptimeSecs': uptime, + 'media': s?.media?.name, + 'transport': hub.engine.transport.name, + 'eventId': hub.engine.eventId, + })), + ); + }); + mountSessionMediaRoute(_app, sessions); + mountSyncRoute(_app, hub, (sid, token, media) { + final name = Uri.encodeComponent(media?.name ?? 'file'); + return 'http://${config.advertiseHost}:$boundPort/m/$sid/$token/$name'; + }); + _app.fallback = (final req) => Response.notFound( + body: Body.fromString('not found'), + ); + } + + Future start() async { + if (_server != null) return; + final s = session; + if (config.mediaPath.isNotEmpty) { + final file = File(config.mediaPath); + if (await file.exists()) { + final name = file.path.split(Platform.pathSeparator).last; + final size = await file.length(); + s.media = SessionMedia(name: name, size: size, duration: 0, file: file); + hub.engine.media = HelloMedia(name: name, size: size, duration: 0); + } else { + _log.warning('media file not found: ${config.mediaPath}'); + } + } + _mountRoutes(); + _server = await _app.serve( + address: InternetAddress.anyIPv4, + port: config.port, + ); + _startedAt = DateTime.now(); + _heartbeat = Timer.periodic(const Duration(seconds: 1), (_) { + hub.engine.sweepStale(); + hub.sweepClosedSockets(); + hub.dropSilentSockets(); + hub.broadcastRoom(); + hub.broadcastFeed(); + }); + _log.info('room "${config.name}" listening on $boundPort'); + } + + Future stop() async { + if (_server == null) return; + _heartbeat?.cancel(); + _heartbeat = null; + await hub.closeAllSockets(); + await _app.close(); + _server = null; + _log.info('room "${config.name}" stopped'); + } +} diff --git a/packages/server/lib/src/session.dart b/packages/server/lib/src/session.dart new file mode 100644 index 0000000..b20383e --- /dev/null +++ b/packages/server/lib/src/session.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:sync_relay_core/core.dart'; + +enum MediaGate { ok, gone, unauthorized, forbidden, offline } + +class ViewerToken { + final String token; + final String name; + final String avatar; + bool kicked = false; + int bytesServed = 0; + DateTime lastSeen; + Future Function(String reason)? onKick; + ViewerToken({required this.token, required this.name, this.avatar = ''}) + : lastSeen = DateTime.now().toUtc(); + + bool isStale(Duration timeout, [DateTime? now]) => + (now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout; + + void touch([DateTime? now]) => lastSeen = now ?? DateTime.now().toUtc(); +} + +class SessionMedia { + final String name; + final int size; + final double duration; + final String? url; + final File? file; + final int mediaId; + const SessionMedia({ + required this.name, + required this.size, + required this.duration, + this.url, + this.file, + this.mediaId = 1, + }); +} + +class Session { + final String id; + final String secret; + final Map tokens = {}; + SessionMedia? media; + final _random = Random.secure(); + + Session({required this.id, required this.secret}); + + String issueToken(String name, String avatar) { + if (avatar.length > RelayConstants.maxAvatarBytes) { + throw const AuthException('avatar too large'); + } + final trimmed = name.trim(); + final cleanName = trimmed.isEmpty + ? '?' + : trimmed.substring( + 0, trimmed.length.clamp(0, RelayConstants.maxNameLength)); + final bytes = List.generate(16, (_) => _random.nextInt(256)); + final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar); + return token; + } + + MediaGate gate(String? token) { + if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized; + if (tokens[token]!.kicked) return MediaGate.forbidden; + return MediaGate.ok; + } + + Future kick(String token, {String reason = ''}) async { + final viewer = tokens[token]; + if (viewer == null) throw const AuthException('unknown token'); + viewer.kicked = true; + await viewer.onKick?.call(reason); + } + + void addBytes(String? token, int n) { + if (token != null) tokens[token]?.bytesServed += n; + } + + int get totalBytesServed => tokens.values.fold(0, (a, v) => a + v.bytesServed); +} + +class SessionMap { + final Map _sessions = {}; + + Session getOrCreate(String id, String secret) => + _sessions.putIfAbsent(id, () => Session(id: id, secret: secret)); + + Session? get(String id) => _sessions[id]; + + void drop(String id) => _sessions.remove(id); +} diff --git a/packages/server/pubspec.lock b/packages/server/pubspec.lock new file mode 100644 index 0000000..ce3a4e4 --- /dev/null +++ b/packages/server/pubspec.lock @@ -0,0 +1,420 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: fe18c7e37d5acb3b43224fa255281dec007970d6fd01f6d76fcd88ed3777a117 + url: "https://pub.dev" + source: hosted + version: "107.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f2dde9e50c23fc95b846b4ef334e98c3ae8c0effd5d2453b02fc9fb3f8939791 + url: "https://pub.dev" + source: hosted + version: "14.3.0" + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + relic: + dependency: "direct main" + description: + name: relic + sha256: d917b188dd1b6838c630d1904a1e26b040e6647406f2aab01b10fdf0ca051c12 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_core: + dependency: transitive + description: + name: relic_core + sha256: c2f7638d56fdf144a035d17eb8e93fb30fe53bd105fd81c31393f8be4f43ecdc + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_io: + dependency: transitive + description: + name: relic_io + sha256: d64a9f309943200ec7a45b2a127cbc5abf4dd6603159a179396d66b932a15f1f + url: "https://pub.dev" + source: hosted + version: "1.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_relay_core: + dependency: "direct main" + description: + path: "../core" + relative: true + source: path + version: "0.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 + url: "https://pub.dev" + source: hosted + version: "1.32.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" + url: "https://pub.dev" + source: hosted + version: "0.6.20" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: "direct main" + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct dev" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/server/pubspec.yaml b/packages/server/pubspec.yaml new file mode 100644 index 0000000..1dc1045 --- /dev/null +++ b/packages/server/pubspec.yaml @@ -0,0 +1,17 @@ +name: sync_relay_server +description: Pure-Dart room + media server. Embeddable, no Flutter. +publish_to: none +version: 0.1.0 +environment: + sdk: ^3.0.0 +dependencies: + sync_relay_core: + path: ../core + logging: ^1.2.0 + args: ^2.4.0 + relic: ^1.2.0 + web_socket: ^1.0.0 +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 + web_socket_channel: ^3.0.0 diff --git a/packages/server/test/lifecycle_test.dart b/packages/server/test/lifecycle_test.dart new file mode 100644 index 0000000..9d85f8d --- /dev/null +++ b/packages/server/test/lifecycle_test.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +class _ProbeClient { + final WebSocket ws; + final Completer welcomed = Completer(); + final Completer closed = Completer(); + _ProbeClient(this.ws) { + ws.listen( + (m) { + if (!welcomed.isCompleted) { + final decoded = jsonDecode('$m') as Map; + expect(decoded['t'], 'welcome'); + welcomed.complete(); + } + }, + onDone: () { + if (!closed.isCompleted) { + closed.complete('done code=${ws.closeCode} reason=${ws.closeReason}'); + } + }, + onError: (Object e) { + if (!closed.isCompleted) closed.complete('error $e'); + }, + ); + } + + static Future<_ProbeClient> hello(int port, String name) async { + final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync'); + final c = _ProbeClient(ws); + ws.add(jsonEncode({ + 't': 'hello', + 'proto': RelayConstants.protoVersion, + 'secret': 'cycle', + 'user': {'name': name}, + 'media': {'name': '', 'size': 0, 'duration': 0.0}, + })); + await c.welcomed.future.timeout(const Duration(seconds: 5)); + return c; + } +} + +Future _pollBool( + FutureOr Function() cond, + String what, { + Duration timeout = const Duration(seconds: 8), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 100)); + } + fail('timed out: $what'); +} + +void main() { + group('presence lifecycle live', () { + late RoomServer server; + + setUp(() async { + server = RoomServer( + const AppConfig(port: 0, secret: 'cycle', name: 'cycle')); + await server.start(); + }); + + tearDown(() async { + await server.stop(); + }); + + test('clean client close removes member within seconds, not timeout', + () async { + final client = await _ProbeClient.hello(server.boundPort, 'alice'); + await _pollBool( + () => server.hub.engine.members.length == 1, 'join visible'); + await client.ws.close(); + await _pollBool( + () => server.hub.engine.members.isEmpty, 'member removed'); + expect(server.session.tokens, isEmpty); + expect(server.hub.sockets, isEmpty); + }); + + test('server stop closes client sockets', () async { + final client = await _ProbeClient.hello(server.boundPort, 'bob'); + await _pollBool( + () => server.hub.engine.members.length == 1, 'join visible'); + await server.stop(); + final outcome = await client.closed.future + .timeout(const Duration(seconds: 5), onTimeout: () => 'TIMEOUT'); + expect(outcome, isNot('TIMEOUT'), reason: 'client never saw close'); + }); + }); +} diff --git a/packages/server/test/media_endpoint_test.dart b/packages/server/test/media_endpoint_test.dart new file mode 100644 index 0000000..8bbe559 --- /dev/null +++ b/packages/server/test/media_endpoint_test.dart @@ -0,0 +1,172 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:relic/relic.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +void main() { + group('resolveMediaRequest pure table', () { + test('no Range -> 200 full with exact length', () { + final r = resolveMediaRequest(rangeHeader: null, size: 1000, filename: 'a.mkv'); + expect(r.status, 200); + expect(r.headers['accept-ranges'], 'bytes'); + expect(r.headers['content-type'], 'video/x-matroska'); + expect(r.headers['content-length'], '1000'); + expect(r.start, 0); + expect(r.end, isNull); + }); + + test('closed range -> 206 exact bytes + content-range', () { + final r = resolveMediaRequest(rangeHeader: 'bytes=0-99', size: 1000, filename: 'a.mkv'); + expect(r.status, 206); + expect(r.headers['content-range'], 'bytes 0-99/1000'); + expect(r.headers['content-length'], '100'); + }); + + test('open-ended range -> 206 to EOF', () { + final r = resolveMediaRequest(rangeHeader: 'bytes=900-', size: 1000, filename: 'b.mp4'); + expect(r.status, 206); + expect(r.headers['content-range'], 'bytes 900-999/1000'); + expect(r.headers['content-length'], '100'); + expect(r.headers['content-type'], 'video/mp4'); + }); + + test('suffix range -> tail (mkv cues probe)', () { + final r = resolveMediaRequest(rangeHeader: 'bytes=-500', size: 1000, filename: 'c.mkv'); + expect(r.status, 206); + expect(r.start, 500); + expect(r.headers['content-length'], '500'); + }); + + test('out-of-range start -> 416 with bytes */size', () { + final r = resolveMediaRequest(rangeHeader: 'bytes=5000-6000', size: 1000, filename: 'a.mkv'); + expect(r.status, 416); + expect(r.headers['content-range'], 'bytes */1000'); + }); + + test('gates: gone/auth/forbidden/offline', () { + expect( + resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.gone).status, + 410); + expect( + resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized) + .status, + 401); + expect( + resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status, + 403); + final off = + resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline); + expect(off.status, 503); + expect(off.headers['retry-after'], isNotEmpty); + }); + + test('content types by extension', () { + expect(contentTypeForFile('x.MKV'), 'video/x-matroska'); + expect(contentTypeForFile('x.mp4'), 'video/mp4'); + expect(contentTypeForFile('x.webm'), 'video/webm'); + expect(contentTypeForFile('x.avi'), 'video/x-msvideo'); + expect(contentTypeForFile('x.unknown'), 'application/octet-stream'); + }); + }); + + group('live Relic media route (byte-exact)', () { + late File file; + late Uint8List bytes; + late RelicApp app; + late RelicServer server; + late HttpClient client; + + setUp(() async { + bytes = Uint8List.fromList(List.generate(65536, (i) => i % 251)); + file = File('${Directory.systemTemp.path}/media_test_${DateTime.now().microsecondsSinceEpoch}.bin'); + await file.writeAsBytes(bytes); + app = RelicApp(); + mountMediaRoute(app, file); + server = await app.serve(address: InternetAddress.loopbackIPv4, port: 0); + client = HttpClient(); + }); + + tearDown(() async { + client.close(); + await app.close(); + await file.delete(); + }); + + Future get(String path, {String? range}) async { + final req = await client.get('127.0.0.1', server.port, path); + if (range != null) req.headers.set('range', range); + return req.close(); + } + + Future> readAll(HttpClientResponse resp) async { + final out = []; + await for (final chunk in resp) { + out.addAll(chunk); + } + return out; + } + + test('200 full body matches file bytes', () async { + final resp = await get('/m/file'); + expect(resp.statusCode, 200); + expect(resp.headers.value('accept-ranges'), 'bytes'); + expect(resp.headers.value('content-length'), '${bytes.length}'); + expect(await readAll(resp), bytes); + }); + + test('206 closed range byte-exact', () async { + final resp = await get('/m/file', range: 'bytes=100-199'); + expect(resp.statusCode, 206); + expect(resp.headers.value('content-range'), 'bytes 100-199/${bytes.length}'); + expect(resp.headers.value('content-length'), '100'); + expect(await readAll(resp), bytes.sublist(100, 200)); + }); + + test('206 open-ended streams to EOF', () async { + final resp = await get('/m/file', range: 'bytes=${bytes.length - 1000}-'); + expect(resp.statusCode, 206); + expect(resp.headers.value('content-length'), '1000'); + expect(await readAll(resp), bytes.sublist(bytes.length - 1000)); + }); + + test('206 suffix tail byte-exact', () async { + final resp = await get('/m/file', range: 'bytes=-256'); + expect(resp.statusCode, 206); + expect(await readAll(resp), bytes.sublist(bytes.length - 256)); + }); + + test('seek storm: 20 rapid seeks all byte-exact', () async { + for (var i = 0; i < 20; i++) { + final start = (i * 997) % (bytes.length - 512); + final resp = await get('/m/file', range: 'bytes=$start-${start + 511}'); + expect(resp.statusCode, 206); + expect(await readAll(resp), bytes.sublist(start, start + 512)); + } + }); + + test('onLog hook records method/range/status/bytes', () async { + final seen = []; + final logApp = RelicApp(); + mountMediaRoute(logApp, file, route: '/m/logged', onLog: seen.add); + final logServer = + await logApp.serve(address: InternetAddress.loopbackIPv4, port: 0); + try { + final req = await client.get('127.0.0.1', logServer.port, '/m/logged'); + req.headers.set('range', 'bytes=0-9'); + final resp = await req.close(); + await resp.drain(); + expect(resp.statusCode, 206); + } finally { + await logApp.close(); + } + expect(seen, hasLength(1)); + expect(seen.single.status, 206); + expect(seen.single.bytesWritten, 10); + expect(seen.single.rangeHeader, 'bytes=0-9'); + expect(seen.single.toString(), contains('206')); + }); + }); +} diff --git a/packages/server/test/room_auth_test.dart b/packages/server/test/room_auth_test.dart new file mode 100644 index 0000000..d7aaf7f --- /dev/null +++ b/packages/server/test/room_auth_test.dart @@ -0,0 +1,391 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() { + group('room auth live', () { + late File file; + late Uint8List bytes; + late RoomServer server; + late HttpClient http; + + setUp(() async { + bytes = Uint8List.fromList(List.generate(8192, (i) => i % 251)); + file = File( + '${Directory.systemTemp.path}/room_test_${DateTime.now().microsecondsSinceEpoch}.bin'); + await file.writeAsBytes(bytes); + server = RoomServer(AppConfig( + port: 0, secret: 'shh', name: 'test', mediaPath: file.path)); + await server.start(); + http = HttpClient(); + }); + + tearDown(() async { + http.close(); + await server.stop(); + await file.delete(); + }); + + Future mediaGet(String path, {String? range}) async { + final req = await http.get('127.0.0.1', server.boundPort, path); + if (range != null) req.headers.set('range', range); + return req.close(); + } + + Future hello(String secret, + {int proto = RelayConstants.protoVersion, + Duration timeout = const Duration(seconds: 5)}) async { + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final msg = HelloMessage( + proto: proto, + secret: secret, + user: const HelloUser(name: 'alice'), + media: + const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson(); + ch.sink.add(jsonEncode(msg)); + try { + final first = await ch.stream.first.timeout(timeout); + await ch.sink.close(); + return '$first'; + } catch (_) { + try { + await ch.sink.close(); + } catch (_) {} + rethrow; + } + } + + test('health reports room, proto, viewers, bytes', () async { + final req = await http.get('127.0.0.1', server.boundPort, '/health'); + final resp = await req.close(); + expect(resp.statusCode, 200); + final body = await resp.transform(utf8.decoder).join(); + final decoded = jsonDecode(body) as Map; + expect(decoded['ok'], isTrue); + expect(decoded['room'], 'test'); + expect(decoded['proto'], RelayConstants.protoVersion); + expect(decoded['viewers'], 0); + expect(decoded['bytesServed'], 0); + expect(decoded['media'], isNotNull); + }); + + test('wrong secret gets err, right secret gets welcome+token', () async { + final bad = await hello('wrong'); + expect(jsonDecode(bad)['t'], 'err'); + + final good = await hello('shh'); + final decoded = jsonDecode(good) as Map; + expect(decoded['t'], 'welcome'); + final token = decoded['token'] as String; + expect(token, hasLength(32)); + expect(decoded['mediaUrl'], contains(token)); + + final req = await http.get('127.0.0.1', server.boundPort, '/health'); + final resp = await req.close(); + await resp.drain(); + expect(server.session.tokens.length, 1); + }); + + test('proto mismatch rejected', () async { + final reply = + await hello('shh', proto: RelayConstants.protoVersion + 99); + expect(jsonDecode(reply)['t'], 'err'); + expect(server.session.tokens, isEmpty); + }); + + test('media: no token 401, bad token 401, good token 200 byte-exact', + () async { + final good = await hello('shh'); + final token = (jsonDecode(good) as Map)['token'] as String; + final name = file.path.split(Platform.pathSeparator).last; + + var resp = await mediaGet('/m/default/notoken/$name'); + expect(resp.statusCode, 401); + await resp.drain(); + + resp = await mediaGet('/m/default/deadbeefdeadbeefdeadbeefdeadbeef/$name'); + expect(resp.statusCode, 401); + await resp.drain(); + + resp = await mediaGet('/m/default/$token/$name', range: 'bytes=0-99'); + expect(resp.statusCode, 206); + expect(resp.headers.value('content-range'), 'bytes 0-99/8192'); + final body = []; + await for (final c in resp) { + body.addAll(c); + } + expect(body, bytes.sublist(0, 100)); + expect(server.session.tokens[token]!.bytesServed, 100); + }); + + test('unknown session -> 410', () async { + final resp = await mediaGet('/m/nosuch/abc/file'); + expect(resp.statusCode, 410); + await resp.drain(); + }); + + test('kick: WS drops and media goes 403', () async { + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final firstCompleter = Completer(); + var wsClosed = false; + final sub = ch.stream.listen( + (m) { + if (!firstCompleter.isCompleted) firstCompleter.complete('$m'); + }, + onDone: () => wsClosed = true, + ); + final msg = HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: const HelloUser(name: 'mallory'), + media: + const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson(); + ch.sink.add(jsonEncode(msg)); + final first = + await firstCompleter.future.timeout(const Duration(seconds: 5)); + final token = (jsonDecode(first) as Map)['token'] as String; + + await server.session.kick(token); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!wsClosed && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(wsClosed, isTrue); + try { + await sub.cancel(); + } catch (_) {} + try { + await ch.sink.close(); + } catch (_) {} + + final name = file.path.split(Platform.pathSeparator).last; + final resp = await mediaGet('/m/default/$token/$name'); + expect(resp.statusCode, 403); + await resp.drain(); + }); + + test('kick delivers reason then drops; instant rejoin works', () async { + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final messages = []; + var wsClosed = false; + final sub = ch.stream.listen( + (m) => messages.add('$m'), + onDone: () => wsClosed = true, + ); + HelloMessage helloFor(String who) => HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: HelloUser(name: who), + media: const HelloMedia( + name: 'f.bin', size: 8192, duration: 10.0)); + ch.sink.add(jsonEncode(helloFor(' sleepy').toJson())); + Map? welcome; + final welcomeDeadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(welcomeDeadline)) { + for (final m in messages) { + final decoded = jsonDecode(m) as Map; + if (decoded['t'] == 'welcome') { + welcome = Map.from(decoded); + } + } + if (welcome != null) break; + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(welcome, isNotNull); + final token = welcome!['token'] as String; + + await server.session.kick(token, reason: 'idle too long'); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + Map? kicked; + while (DateTime.now().isBefore(deadline)) { + for (final m in messages.skip(1)) { + final decoded = jsonDecode(m) as Map; + if (decoded['t'] == 'kicked') { + kicked = Map.from(decoded); + } + } + if (wsClosed && kicked != null) break; + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(wsClosed, isTrue); + expect(kicked, isNotNull); + expect(kicked!['reason'], 'idle too long'); + try { + await sub.cancel(); + } catch (_) {} + try { + await ch.sink.close(); + } catch (_) {} + + final rejoin = await hello('shh'); + final decoded = jsonDecode(rejoin) as Map; + expect(decoded['t'], 'welcome'); + final token2 = decoded['token'] as String; + expect(token2, isNot(token)); + final name = file.path.split(Platform.pathSeparator).last; + final resp = await mediaGet('/m/default/$token2/$name'); + expect(resp.statusCode, 200); + await resp.drain(); + }); + + test('kick via socket: peer kicked, unknown target errors', () async { + WebSocketChannel open() => WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + Future<(WebSocketChannel, String, String, List)> join(String who) async { + final ch = open(); + final messages = []; + ch.stream.listen((m) => messages.add('$m')); + final msg = HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: HelloUser(name: who), + media: const HelloMedia( + name: 'f.bin', size: 8192, duration: 10.0)).toJson(); + ch.sink.add(jsonEncode(msg)); + Map? welcome; + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + for (final m in messages) { + final decoded = jsonDecode(m) as Map; + if (decoded['t'] == 'welcome') { + welcome = Map.from(decoded); + } + } + if (welcome != null) break; + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(welcome, isNotNull, + reason: 'no welcome for $who: $messages'); + final w = welcome!; + return (ch, w['token'] as String, w['memberId'] as String, messages); + } + + Map? findType(List messages, String type) { + for (final m in messages) { + final decoded = jsonDecode(m) as Map; + if (decoded['t'] == type) return Map.from(decoded); + } + return null; + } + + Future?> waitType( + List messages, String type, Duration timeout) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final found = findType(messages, type); + if (found != null) return found; + await Future.delayed(const Duration(milliseconds: 50)); + } + return findType(messages, type); + } + + final (a, _, _, messagesA) = await join('alice'); + final (b, tokenB, idB, messagesB) = await join('bob'); + try { + a.sink.add(jsonEncode( + KickRequest(target: idB, reason: 'afk?').toJson())); + final kicked = await waitType( + messagesB, 'kicked', const Duration(seconds: 5)); + expect(kicked, isNotNull); + expect(kicked!['reason'], 'afk?'); + expect(server.session.gate(tokenB), MediaGate.forbidden); + + a.sink.add( + jsonEncode(const KickRequest(target: 'm99').toJson())); + final err = await waitType( + messagesA, 'err', const Duration(seconds: 5)); + expect(err, isNotNull); + } finally { + try { + await a.sink.close(); + } catch (_) {} + try { + await b.sink.close(); + } catch (_) {} + } + }); + + test('silent socket dropped and member removed', () async { + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final messages = []; + var wsClosed = false; + final sub = ch.stream.listen( + (m) => messages.add('$m'), + onDone: () => wsClosed = true, + ); + final msg = HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: const HelloUser(name: 'ghost'), + media: const HelloMedia( + name: 'f.bin', size: 8192, duration: 10.0)).toJson(); + ch.sink.add(jsonEncode(msg)); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (messages.isEmpty && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + final token = + (jsonDecode(messages.firstWhere((m) => (jsonDecode(m) as Map)['t'] == 'welcome')) as Map)['token'] + as String; + server.hub.engine.members[token]!.lastSeen = + DateTime.now().toUtc().subtract(const Duration(seconds: 61)); + server.hub.dropSilentSockets(); + final closeDeadline = DateTime.now().add(const Duration(seconds: 5)); + while ((!wsClosed || + server.hub.engine.members.containsKey(token) || + server.session.tokens.containsKey(token)) && + DateTime.now().isBefore(closeDeadline)) { + await Future.delayed(const Duration(milliseconds: 50)); + } + expect(wsClosed, isTrue); + expect(server.hub.engine.members.containsKey(token), isFalse); + expect(server.session.tokens.containsKey(token), isFalse); + try { + await sub.cancel(); + } catch (_) {} + try { + await ch.sink.close(); + } catch (_) {} + }); + + test('any frame touches lastSeen (heartbeat baseline)', () async { + final good = await hello('shh'); + final token = (jsonDecode(good) as Map)['token'] as String; + final viewer = server.session.tokens[token]!; + viewer.touch(DateTime.utc(2020, 1, 1)); + expect(viewer.isStale(const Duration(seconds: 30)), isTrue); + + final ch = WebSocketChannel.connect( + Uri.parse('ws://127.0.0.1:${server.boundPort}/sync')); + final sub = ch.stream.listen((_) {}); + ch.sink.add(jsonEncode(HelloMessage( + proto: RelayConstants.protoVersion, + secret: 'shh', + user: const HelloUser(name: 'hb'), + media: const HelloMedia( + name: 'f.bin', size: 8192, duration: 10.0)) + .toJson())); + await Future.delayed(const Duration(seconds: 1)); + final hbToken = server.session.tokens.keys + .firstWhere((k) => server.session.tokens[k]!.name == 'hb'); + expect(server.session.tokens[hbToken]!.isStale(const Duration(seconds: 30)), isFalse); + expect(viewer.isStale(const Duration(seconds: 30)), isTrue); + try { + await sub.cancel(); + } catch (_) {} + try { + await ch.sink.close(); + } catch (_) {} + }); + }); +} diff --git a/packages/server/test/server_test.dart b/packages/server/test/server_test.dart new file mode 100644 index 0000000..e70fdf7 --- /dev/null +++ b/packages/server/test/server_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +void main() { + test('server has zero flutter imports', () async { + final libDir = Directory('lib'); + final binDir = Directory('bin'); + final offenders = []; + for (final dir in [libDir, binDir]) { + if (!await dir.exists()) continue; + await for (final entity in dir.list(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final content = await entity.readAsString(); + if (content.contains('package:flutter/') || content.contains('dart:ui')) { + offenders.add(entity.path); + } + } + } + expect(offenders, isEmpty, reason: 'Flutter imports in server: $offenders'); + }); + + test('server starts, answers /health, stops cleanly', () async { + final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test')); + await server.start(); + expect(server.isRunning, isTrue); + final port = server.boundPort; + expect(port, greaterThan(0)); + + final client = HttpClient(); + try { + final req = await client.get('127.0.0.1', port, '/health'); + final resp = await req.close(); + expect(resp.statusCode, 200); + final body = await resp.transform(const SystemEncoding().decoder).join(); + expect(body, contains('test')); + } finally { + client.close(); + } + + await server.stop(); + expect(server.isRunning, isFalse); + }); + + test('unknown path returns 404', () async { + final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test')); + await server.start(); + final client = HttpClient(); + try { + final req = await client.get('127.0.0.1', server.boundPort, '/nope'); + final resp = await req.close(); + expect(resp.statusCode, 404); + await resp.drain(); + } finally { + client.close(); + } + await server.stop(); + }); +} diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart new file mode 100644 index 0000000..c9ca0cb --- /dev/null +++ b/packages/server/test/session_test.dart @@ -0,0 +1,115 @@ +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +void main() { + group('Session model', () { + test('tokens are unique 128-bit hex', () { + final s = Session(id: 'default', secret: 'shh'); + final a = s.issueToken('alice', ''); + final b = s.issueToken('bob', ''); + expect(a, hasLength(32)); + expect(a, isNot(b)); + expect(RegExp(r'^[0-9a-f]{32}$').hasMatch(a), isTrue); + }); + + test('gate: unknown -> unauthorized, known -> ok', () { + final s = Session(id: 'default', secret: 'shh'); + expect(s.gate(null), MediaGate.unauthorized); + expect(s.gate('nope'), MediaGate.unauthorized); + final t = s.issueToken('alice', ''); + expect(s.gate(t), MediaGate.ok); + }); + + test('kick unknown throws, kick known forbids + drops', () async { + final s = Session(id: 'default', secret: 'shh'); + expect(() => s.kick('nope'), throwsA(isA())); + final t = s.issueToken('mallory', ''); + var dropped = false; + String? gotReason; + s.tokens[t]!.onKick = (reason) async { + dropped = true; + gotReason = reason; + }; + await s.kick(t, reason: 'idle'); + expect(s.gate(t), MediaGate.forbidden); + expect(dropped, isTrue); + expect(gotReason, 'idle'); + }); + + test('oversize avatar rejected', () { + final s = Session(id: 'default', secret: 'shh'); + final big = 'x' * (RelayConstants.maxAvatarBytes + 1); + expect(() => s.issueToken('alice', big), throwsA(isA())); + }); + + test('names truncated to cap, blank becomes ?', () { + final s = Session(id: 'default', secret: 'shh'); + final long = s.issueToken('a' * 100, ''); + expect(s.tokens[long]!.name, hasLength(RelayConstants.maxNameLength)); + final blank = s.issueToken(' ', ''); + expect(s.tokens[blank]!.name, '?'); + }); + + test('byte counters accumulate', () { final s = Session(id: 'default', secret: 'shh'); + final a = s.issueToken('alice', ''); + final b = s.issueToken('bob', ''); + s.addBytes(a, 100); + s.addBytes(b, 50); + s.addBytes('ghost', 999); + expect(s.tokens[a]!.bytesServed, 100); + expect(s.totalBytesServed, 150); + }); + + test('SessionMap create/get/drop', () { + final m = SessionMap(); + expect(m.get('default'), isNull); + final s = m.getOrCreate('default', 'shh'); + expect(m.get('default'), same(s)); + expect(m.getOrCreate('default', 'other'), same(s)); + m.drop('default'); + expect(m.get('default'), isNull); + }); + }); + + group('core welcome + proto', () { + test('proto bumped to 4 for readiness + room fan-out', () { + expect(RelayConstants.protoVersion, 4); + }); + + test('welcome round-trips with token + member id', () { + const w = WelcomeMessage(token: 'abc', memberId: 'm1', mediaUrl: 'http://h/m'); + final back = WelcomeMessage.fromJson(Map.from(w.toJson())); + expect(back.token, 'abc'); + expect(back.memberId, 'm1'); + expect(back.mediaUrl, 'http://h/m'); + expect(back.state, isNull); + }); + + test('welcome rejects wrong type', () { + expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA())); + }); + + test('kicked + kick request round-trip', () { + const k = KickedMessage(reason: 'idle'); + final back = KickedMessage.fromJson(Map.from(k.toJson())); + expect(back.reason, 'idle'); + const r = KickRequest(target: 'abc', reason: 'afk?'); + final backR = KickRequest.fromJson(Map.from(r.toJson())); + expect(backR.target, 'abc'); + expect(backR.reason, 'afk?'); + expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA())); + expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA())); + }); + + test('lastSeen touch + stale helper', () { + final s = Session(id: 'default', secret: 'shh'); + final t = s.issueToken('alice', ''); + final viewer = s.tokens[t]!; + expect(viewer.isStale(const Duration(seconds: 30)), isFalse); + viewer.touch(DateTime.utc(2020, 1, 1)); + expect(viewer.isStale(const Duration(seconds: 30)), isTrue); + expect(viewer.isStale(const Duration(seconds: 30), DateTime.utc(2020, 1, 1, 0, 0, 10)), isFalse); + }); + }); +} diff --git a/tool/e2e_check.dart b/tool/e2e_check.dart new file mode 100644 index 0000000..449f7a1 --- /dev/null +++ b/tool/e2e_check.dart @@ -0,0 +1,134 @@ +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; + +int _failures = 0; + +void check(String name, bool ok, [String detail = '']) { + // ignore: avoid_print + print('${ok ? 'PASS' : 'FAIL'} $name${detail.isEmpty ? '' : ' ($detail)'}'); + if (!ok) _failures++; +} + +class FakeViewer { + final RoomEngine engine; + final String token; + double ownPosition; + double buffered; + final double lagSecs; + int seenEventId = 0; + + FakeViewer(this.engine, this.token, + {this.ownPosition = 0, this.buffered = 6.0, this.lagSecs = 0}); + + void tick() { + seenEventId = engine.eventId; + final edge = engine.positionAt(); + if ((ownPosition - edge).abs() > 0.5) { + ownPosition = edge - lagSecs; + if (ownPosition < 0) ownPosition = 0; + } else { + ownPosition = edge - lagSecs; + if (ownPosition < 0) ownPosition = 0; + } + engine.applyReport(token, ownPosition, buffered, seenEventId); + } +} + +Future main(List args) async { + var simTime = DateTime.utc(2026, 1, 1); + final engine = RoomEngine(clock: () => simTime); + engine.media = const HelloMedia(name: 'Movie.mkv', size: 4000000000, duration: 7200.0); + + void step([int seconds = 1]) => simTime = simTime.add(Duration(seconds: seconds)); + + engine.applyJoin('a', 'alice', ''); + engine.applyJoin('b', 'bob', ''); + engine.applyJoin('c', 'cara', ''); + for (final t in ['a', 'b', 'c']) { + engine.applyReady(t, true); + engine.applyReport(t, 0, 6.0, engine.eventId); + } + check('room playing after all ready+buffered', engine.transport == Transport.playing); + + final alice = FakeViewer(engine, 'a'); + final bob = FakeViewer(engine, 'b'); + final cara = FakeViewer(engine, 'c', lagSecs: 8.0); + var viewers = [alice, bob, cara]; + + for (var s = 0; s < 3600; s++) { + step(); + for (final v in viewers) { + v.tick(); + } + } + final edge1h = engine.positionAt(); + check('1h in, edge advanced ~3600s', (edge1h - 3600).abs() < 2.0, 'edge=$edge1h'); + check('lagging cara never dragged clock', (engine.positionAt() - 3600).abs() < 2.0, + 'edge=${engine.positionAt()}'); + + final eidBeforeSeek = engine.eventId; + engine.applySeek('a', edge1h + 600); + check('seek jumps edge once', (engine.positionAt() - (edge1h + 600)).abs() < 0.01); + check('seek bumps eventId', engine.eventId == eidBeforeSeek + 1); + engine.applyReport('c', edge1h - 8.0, 6.0, eidBeforeSeek); + check('stale-event report causes no ping-pong', + (engine.positionAt() - (edge1h + 600)).abs() < 0.5, 'edge=${engine.positionAt()}'); + + for (var s = 0; s < 1800; s++) { + step(); + for (final v in viewers) { + v.tick(); + } + } + + engine.applyJoin('d', 'dan', ''); + check('mid-play join does not halt room', engine.transport == Transport.playing); + final dan = FakeViewer(engine, 'd'); + dan.ownPosition = engine.positionAt(); + dan.tick(); + engine.applyReady('d', true); + engine.applyReport('d', dan.ownPosition, 6.0, engine.eventId); + viewers.add(dan); + check('late joiner lands in set + in sync', + engine.members['d']!.inSet && (dan.ownPosition - engine.positionAt()).abs() < 1.0); + + for (var s = 0; s < 600; s++) { + step(); + for (final v in viewers) { + v.tick(); + } + } + engine.applyReady('b', false); + final frozen = engine.positionAt(); + check('unready pauses room', engine.transport == Transport.held); + for (var s = 0; s < 30; s++) { + step(); + engine.applyReport('a', frozen, 6.0, engine.eventId); + engine.applyReport('c', frozen - 8.0, 6.0, engine.eventId); + engine.applyReport('d', frozen, 6.0, engine.eventId); + } + check('held edge frozen for 30s', (engine.positionAt() - frozen).abs() < 0.01); + engine.applyReady('b', true); + for (final v in viewers) { + engine.applyReport(v.token, frozen, 6.0, engine.eventId); + } + check('unpause resumes', engine.transport == Transport.playing); + check('resume aligned within RTT tolerance', (engine.positionAt() - frozen).abs() < 1.0, + 'edge=${engine.positionAt()} frozen=$frozen'); + + for (var i = 0; i < 20; i++) { + engine.applyReady('c', false); + engine.applyReady('c', true); + engine.applyReport('c', engine.positionAt(), 6.0, engine.eventId); + } + check('post-flap room converges to playing', engine.transport == Transport.playing); + + engine.applySwap('a', const HelloMedia(name: 'Ep02.mkv', size: 100, duration: 1200.0)); + check('swap holds + resets edge', engine.transport == Transport.held && engine.positionAt() == 0); + check('swap clears sets', engine.members.values.every((m) => !m.inSet && !m.ready)); + + // ignore: avoid_print + print(_failures == 0 ? 'E2E ALL PASS' : 'E2E $_failures FAILURES'); + exitCode = _failures == 0 ? 0 : 1; +} diff --git a/tool/fake_viewer.dart b/tool/fake_viewer.dart new file mode 100644 index 0000000..cbfcb93 --- /dev/null +++ b/tool/fake_viewer.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:sync_relay_core/core.dart'; + +int _failures = 0; + +void check(String name, bool ok, [String detail = '']) { + // ignore: avoid_print + print('${ok ? 'PASS' : 'FAIL'} $name${detail.isEmpty ? '' : ' ($detail)'}'); + if (!ok) _failures++; +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +Future main(List args) async { + var server = 'http://127.0.0.1:8080'; + var secret = 'change-me'; + var name = 'viewer'; + String? expectFile; + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--server': + server = args[++i]; + case '--secret': + secret = args[++i]; + case '--name': + name = args[++i]; + case '--file': + expectFile = args[++i]; + } + } + final base = Uri.parse(server); + final wsUri = base.replace(scheme: base.scheme == 'https' ? 'wss' : 'ws', path: '/sync'); + + final ch = WebSocketChannel.connect(wsUri); + final firstCompleter = Completer(); + final sub = ch.stream.listen( + (m) { + if (!firstCompleter.isCompleted) firstCompleter.complete('$m'); + }, + onError: (Object e) { + if (!firstCompleter.isCompleted) firstCompleter.completeError(e); + }, + ); + ch.sink.add(jsonEncode({ + 't': 'hello', + 'proto': RelayConstants.protoVersion, + 'secret': secret, + 'user': {'name': name}, + 'media': {'name': '', 'size': 0, 'duration': 0.0}, + })); + late Map welcome; + try { + final raw = await firstCompleter.future.timeout(const Duration(seconds: 10)); + welcome = Map.from(jsonDecode(raw) as Map); + } catch (e) { + check('hello -> welcome', false, '$e'); + exitCode = 1; + return; + } + check('hello -> welcome', welcome['t'] == 'welcome', '${welcome['t']}'); + final token = '${welcome['token']}'; + check('token issued', token.length == 32, 'len=${token.length}'); + final mediaUrl = welcome['mediaUrl'] as String?; + check('mediaUrl present', mediaUrl != null && mediaUrl.contains(token)); + if (mediaUrl == null) { + exitCode = 1; + return; + } + final mediaUri = Uri.parse(mediaUrl); + + Uint8List? expected; + if (expectFile != null) expected = await File(expectFile).readAsBytes(); + final client = HttpClient(); + try { + Future<(HttpClientResponse, List)> get({String? range}) async { + final req = await client.getUrl(mediaUri); + if (range != null) req.headers.set('range', range); + final resp = await req.close(); + final body = []; + await for (final c in resp) { + body.addAll(c); + } + return (resp, body); + } + + var (resp, body) = await get(); + check('no-range -> 200', resp.statusCode == 200, '${resp.statusCode}'); + check('accept-ranges', resp.headers.value('accept-ranges') == 'bytes'); + if (expected != null) { + check('full body byte-exact', _bytesEqual(body, expected), + 'got=${body.length} want=${expected.length}'); + } + final size = expected?.length ?? int.parse(resp.headers.value('content-length') ?? '0'); + check('size known', size > 0, 'size=$size'); + + (resp, body) = await get(range: 'bytes=0-99'); + check('closed range -> 206', resp.statusCode == 206, '${resp.statusCode}'); + check('content-range', resp.headers.value('content-range') == 'bytes 0-99/$size'); + if (expected != null) check('closed bytes exact', _bytesEqual(body, expected.sublist(0, 100))); + + (resp, body) = await get(range: 'bytes=${size - 1000}-'); + check('open-ended -> 206', resp.statusCode == 206, '${resp.statusCode}'); + if (expected != null) { + check('open-ended bytes exact', _bytesEqual(body, expected.sublist(size - 1000))); + } + + (resp, body) = await get(range: 'bytes=-256'); + check('suffix tail -> 206', resp.statusCode == 206, '${resp.statusCode}'); + if (expected != null) { + check('suffix bytes exact', _bytesEqual(body, expected.sublist(size - 256))); + } + + (resp, body) = await get(range: 'bytes=${size + 100}-${size + 200}'); + check('out-of-range -> 416', resp.statusCode == 416, '${resp.statusCode}'); + + final badUri = mediaUri.replace( + pathSegments: [...mediaUri.pathSegments.take(2), '0' * 32, ...mediaUri.pathSegments.skip(3)]); + final badReq = await client.getUrl(badUri); + final badResp = await badReq.close(); + await badResp.drain(); + check('bad token -> 401', badResp.statusCode == 401, '${badResp.statusCode}'); + } finally { + client.close(); + try { + await sub.cancel(); + } catch (_) {} + try { + await ch.sink.close(); + } catch (_) {} + } + // ignore: avoid_print + print(_failures == 0 ? 'ALL PASS' : '$_failures FAILURES'); + exitCode = _failures == 0 ? 0 : 1; +} diff --git a/tool/pubspec.lock b/tool/pubspec.lock new file mode 100644 index 0000000..36ca436 --- /dev/null +++ b/tool/pubspec.lock @@ -0,0 +1,219 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + relic: + dependency: "direct main" + description: + name: relic + sha256: d917b188dd1b6838c630d1904a1e26b040e6647406f2aab01b10fdf0ca051c12 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_core: + dependency: transitive + description: + name: relic_core + sha256: c2f7638d56fdf144a035d17eb8e93fb30fe53bd105fd81c31393f8be4f43ecdc + url: "https://pub.dev" + source: hosted + version: "1.2.0" + relic_io: + dependency: transitive + description: + name: relic_io + sha256: d64a9f309943200ec7a45b2a127cbc5abf4dd6603159a179396d66b932a15f1f + url: "https://pub.dev" + source: hosted + version: "1.2.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_relay_core: + dependency: "direct main" + description: + path: "../packages/core" + relative: true + source: path + version: "0.1.0" + sync_relay_server: + dependency: "direct main" + description: + path: "../packages/server" + relative: true + source: path + version: "0.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.8.0 <4.0.0" diff --git a/tool/pubspec.yaml b/tool/pubspec.yaml new file mode 100644 index 0000000..c053833 --- /dev/null +++ b/tool/pubspec.yaml @@ -0,0 +1,15 @@ +name: sync_relay_tool +description: Spikes and harnesses. +publish_to: none +environment: + sdk: ^3.0.0 +dependencies: + sync_relay_core: + path: ../packages/core + sync_relay_server: + path: ../packages/server + relic: ^1.2.0 + args: ^2.4.0 + web_socket_channel: ^3.0.0 +dev_dependencies: + lints: ^5.0.0 diff --git a/tool/range_logger.dart b/tool/range_logger.dart new file mode 100644 index 0000000..46d724b --- /dev/null +++ b/tool/range_logger.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:relic/relic.dart'; +import 'package:sync_relay_server/server.dart'; + +Future main(List args) async { + final parser = ArgParser() + ..addOption('file', abbr: 'f', help: 'media file to serve') + ..addOption('port', abbr: 'p', defaultsTo: '8080') + ..addOption('route', defaultsTo: '/m/file'); + final parsed = parser.parse(args); + final fileArg = parsed['file'] as String?; + if (fileArg == null) { + stderr.writeln('usage: range_logger.dart --file [--port 8080]'); + exit(2); + } + final file = File(fileArg); + if (!await file.exists()) { + stderr.writeln('file not found: $fileArg'); + exit(2); + } + final size = await file.length(); + final app = RelicApp(); + mountMediaRoute(app, file, + route: parsed['route'] as String, + onLog: (log) { + // ignore: avoid_print + print(log); + }); + app.fallback = (final req) { + // ignore: avoid_print + print('${req.method.name} ${req.url} -> 404'); + return Response.notFound(body: Body.fromString('not found')); + }; + final server = await app.serve( + address: InternetAddress.anyIPv4, + port: int.tryParse('${parsed['port']}') ?? 8080, + ); + // ignore: avoid_print + print('SERVING file=$fileArg size=$size on :${server.port}${parsed['route']}'); + await ProcessSignal.sigint.watch().first; + await app.close(); +} diff --git a/tool/shaper.dart b/tool/shaper.dart new file mode 100644 index 0000000..a4f328a --- /dev/null +++ b/tool/shaper.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:io'; + +Future main(List args) async { + var listenPort = 8090; + var targetHost = '127.0.0.1'; + var targetPort = 8080; + var kbps = 1024; + var latencyMs = 0; + var killAfterSecs = 0; + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--listen': + listenPort = int.parse(args[++i]); + case '--target': + final parts = args[++i].split(':'); + targetHost = parts[0]; + targetPort = int.parse(parts[1]); + case '--kbps': + kbps = int.parse(args[++i]); + case '--latency-ms': + latencyMs = int.parse(args[++i]); + case '--kill-after': + killAfterSecs = int.parse(args[++i]); + } + } + final server = await ServerSocket.bind(InternetAddress.anyIPv4, listenPort); + // ignore: avoid_print + print('SHAPER :$listenPort -> $targetHost:$targetPort ${kbps}KB/s latency=${latencyMs}ms' + '${killAfterSecs > 0 ? ' kill-after=${killAfterSecs}s' : ''}'); + var dead = false; + if (killAfterSecs > 0) { + Timer(Duration(seconds: killAfterSecs), () { + dead = true; + // ignore: avoid_print + print('SHAPER kill switch fired, closing listener'); + server.close(); + }); + } + await for (final client in server) { + if (dead) { + client.close(); + continue; + } + Socket? upstream; + try { + upstream = await Socket.connect(targetHost, targetPort); + } catch (e) { + // ignore: avoid_print + print('SHAPER upstream refused: $e'); + client.close(); + continue; + } + unawaited(_pipe(client, upstream, kbps, latencyMs, 'c->s')); + unawaited(_pipe(upstream, client, kbps, latencyMs, 's->c')); + } +} + +Future _pipe(Socket from, Socket to, int kbps, int latencyMs, String tag) async { + final chunkBudget = kbps * 1024 ~/ 10; + try { + await for (final data in from) { + if (latencyMs > 0) await Future.delayed(Duration(milliseconds: latencyMs)); + var offset = 0; + while (offset < data.length) { + final end = (offset + chunkBudget).clamp(0, data.length); + to.add(data.sublist(offset, end)); + await to.flush(); + offset = end; + if (offset < data.length) await Future.delayed(const Duration(milliseconds: 100)); + } + } + await to.close(); + } catch (e) { + // ignore: avoid_print + print('SHAPER $tag closed: $e'); + try { + await to.close(); + } catch (_) {} + } +}