Desktop: video freeze recovery, session fixes, Home redesign
- Player + VideoController per session; Reload video (Ctrl+R) and a frozen-picture watchdog that reloads automatically. - Shortcuts no longer fire while typing; empty swap disabled; hwdec setting/env actually applied. - Idle unready measured from real inactivity; cancellable reconnects and setup; ready restored from server state; kicked/failed host stops its server; graceful leave on window close; no URLs with keys in display names. - Welcome + connect screens replaced by one Home screen with Join and Host cards, recent servers, Settings, Integrations and About. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beae87b0cc
commit
aeff087c7d
30 files changed
+3278
-601
No files matched your search
+4
-16
@@ -1,17 +1,5 @@
|
|||||||
# watch_together
|
# apps/desktop
|
||||||
|
|
||||||
A new Flutter project.
|
The Flutter desktop app (the only binary users run). Layout and rules:
|
||||||
|
[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md). Build and test:
|
||||||
## Getting Started
|
[../../docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import 'src/screens/connect_screen.dart';
|
import 'src/screens/home/home_screen.dart';
|
||||||
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/screens/session_screen.dart';
|
import 'src/screens/session_screen.dart';
|
||||||
import 'src/screens/welcome_screen.dart';
|
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
|
|
||||||
class WatchTogetherApp extends StatelessWidget {
|
class WatchTogetherApp extends StatelessWidget {
|
||||||
@@ -13,13 +13,12 @@ class WatchTogetherApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Watch Together',
|
title: appName,
|
||||||
theme: ThemeData.dark(),
|
theme: ThemeData.dark(),
|
||||||
home: SignalBuilder(
|
home: SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return switch (session.route.value) {
|
return switch (session.route.value) {
|
||||||
AppRoute.welcome => const WelcomeScreen(),
|
AppRoute.home => const HomeScreen(),
|
||||||
AppRoute.connect => const ConnectScreen(),
|
|
||||||
AppRoute.session => const SessionScreen(),
|
AppRoute.session => const SessionScreen(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'src/screens/home/ui.dart';
|
||||||
import 'src/state/session_state.dart';
|
import 'src/state/session_state.dart';
|
||||||
|
import 'src/state/settings.dart';
|
||||||
import 'src/update/updater.dart';
|
import 'src/update/updater.dart';
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
|
launchArgs = List.unmodifiable(args);
|
||||||
if (args.contains('--upgrade')) {
|
if (args.contains('--upgrade')) {
|
||||||
exitCode = await runUpgrade();
|
exitCode = await runUpgrade();
|
||||||
exit(exitCode);
|
exit(exitCode);
|
||||||
@@ -39,19 +42,22 @@ Future<void> main(List<String> args) async {
|
|||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
await windowManager.setPreventClose(true);
|
||||||
|
windowManager.addListener(_CloseGuard());
|
||||||
const windowOptions = WindowOptions(
|
const windowOptions = WindowOptions(
|
||||||
size: Size(1280, 720),
|
size: Size(1280, 720),
|
||||||
minimumSize: Size(160, 90),
|
minimumSize: Size(160, 90),
|
||||||
center: true,
|
center: true,
|
||||||
backgroundColor: Color(0xFF000000),
|
backgroundColor: Color(0xFF000000),
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
titleBarStyle: TitleBarStyle.hidden,
|
||||||
title: 'Watch Together',
|
title: appName,
|
||||||
);
|
);
|
||||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
await windowManager.show();
|
await windowManager.show();
|
||||||
await windowManager.focus();
|
await windowManager.focus();
|
||||||
});
|
});
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
await AppSettings.instance.ensureLoaded();
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await SessionState.instance.loadForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -67,3 +73,22 @@ Future<void> runHeadless(List<String> args) async {
|
|||||||
await ProcessSignal.sigint.watch().first;
|
await ProcessSignal.sigint.watch().first;
|
||||||
await session.stopServer();
|
await session.stopServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||||
|
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||||
|
/// window open.
|
||||||
|
class _CloseGuard with WindowListener {
|
||||||
|
bool _closing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onWindowClose() async {
|
||||||
|
if (_closing) return;
|
||||||
|
_closing = true;
|
||||||
|
try {
|
||||||
|
await SessionState.instance.leave().timeout(const Duration(seconds: 2));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('leave on close failed: $e');
|
||||||
|
}
|
||||||
|
await windowManager.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// FNV-1a over a 15x15 grid of RGB(A) pixels in the central half of an
|
||||||
|
/// image. Used on a small capture of what the compositor shows: the
|
||||||
|
/// centre is video, the edges carry the (changing) control overlays.
|
||||||
|
int hashCenterPixels(ByteData rgba, int width, int height) {
|
||||||
|
var x = 0x811c9dc5;
|
||||||
|
if (width <= 0 || height <= 0) return x;
|
||||||
|
for (var gy = 1; gy < 16; gy++) {
|
||||||
|
for (var gx = 1; gx < 16; gx++) {
|
||||||
|
final px = (width * (0.25 + 0.5 * gx / 16)).floor();
|
||||||
|
final py = (height * (0.25 + 0.5 * gy / 16)).floor();
|
||||||
|
final o = (py * width + px) * 4;
|
||||||
|
if (o + 3 > rgba.lengthInBytes) continue;
|
||||||
|
for (var c = 0; c < 3; c++) {
|
||||||
|
x = ((x ^ rgba.getUint8(o + c)) * 0x01000193) & 0xffffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a over ~4k bytes spread evenly across a raw frame (mpv
|
||||||
|
/// `screenshot-raw`); dimension-agnostic, enough to tell frames apart.
|
||||||
|
int hashSpread(Uint8List bytes) {
|
||||||
|
var x = 0x811c9dc5;
|
||||||
|
if (bytes.isEmpty) return x;
|
||||||
|
final step = bytes.length <= 4096 ? 1 : bytes.length ~/ 4096;
|
||||||
|
for (var i = 0; i < bytes.length; i += step) {
|
||||||
|
x = ((x ^ bytes[i]) * 0x01000193) & 0xffffffff;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample of player state, taken about once a second.
|
||||||
|
class FrameSample {
|
||||||
|
final DateTime at;
|
||||||
|
final double position;
|
||||||
|
|
||||||
|
/// Fingerprint of the picture currently shown on screen; null when it
|
||||||
|
/// cannot be captured (no view, hidden window, headless).
|
||||||
|
final int? frames;
|
||||||
|
|
||||||
|
/// Playing, not buffering, rate > 0, video track present, window
|
||||||
|
/// visible. Anything else disarms the watchdog.
|
||||||
|
final bool eligible;
|
||||||
|
const FrameSample({
|
||||||
|
required this.at,
|
||||||
|
required this.position,
|
||||||
|
required this.frames,
|
||||||
|
required this.eligible,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects a frozen picture: position advances normally but the shown
|
||||||
|
/// picture stands still for [stallAfter]. Seeks (position jumps), pauses,
|
||||||
|
/// buffering and missing captures all reset the stall clock. A hit is a
|
||||||
|
/// suspicion only — a still scene looks the same — so the caller confirms
|
||||||
|
/// it against the decoder and calls [dismiss] when it was a still scene.
|
||||||
|
/// Fires at most once until the picture is seen moving again for
|
||||||
|
/// [rearmAfter], so a player that stays (or soon gets) frozen after a
|
||||||
|
/// reload does not reload in a loop. [reset] keeps that latch.
|
||||||
|
class FrameWatchdog {
|
||||||
|
static const stallAfter = Duration(seconds: 3);
|
||||||
|
static const rearmAfter = Duration(seconds: 10);
|
||||||
|
|
||||||
|
FrameSample? _last;
|
||||||
|
DateTime? _stallSince;
|
||||||
|
DateTime? _healthySince;
|
||||||
|
bool _fired = false;
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_last = null;
|
||||||
|
_stallSince = null;
|
||||||
|
_healthySince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last hit was a still scene: re-arm, but wait a full
|
||||||
|
/// [stallAfter] before suspecting again.
|
||||||
|
void dismiss() {
|
||||||
|
_fired = false;
|
||||||
|
_stallSince = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true exactly when a freeze is suspected.
|
||||||
|
bool feed(FrameSample s) {
|
||||||
|
final last = _last;
|
||||||
|
_last = s;
|
||||||
|
if (!s.eligible || s.frames == null || last == null ||
|
||||||
|
!last.eligible || last.frames == null) {
|
||||||
|
_stallSince = null;
|
||||||
|
_healthySince = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (s.frames != last.frames) {
|
||||||
|
_stallSince = null;
|
||||||
|
_healthySince ??= last.at;
|
||||||
|
if (s.at.difference(_healthySince!) >= rearmAfter) _fired = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_healthySince = null;
|
||||||
|
final wall = s.at.difference(last.at).inMilliseconds / 1000.0;
|
||||||
|
final moved = s.position - last.position;
|
||||||
|
// Normal playback only: a jump (seek) or a standstill (stall, the
|
||||||
|
// position itself stuck) is not a frozen picture.
|
||||||
|
final normal = wall > 0 && moved > wall * 0.5 && moved < wall * 2 + 0.5;
|
||||||
|
if (!normal) {
|
||||||
|
_stallSince = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_stallSince ??= last.at;
|
||||||
|
if (_fired) return false;
|
||||||
|
if (s.at.difference(_stallSince!) >= stallAfter) {
|
||||||
|
_fired = true;
|
||||||
|
_stallSince = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,93 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../state/settings.dart';
|
||||||
import '../sync/room_diff.dart';
|
import '../sync/room_diff.dart';
|
||||||
|
import 'frame_watchdog.dart';
|
||||||
|
|
||||||
|
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||||
|
/// by [init] on join, released by [dispose] on leave, swapped by
|
||||||
|
/// [recreate] for "Reload video". Never reuse a player across sessions —
|
||||||
|
/// a GPU-side freeze sticks to the player, not to the widget.
|
||||||
class PlaybackController {
|
class PlaybackController {
|
||||||
late final Player player;
|
Player? _player;
|
||||||
bool _ready = false;
|
VideoController? _video;
|
||||||
|
Timer? _watchTimer;
|
||||||
|
bool _probing = false;
|
||||||
|
final FrameWatchdog _watchdog = FrameWatchdog();
|
||||||
|
|
||||||
|
/// Bumped for every new player; the video view keys itself on it so a
|
||||||
|
/// recreated player gets a fresh `Video` widget.
|
||||||
|
final generation = signal(0);
|
||||||
|
|
||||||
|
/// True while a live player exists. The video view shows plain black
|
||||||
|
/// otherwise, so no texture outlives its player.
|
||||||
|
final alive = signal(false);
|
||||||
|
|
||||||
|
/// Called once when the watchdog sees a frozen picture.
|
||||||
|
void Function()? onFrozen;
|
||||||
|
|
||||||
|
/// Fingerprint of the picture on screen, installed by the video view
|
||||||
|
/// (`ShownFrameProbe.sample`). Without it the watchdog stays idle.
|
||||||
|
Future<int?> Function()? shownFrame;
|
||||||
|
|
||||||
|
Player get player => _player!;
|
||||||
|
bool get _ready => _player != null;
|
||||||
|
|
||||||
|
/// The [VideoController] of the current player. Created lazily by the
|
||||||
|
/// video view (it needs a running Flutter binding; headless tests never
|
||||||
|
/// touch it) and released with the player.
|
||||||
|
VideoController get video {
|
||||||
|
final existing = _video;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final hwdec = _hwdecOverride;
|
||||||
|
// media_kit's video output sets hwdec itself on attach ('auto' unless
|
||||||
|
// configured), which used to override SYNC_RELAY_HWDEC.
|
||||||
|
return _video = VideoController(
|
||||||
|
player,
|
||||||
|
configuration: VideoControllerConfiguration(hwdec: hwdec),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SYNC_RELAY_HWDEC wins over the Settings choice; read per player, so a
|
||||||
|
// changed setting applies on the next join or Reload video.
|
||||||
|
static String? get _hwdecOverride {
|
||||||
|
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||||
|
if (hwdec != null && hwdec.isNotEmpty) return hwdec;
|
||||||
|
return AppSettings.instance.hwdec.peek();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
player = Player(
|
if (_player != null) return;
|
||||||
|
_player = Player(
|
||||||
configuration: PlayerConfiguration(
|
configuration: PlayerConfiguration(
|
||||||
bufferSize: 64 * 1024 * 1024,
|
bufferSize: 64 * 1024 * 1024,
|
||||||
libass: true,
|
libass: true,
|
||||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||||
));
|
));
|
||||||
_ready = true;
|
|
||||||
await _pinBufferProps();
|
await _pinBufferProps();
|
||||||
|
_watchdog.reset();
|
||||||
|
_watchTimer = Timer.periodic(const Duration(seconds: 1), (_) => _watch());
|
||||||
|
generation.value++;
|
||||||
|
alive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose the current player and start a fresh one (no media loaded).
|
||||||
|
/// Keeps volume. The caller reopens media and resyncs.
|
||||||
|
Future<void> recreate() async {
|
||||||
|
final volume = _player?.state.volume;
|
||||||
|
await dispose();
|
||||||
|
await init();
|
||||||
|
if (volume != null) {
|
||||||
|
try {
|
||||||
|
await player.setVolume(volume);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pin(String property, String value) async {
|
Future<void> _pin(String property, String value) async {
|
||||||
@@ -32,14 +102,81 @@ class PlaybackController {
|
|||||||
Future<void> _pinBufferProps() async {
|
Future<void> _pinBufferProps() async {
|
||||||
await _pin('demuxer-readahead-secs', '10');
|
await _pin('demuxer-readahead-secs', '10');
|
||||||
await _pin('cache-secs', '10');
|
await _pin('cache-secs', '10');
|
||||||
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
|
final hwdec = _hwdecOverride;
|
||||||
if (hwdec != null && hwdec.isNotEmpty) {
|
if (hwdec != null) {
|
||||||
await _pin('hwdec', hwdec);
|
await _pin('hwdec', hwdec);
|
||||||
}
|
}
|
||||||
await _pin('stream-lavf-o',
|
await _pin('stream-lavf-o',
|
||||||
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
'reconnect_streamed=1,reconnect_on_http_error=1,reconnect_delay_max=4');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the decoder's current frame (mpv `screenshot-raw`,
|
||||||
|
/// off-thread); null without video.
|
||||||
|
Future<int?> _sourceFrame(Player p) async {
|
||||||
|
try {
|
||||||
|
final raw = await p.screenshot(format: null);
|
||||||
|
return raw == null || raw.isEmpty ? null : hashSpread(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A suspected freeze is real only if mpv's own frames still move: a
|
||||||
|
/// still scene looks frozen on screen too.
|
||||||
|
Future<bool> _sourceMoving(Player p) async {
|
||||||
|
final a = await _sourceFrame(p);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
if (!identical(p, _player) || !p.state.playing) return false;
|
||||||
|
final b = await _sourceFrame(p);
|
||||||
|
return a != null && b != null && a != b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get _windowVisible {
|
||||||
|
try {
|
||||||
|
final s = WidgetsBinding.instance.lifecycleState;
|
||||||
|
return s == null ||
|
||||||
|
s == AppLifecycleState.resumed ||
|
||||||
|
s == AppLifecycleState.inactive;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _watch() async {
|
||||||
|
final p = _player;
|
||||||
|
final probe = shownFrame;
|
||||||
|
// Only meaningful once the video view is up.
|
||||||
|
if (p == null || _video == null || probe == null || _probing) return;
|
||||||
|
_probing = true;
|
||||||
|
try {
|
||||||
|
final frames = await probe();
|
||||||
|
if (!identical(p, _player)) return;
|
||||||
|
final s = p.state;
|
||||||
|
final eligible = s.playing &&
|
||||||
|
!s.buffering &&
|
||||||
|
!s.completed &&
|
||||||
|
s.rate > 0 &&
|
||||||
|
(s.width ?? 0) > 0 &&
|
||||||
|
_windowVisible;
|
||||||
|
final fired = _watchdog.feed(FrameSample(
|
||||||
|
at: DateTime.now(),
|
||||||
|
position: positionSecs,
|
||||||
|
frames: frames,
|
||||||
|
eligible: eligible,
|
||||||
|
));
|
||||||
|
if (fired) {
|
||||||
|
if (await _sourceMoving(p) && identical(p, _player)) {
|
||||||
|
debugPrint('video watchdog: picture stuck while mpv frames move');
|
||||||
|
onFrozen?.call();
|
||||||
|
} else {
|
||||||
|
_watchdog.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_probing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> openUrl(String url) async {
|
Future<void> openUrl(String url) async {
|
||||||
if (!_ready) return;
|
if (!_ready) return;
|
||||||
try {
|
try {
|
||||||
@@ -50,20 +187,28 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double get positionSecs =>
|
double get positionSecs {
|
||||||
player.state.position.inMilliseconds / 1000.0;
|
final p = _player;
|
||||||
|
if (p == null) return 0;
|
||||||
|
return p.state.position.inMilliseconds / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
double get bufferedSecsAhead {
|
double get bufferedSecsAhead {
|
||||||
final ahead =
|
final p = _player;
|
||||||
(player.state.buffer - player.state.position).inMilliseconds / 1000.0;
|
if (p == null) return 0;
|
||||||
|
final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0;
|
||||||
if (ahead.isNaN || ahead < 0) return 0;
|
if (ahead.isNaN || ahead < 0) return 0;
|
||||||
return ahead;
|
return ahead;
|
||||||
}
|
}
|
||||||
|
|
||||||
double get durationSecs =>
|
double get durationSecs {
|
||||||
player.state.duration.inMilliseconds / 1000.0;
|
final p = _player;
|
||||||
|
if (p == null) return 0;
|
||||||
|
return p.state.duration.inMilliseconds / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> apply(List<PlayerAction> actions) async {
|
Future<void> apply(List<PlayerAction> actions) async {
|
||||||
|
if (!_ready) return;
|
||||||
for (final action in actions) {
|
for (final action in actions) {
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -85,10 +230,33 @@ class PlaybackController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release the player. The view drops its texture first ([alive] goes
|
||||||
|
/// false, one frame passes), then mpv is torn down.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
_ready = false;
|
final p = _player;
|
||||||
|
if (p == null) return;
|
||||||
|
_watchTimer?.cancel();
|
||||||
|
_watchTimer = null;
|
||||||
|
_player = null;
|
||||||
|
_video = null;
|
||||||
|
alive.value = false;
|
||||||
|
await _settleUi();
|
||||||
try {
|
try {
|
||||||
await player.dispose();
|
await p.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two frames: one may already be mid-build when [alive] flips. Then
|
||||||
|
// the length of a route pop (the fullscreen route holds its own `Video`
|
||||||
|
// and leaves animated). Bounded, since a hidden window renders no
|
||||||
|
// frames; headless tests have no binding and skip it all.
|
||||||
|
static Future<void> _settleUi() async {
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
await WidgetsBinding.instance.endOfFrame
|
||||||
|
.timeout(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 350));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,359 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:signals/signals_flutter.dart';
|
|
||||||
import 'package:window_manager/window_manager.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';
|
|
||||||
import '../widgets/window_buttons.dart';
|
|
||||||
|
|
||||||
class ConnectScreen extends StatefulWidget {
|
|
||||||
const ConnectScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ConnectScreen> createState() => _ConnectScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectScreenState extends State<ConnectScreen> {
|
|
||||||
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<ConnectParams> _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<void> _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<void> _cancel() async {
|
|
||||||
_attempt++;
|
|
||||||
try {
|
|
||||||
await SessionState.instance.coordinator.cancelConnect();
|
|
||||||
await SessionState.instance.stopServer();
|
|
||||||
} catch (_) {}
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_busy = false;
|
|
||||||
_notice = 'connection cancelled';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showKickedDialog(String reason) async {
|
|
||||||
if (!mounted || _dialogOpen) return;
|
|
||||||
_dialogOpen = true;
|
|
||||||
try {
|
|
||||||
await showDialog<void>(
|
|
||||||
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<void> _showConnectionDialog(String message) async {
|
|
||||||
if (!mounted || _dialogOpen) return;
|
|
||||||
_dialogOpen = true;
|
|
||||||
try {
|
|
||||||
await showDialog<void>(
|
|
||||||
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<void> _browse(TextEditingController target, {required String dialogTitle, List<String>? 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: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
|
||||||
child: DragToMoveArea(
|
|
||||||
child: AppBar(
|
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => session.route.value = AppRoute.welcome),
|
|
||||||
title: SignalBuilder(builder: (context) => Text(session.connectMode.value == ConnectMode.host ? 'Host room' : 'Join room')),
|
|
||||||
actions: const [WindowButtons()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
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<ConnectMode>(
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../update/updater.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// Opens [url] in the default browser (no url_launcher dependency).
|
||||||
|
Future<bool> openInBrowser(String url) async {
|
||||||
|
try {
|
||||||
|
final ProcessResult r;
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
r = await Process.run('rundll32', ['url.dll,FileProtocolHandler', url]);
|
||||||
|
} else if (Platform.isMacOS) {
|
||||||
|
r = await Process.run('open', [url]);
|
||||||
|
} else {
|
||||||
|
r = await Process.run('xdg-open', [url]);
|
||||||
|
}
|
||||||
|
return r.exitCode == 0;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AboutPage extends StatelessWidget {
|
||||||
|
final UpdateController? updates;
|
||||||
|
const AboutPage({super.key, this.updates});
|
||||||
|
|
||||||
|
Future<void> _open(BuildContext context) async {
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
if (await openInBrowser(repoUrl)) return;
|
||||||
|
await Clipboard.setData(const ClipboardData(text: repoUrl));
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not open a browser — link copied')));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return PageScroll(
|
||||||
|
maxWidth: 680,
|
||||||
|
children: [
|
||||||
|
const PageTitle('About'),
|
||||||
|
Panel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(appName, style: t.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText('Version $appVersion',
|
||||||
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
UpdateStatusLine(controller: updates),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: SelectableText(repoUrl, style: t.bodySmall)),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy link',
|
||||||
|
icon: const Icon(Icons.copy, size: 18),
|
||||||
|
onPressed: () =>
|
||||||
|
Clipboard.setData(const ClipboardData(text: repoUrl)),
|
||||||
|
),
|
||||||
|
FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _open(context),
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
label: const Text('Source'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../session/session_coordinator.dart';
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import '../../widgets/avatar_pipeline.dart';
|
||||||
|
import '../../widgets/media_input.dart';
|
||||||
|
|
||||||
|
/// What a card shows under its fields after a failed attempt.
|
||||||
|
class CardError {
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
const CardError(this.title, this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host/Join submit state for the two Home cards. One attempt at a time;
|
||||||
|
/// [cancel] abandons it via [SessionState.cancelPending].
|
||||||
|
class ConnectFlow extends ChangeNotifier {
|
||||||
|
ConnectFlow({SessionState? session}) : _session = session ?? SessionState.instance;
|
||||||
|
final SessionState _session;
|
||||||
|
|
||||||
|
ConnectMode? busy;
|
||||||
|
final errors = <ConnectMode, CardError?>{};
|
||||||
|
final notices = <ConnectMode, String?>{};
|
||||||
|
int _attempt = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Set when an attempt ends in a kick, so Home shows the removal dialog.
|
||||||
|
void Function(String reason)? onKicked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearError(ConnectMode mode) {
|
||||||
|
if (errors[mode] == null && notices[mode] == null) return;
|
||||||
|
errors[mode] = null;
|
||||||
|
notices[mode] = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and builds the params; throws [FormatException] with a
|
||||||
|
/// user-facing message.
|
||||||
|
static Future<ConnectParams> buildParams(ConnectMode mode, ConnectForm f) async {
|
||||||
|
final rawPort = mode == ConnectMode.host ? f.hostPort : f.port;
|
||||||
|
final port = int.tryParse(rawPort.trim());
|
||||||
|
if (port == null || port <= 0 || port > 65535) {
|
||||||
|
throw const FormatException('port must be 1–65535');
|
||||||
|
}
|
||||||
|
if (f.name.trim().isEmpty) {
|
||||||
|
throw const FormatException('set your display name first (click your profile at the top)');
|
||||||
|
}
|
||||||
|
if (mode == ConnectMode.join && f.host.trim().isEmpty) {
|
||||||
|
throw const FormatException('enter the server address');
|
||||||
|
}
|
||||||
|
String avatar = '';
|
||||||
|
try {
|
||||||
|
avatar = await encodeAvatar(f.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');
|
||||||
|
}
|
||||||
|
if (mode == ConnectMode.host &&
|
||||||
|
f.mediaPath.isNotEmpty &&
|
||||||
|
!await File(f.mediaPath).exists()) {
|
||||||
|
throw const FormatException('media file not found — check the path or pick again with Browse');
|
||||||
|
}
|
||||||
|
return ConnectParams(
|
||||||
|
host: f.host.trim(),
|
||||||
|
port: port,
|
||||||
|
secret: mode == ConnectMode.host ? f.hostSecret : f.secret,
|
||||||
|
name: f.name.trim(),
|
||||||
|
avatar: avatar,
|
||||||
|
mediaPath: mode == ConnectMode.host ? f.mediaPath : '',
|
||||||
|
linkUrl: mode == ConnectMode.host ? f.linkUrl : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [edit] fills the card's fields into a copy of the last form, so the
|
||||||
|
/// other card's saved values survive.
|
||||||
|
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
||||||
|
if (busy != null) return;
|
||||||
|
final myAttempt = ++_attempt;
|
||||||
|
final form = _session.lastForm.copy();
|
||||||
|
edit(form);
|
||||||
|
form.name = form.name.trim();
|
||||||
|
form.host = form.host.trim();
|
||||||
|
if (mode == ConnectMode.host) {
|
||||||
|
final parsed = parseMediaInput(form.linkUrl.isNotEmpty ? form.linkUrl : form.mediaPath);
|
||||||
|
form
|
||||||
|
..mediaPath = parsed.mediaPath
|
||||||
|
..linkUrl = parsed.linkUrl;
|
||||||
|
}
|
||||||
|
busy = mode;
|
||||||
|
errors[mode] = null;
|
||||||
|
notices[mode] = null;
|
||||||
|
_notify();
|
||||||
|
|
||||||
|
final ConnectParams params;
|
||||||
|
try {
|
||||||
|
params = await buildParams(mode, form);
|
||||||
|
} catch (e) {
|
||||||
|
if (myAttempt != _attempt) return;
|
||||||
|
busy = null;
|
||||||
|
errors[mode] = CardError('Check the form',
|
||||||
|
e is FormatException ? e.message : 'check the form and try again');
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (mode == ConnectMode.host) {
|
||||||
|
await _session.host(params, form);
|
||||||
|
} else {
|
||||||
|
await _session.join(params, form);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (myAttempt != _attempt) return;
|
||||||
|
busy = null;
|
||||||
|
final kick = _session.coordinator.kickReason.peek();
|
||||||
|
if (kick != null && kick.isNotEmpty) {
|
||||||
|
onKicked?.call(kick);
|
||||||
|
} else {
|
||||||
|
errors[mode] = CardError('Could not connect', '$e');
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (myAttempt != _attempt) {
|
||||||
|
// Cancelled after it got through: back out.
|
||||||
|
try {
|
||||||
|
await _session.leave();
|
||||||
|
await _session.stopServer();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = null;
|
||||||
|
_notify();
|
||||||
|
await _session.rememberServer(RecentServer(
|
||||||
|
hosted: mode == ConnectMode.host,
|
||||||
|
host: mode == ConnectMode.host ? '' : form.host,
|
||||||
|
port: mode == ConnectMode.host ? form.hostPort.trim() : form.port.trim(),
|
||||||
|
secret: mode == ConnectMode.host ? form.hostSecret : form.secret,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancel() async {
|
||||||
|
final mode = busy;
|
||||||
|
_attempt++;
|
||||||
|
try {
|
||||||
|
await _session.cancelPending();
|
||||||
|
} catch (_) {}
|
||||||
|
busy = null;
|
||||||
|
if (mode != null) notices[mode] = 'Cancelled';
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import 'connect_flow.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// Below this width the Join/Host cards stack.
|
||||||
|
const _stackBelow = 700.0;
|
||||||
|
|
||||||
|
const _videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||||
|
|
||||||
|
class HomePage extends StatefulWidget {
|
||||||
|
final UpdateController? updates;
|
||||||
|
const HomePage({super.key, this.updates});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomePage> createState() => _HomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomePageState extends State<HomePage> {
|
||||||
|
final _flow = ConnectFlow();
|
||||||
|
late final _f = SessionState.instance.lastForm;
|
||||||
|
late final _joinHost = TextEditingController(text: _f.host);
|
||||||
|
late final _joinPort = TextEditingController(text: _f.port);
|
||||||
|
late final _joinSecret = TextEditingController(text: _f.secret);
|
||||||
|
late final _hostPort = TextEditingController(text: _f.hostPort);
|
||||||
|
late final _hostSecret = TextEditingController(text: _f.hostSecret);
|
||||||
|
late final _media =
|
||||||
|
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
||||||
|
late final void Function() _stopKickWatch;
|
||||||
|
bool _dialogOpen = false;
|
||||||
|
String? _lastKickShown;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_flow.onKicked = _showKickedDialog;
|
||||||
|
// Back from a session that ended in a kick: say so.
|
||||||
|
_stopKickWatch = effect(() {
|
||||||
|
final kick = SessionState.instance.coordinator.kickReason.value;
|
||||||
|
if (kick == null || kick.isEmpty) {
|
||||||
|
_lastKickShown = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stopKickWatch();
|
||||||
|
_flow.dispose();
|
||||||
|
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _join() => _flow.submit(ConnectMode.join, (f) {
|
||||||
|
f
|
||||||
|
..host = _joinHost.text
|
||||||
|
..port = _joinPort.text.trim()
|
||||||
|
..secret = _joinSecret.text;
|
||||||
|
});
|
||||||
|
|
||||||
|
void _host() => _flow.submit(ConnectMode.host, (f) {
|
||||||
|
f
|
||||||
|
..hostPort = _hostPort.text.trim()
|
||||||
|
..hostSecret = _hostSecret.text
|
||||||
|
..mediaPath = _media.text.trim()
|
||||||
|
..linkUrl = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
void _useRecent(RecentServer r) {
|
||||||
|
if (r.hosted) {
|
||||||
|
_hostPort.text = r.port;
|
||||||
|
_hostSecret.text = r.secret;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_joinHost.text = r.host;
|
||||||
|
_joinPort.text = r.port;
|
||||||
|
_joinSecret.text = r.secret;
|
||||||
|
_join();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _browseMedia() async {
|
||||||
|
try {
|
||||||
|
final files = await FilePicker.pickFiles(
|
||||||
|
dialogTitle: 'Pick media file',
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: _videoExtensions,
|
||||||
|
);
|
||||||
|
final path = files.isEmpty ? null : files.first.path;
|
||||||
|
if (path != null && mounted) setState(() => _media.text = path);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_flow.notices[ConnectMode.host] =
|
||||||
|
'File dialog unavailable (${e.runtimeType}) — paste the path instead';
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showKickedDialog(String reason) async {
|
||||||
|
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
|
||||||
|
_lastKickShown = reason;
|
||||||
|
_dialogOpen = true;
|
||||||
|
try {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('You were removed'),
|
||||||
|
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
|
||||||
|
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_dialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: _flow,
|
||||||
|
builder: (context, _) => PageScroll(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const ProfileChip(),
|
||||||
|
const Spacer(),
|
||||||
|
UpdateIndicator(controller: widget.updates),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
LayoutBuilder(builder: (context, box) {
|
||||||
|
final stacked = box.maxWidth < _stackBelow;
|
||||||
|
final join = _joinCard(context, fill: !stacked);
|
||||||
|
final host = _hostCard(context, fill: !stacked);
|
||||||
|
if (stacked) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [join, const SizedBox(height: kGap), host],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: join),
|
||||||
|
const SizedBox(width: kGap),
|
||||||
|
Expanded(child: host),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
_recents(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _joinCard(BuildContext context, {required bool fill}) {
|
||||||
|
return _ConnectCard(
|
||||||
|
key: const ValueKey('join-card'),
|
||||||
|
icon: Icons.login,
|
||||||
|
title: 'Join',
|
||||||
|
subtitle: 'Connect to a friend’s server.',
|
||||||
|
mode: ConnectMode.join,
|
||||||
|
flow: _flow,
|
||||||
|
action: 'Join',
|
||||||
|
onSubmit: _join,
|
||||||
|
fill: fill,
|
||||||
|
fields: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: TextField(
|
||||||
|
controller: _joinHost,
|
||||||
|
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
|
||||||
|
onChanged: (_) => _flow.clearError(ConnectMode.join),
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _joinPort,
|
||||||
|
decoration: fieldDecoration('Port'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _joinSecret,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: fieldDecoration('Password (optional)'),
|
||||||
|
onSubmitted: (_) => _join(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _hostCard(BuildContext context, {required bool fill}) {
|
||||||
|
return _ConnectCard(
|
||||||
|
key: const ValueKey('host-card'),
|
||||||
|
icon: Icons.cast,
|
||||||
|
title: 'Host',
|
||||||
|
subtitle: 'Run the server here. Your friends need this port open.',
|
||||||
|
mode: ConnectMode.host,
|
||||||
|
flow: _flow,
|
||||||
|
action: 'Host',
|
||||||
|
onSubmit: _host,
|
||||||
|
fill: fill,
|
||||||
|
fields: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 110,
|
||||||
|
child: TextField(
|
||||||
|
controller: _hostPort,
|
||||||
|
decoration: fieldDecoration('Port'),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onSubmitted: (_) => _host(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _hostSecret,
|
||||||
|
builder: (context, _) => TextField(
|
||||||
|
controller: _hostSecret,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: fieldDecoration('Password (optional)').copyWith(
|
||||||
|
helperText: _hostSecret.text.isEmpty
|
||||||
|
? 'No password — anyone can join'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _host(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _media,
|
||||||
|
decoration: fieldDecoration(
|
||||||
|
'Media',
|
||||||
|
hint: 'File path or http(s) link — optional',
|
||||||
|
suffix: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: TextButton(onPressed: _browseMedia, child: const Text('Browse')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _host(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _recents(BuildContext context) {
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final list = SessionState.instance.recents.value;
|
||||||
|
if (list.isEmpty) return const SizedBox.shrink();
|
||||||
|
final busy = _flow.busy != null;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Recent',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelLarge
|
||||||
|
?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final r in list)
|
||||||
|
InputChip(
|
||||||
|
key: ValueKey('recent-${r.key}'),
|
||||||
|
avatar: Icon(r.hosted ? Icons.cast : Icons.dns_outlined, size: 18),
|
||||||
|
label: Text(r.hosted ? 'Hosted on :${r.port}' : '${r.host}:${r.port}'),
|
||||||
|
tooltip: r.hosted ? 'Fill the Host card' : 'Join ${r.host}:${r.port}',
|
||||||
|
isEnabled: !busy,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
onPressed: () => _useRecent(r),
|
||||||
|
deleteButtonTooltipMessage: 'Forget',
|
||||||
|
onDeleted: () => SessionState.instance.forgetServer(r),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConnectCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final ConnectMode mode;
|
||||||
|
final ConnectFlow flow;
|
||||||
|
final String action;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final List<Widget> fields;
|
||||||
|
// Side by side: push the button to the bottom so both cards line up.
|
||||||
|
final bool fill;
|
||||||
|
const _ConnectCard({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.mode,
|
||||||
|
required this.flow,
|
||||||
|
required this.action,
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.fields,
|
||||||
|
required this.fill,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final scheme = theme.colorScheme;
|
||||||
|
final busyHere = flow.busy == mode;
|
||||||
|
final busyElsewhere = flow.busy != null && !busyHere;
|
||||||
|
final error = flow.errors[mode];
|
||||||
|
final notice = flow.notices[mode];
|
||||||
|
return Panel(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 22, color: scheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: theme.textTheme.titleLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(subtitle, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
AbsorbPointer(
|
||||||
|
absorbing: busyHere,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
spacing: 12,
|
||||||
|
children: fields,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ErrorBox(error: error),
|
||||||
|
] else if (notice != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(notice, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
if (fill) const Spacer(),
|
||||||
|
const SizedBox(height: kGap),
|
||||||
|
if (busyHere) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
|
||||||
|
style: theme.textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: busyElsewhere ? null : onSubmit,
|
||||||
|
child: Text(action),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBox extends StatelessWidget {
|
||||||
|
final CardError error;
|
||||||
|
const _ErrorBox({required this.error});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.errorContainer.withValues(alpha: 0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 18, color: scheme.error),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${error.title}\n',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
TextSpan(text: error.message),
|
||||||
|
]),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../widgets/window_chrome_bar.dart';
|
||||||
|
import 'about_page.dart';
|
||||||
|
import 'home_page.dart';
|
||||||
|
import 'integrations_page.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
import 'settings_page.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// Everything outside a session: Home (profile, Join/Host, recents),
|
||||||
|
/// Settings, Integrations, About behind a slim rail.
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
WindowChromeBar(title: appName),
|
||||||
|
Expanded(child: HomeShell()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [HomeScreen] minus the window chrome (which needs window_manager), so
|
||||||
|
/// tests can pump it.
|
||||||
|
class HomeShell extends StatefulWidget {
|
||||||
|
final UpdateController? updates;
|
||||||
|
final bool autoUpdateCheck;
|
||||||
|
const HomeShell({super.key, this.updates, this.autoUpdateCheck = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeShell> createState() => _HomeShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeShellState extends State<HomeShell> {
|
||||||
|
int _tab = 0;
|
||||||
|
|
||||||
|
static const _destinations = [
|
||||||
|
(Icons.home_outlined, Icons.home, 'Home'),
|
||||||
|
(Icons.settings_outlined, Icons.settings, 'Settings'),
|
||||||
|
(Icons.extension_outlined, Icons.extension, 'Integrations'),
|
||||||
|
(Icons.info_outline, Icons.info, 'About'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Profile.instance.ensureLoaded();
|
||||||
|
if (widget.autoUpdateCheck) _autoCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _autoCheck() async {
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
await s.ensureLoaded();
|
||||||
|
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
NavigationRail(
|
||||||
|
selectedIndex: _tab,
|
||||||
|
onDestinationSelected: (i) => setState(() => _tab = i),
|
||||||
|
labelType: NavigationRailLabelType.none,
|
||||||
|
minWidth: 64,
|
||||||
|
backgroundColor: scheme.surfaceContainerLowest,
|
||||||
|
indicatorShape:
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||||
|
destinations: [
|
||||||
|
for (final (icon, selected, label) in _destinations)
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Tooltip(message: label, child: Icon(icon)),
|
||||||
|
selectedIcon: Tooltip(message: label, child: Icon(selected)),
|
||||||
|
label: Text(label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: IndexedStack(
|
||||||
|
index: _tab,
|
||||||
|
children: [
|
||||||
|
HomePage(updates: widget.updates),
|
||||||
|
SettingsPage(updates: widget.updates),
|
||||||
|
const IntegrationsPage(),
|
||||||
|
AboutPage(updates: widget.updates),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
class IntegrationsPage extends StatelessWidget {
|
||||||
|
const IntegrationsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return PageScroll(
|
||||||
|
maxWidth: 680,
|
||||||
|
children: [
|
||||||
|
const PageTitle('Integrations',
|
||||||
|
subtitle: 'Connect media libraries to pick what to watch.'),
|
||||||
|
Panel(
|
||||||
|
enabled: false,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.video_library_outlined, size: 32),
|
||||||
|
const SizedBox(width: kGap),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Jellyfin', style: t.titleMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Coming soon: link your Jellyfin account(s), browse and '
|
||||||
|
'put up episodes, watched/continue-watching sync.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: kGap),
|
||||||
|
const OutlinedButton(onPressed: null, child: Text('Link account')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/session_state.dart';
|
||||||
|
import '../../widgets/avatar_pipeline.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
/// Display name + avatar, shared by the Home profile row and Settings.
|
||||||
|
/// Backed by [SessionState.lastForm]; every change is persisted. The
|
||||||
|
/// avatar goes through [encodeAvatar] (the same shrink the server gets)
|
||||||
|
/// before its path is stored, and the encoded bytes are the preview.
|
||||||
|
class Profile {
|
||||||
|
static final Profile instance = Profile._();
|
||||||
|
Profile._();
|
||||||
|
|
||||||
|
final name = signal('');
|
||||||
|
final preview = signal<Uint8List?>(null);
|
||||||
|
final error = signal<String?>(null);
|
||||||
|
final picking = signal(false);
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
void ensureLoaded() {
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
final f = SessionState.instance.lastForm;
|
||||||
|
name.value = f.name;
|
||||||
|
_refreshPreview(f.avatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshPreview(String path) async {
|
||||||
|
try {
|
||||||
|
final b64 = await encodeAvatar(path);
|
||||||
|
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||||
|
} catch (_) {
|
||||||
|
preview.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setName(String value) {
|
||||||
|
name.value = value.trim();
|
||||||
|
SessionState.instance.lastForm.name = value.trim();
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickAvatar() async {
|
||||||
|
if (picking.peek()) return;
|
||||||
|
picking.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
final List<PlatformFile> files;
|
||||||
|
try {
|
||||||
|
files = await FilePicker.pickFiles(
|
||||||
|
dialogTitle: 'Pick avatar image',
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
error.value = 'file dialog unavailable — try again';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final path = files.isEmpty ? null : files.first.path;
|
||||||
|
if (path == null) return;
|
||||||
|
final String b64;
|
||||||
|
try {
|
||||||
|
b64 = await encodeAvatar(path);
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
error.value = 'could not read that image — pick a PNG, JPG or WebP photo';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SessionState.instance.lastForm.avatarPath = path;
|
||||||
|
preview.value = b64.isEmpty ? null : base64Decode(b64);
|
||||||
|
await _save();
|
||||||
|
} finally {
|
||||||
|
picking.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAvatar() async {
|
||||||
|
SessionState.instance.lastForm.avatarPath = '';
|
||||||
|
preview.value = null;
|
||||||
|
error.value = null;
|
||||||
|
await _save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
try {
|
||||||
|
await SessionState.instance.saveForm();
|
||||||
|
} catch (_) {
|
||||||
|
error.value = 'could not save profile — try again';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar circle with the initial as fallback.
|
||||||
|
class ProfileAvatar extends StatelessWidget {
|
||||||
|
final double radius;
|
||||||
|
const ProfileAvatar({super.key, this.radius = 20});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = Profile.instance;
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final img = p.preview.value;
|
||||||
|
final n = p.name.value;
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
backgroundImage: img != null ? MemoryImage(img) : null,
|
||||||
|
child: img != null
|
||||||
|
? null
|
||||||
|
: p.picking.value
|
||||||
|
? SizedBox(
|
||||||
|
width: radius * 0.8,
|
||||||
|
height: radius * 0.8,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: Text(n.isEmpty ? '?' : n[0].toUpperCase(),
|
||||||
|
style: TextStyle(fontSize: radius * 0.8)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avatar (tap to change) + name field. Used inline in Settings and in
|
||||||
|
/// the Home profile dialog.
|
||||||
|
class ProfileEditor extends StatefulWidget {
|
||||||
|
final bool autofocus;
|
||||||
|
final VoidCallback? onSubmitted;
|
||||||
|
const ProfileEditor({super.key, this.autofocus = false, this.onSubmitted});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProfileEditor> createState() => _ProfileEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileEditorState extends State<ProfileEditor> {
|
||||||
|
late final _name = TextEditingController(text: Profile.instance.name.peek());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = Profile.instance;
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Tooltip(
|
||||||
|
message: 'Change avatar',
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: p.pickAvatar,
|
||||||
|
child: const ProfileAvatar(radius: 36),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: kGap),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
autofocus: widget.autofocus,
|
||||||
|
decoration: fieldDecoration('Display name'),
|
||||||
|
onChanged: p.setName,
|
||||||
|
onSubmitted: (_) => widget.onSubmitted?.call(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
final err = p.error.value;
|
||||||
|
return Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.pickAvatar,
|
||||||
|
child: const Text('Change avatar')),
|
||||||
|
if (p.preview.value != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.clearAvatar,
|
||||||
|
child: const Text('Remove avatar')),
|
||||||
|
if (err != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(err,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.error)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> showProfileDialog(BuildContext context) {
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||||
|
title: const Text('Profile'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 380,
|
||||||
|
child: ProfileEditor(
|
||||||
|
autofocus: true,
|
||||||
|
onSubmitted: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context), child: const Text('Done')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home's top-left: avatar + name, click to edit.
|
||||||
|
class ProfileChip extends StatelessWidget {
|
||||||
|
const ProfileChip({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = Profile.instance;
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Edit profile',
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(kRadius),
|
||||||
|
onTap: () => showProfileDialog(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const ProfileAvatar(radius: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
SignalBuilder(builder: (context) {
|
||||||
|
final n = p.name.value;
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(n.isEmpty ? 'Set your name' : n,
|
||||||
|
style: t.titleMedium?.copyWith(
|
||||||
|
color: n.isEmpty ? Colors.grey : null)),
|
||||||
|
Text('Edit profile',
|
||||||
|
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../../state/settings.dart';
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import 'profile.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
|
/// In-session keys, as handled by the session screen's global key handler.
|
||||||
|
const sessionShortcuts = [
|
||||||
|
('Space', 'Toggle ready'),
|
||||||
|
('← / →', 'Seek back / forward 10 s'),
|
||||||
|
('F', 'Toggle fullscreen'),
|
||||||
|
('Tab', 'Open / close the people & chat panel'),
|
||||||
|
('Ctrl+R', 'Reload the video'),
|
||||||
|
('Esc', 'Close panel or fullscreen, else ask to leave'),
|
||||||
|
];
|
||||||
|
|
||||||
|
class SettingsPage extends StatelessWidget {
|
||||||
|
final UpdateController? updates;
|
||||||
|
const SettingsPage({super.key, this.updates});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PageScroll(
|
||||||
|
maxWidth: 680,
|
||||||
|
children: [
|
||||||
|
const PageTitle('Settings'),
|
||||||
|
const _Section(title: 'Profile', child: ProfileEditor()),
|
||||||
|
const _Section(title: 'Player', child: _HwdecSetting()),
|
||||||
|
_Section(title: 'Updates', child: _UpdateSettings(controller: updates)),
|
||||||
|
const _Section(title: 'Keyboard shortcuts', child: _ShortcutList()),
|
||||||
|
const _Section(
|
||||||
|
title: 'Video quality & shaders',
|
||||||
|
enabled: false,
|
||||||
|
trailing: _SoonBadge(),
|
||||||
|
child: Text('Upscaling and shader presets for mpv.',
|
||||||
|
style: TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Section extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final bool enabled;
|
||||||
|
final Widget? trailing;
|
||||||
|
const _Section(
|
||||||
|
{required this.title, required this.child, this.enabled = true, this.trailing});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: kGap),
|
||||||
|
child: Panel(
|
||||||
|
enabled: enabled,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [SectionLabel(title, trailing: trailing), child],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SoonBadge extends StatelessWidget {
|
||||||
|
const _SoonBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Text('Coming soon', style: TextStyle(fontSize: 12)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HwdecSetting extends StatefulWidget {
|
||||||
|
const _HwdecSetting();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HwdecSetting> createState() => _HwdecSettingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HwdecSettingState extends State<_HwdecSetting> {
|
||||||
|
final _s = AppSettings.instance;
|
||||||
|
late final _custom = TextEditingController(text: _s.hwdecCustom.peek());
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_custom.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SignalBuilder(builder: (context) {
|
||||||
|
final mode = _s.hwdecMode.value;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Hardware decoding'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'Applies on the next join or Reload video (Ctrl+R). Turn it off if video '
|
||||||
|
'shows glitches or a black picture.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SegmentedButton<HwdecMode>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: HwdecMode.auto, label: Text('Auto')),
|
||||||
|
ButtonSegment(value: HwdecMode.off, label: Text('Off')),
|
||||||
|
ButtonSegment(value: HwdecMode.custom, label: Text('Custom')),
|
||||||
|
],
|
||||||
|
selected: {mode},
|
||||||
|
onSelectionChanged: (s) => _s.setHwdecMode(s.first),
|
||||||
|
),
|
||||||
|
if (mode == HwdecMode.custom)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: _custom,
|
||||||
|
decoration: fieldDecoration('mpv hwdec value',
|
||||||
|
hint: 'e.g. vaapi, nvdec, d3d11va, auto-safe'),
|
||||||
|
onChanged: _s.setHwdecCustom,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdateSettings extends StatelessWidget {
|
||||||
|
final UpdateController? controller;
|
||||||
|
const _UpdateSettings({this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = controller ?? UpdateController.instance;
|
||||||
|
final s = AppSettings.instance;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: UpdateStatusLine(controller: c)),
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: c.status,
|
||||||
|
builder: (context, st, _) {
|
||||||
|
if (st.phase == UpdatePhase.idle || st.manifest == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => showUpdateDialog(context, controller: c),
|
||||||
|
child: const Text('Show update'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: updateCheck,
|
||||||
|
builder: (context, check, _) => OutlinedButton(
|
||||||
|
onPressed: check == UpdateCheck.checking ? null : () => runUpdateCheck(c, true),
|
||||||
|
child: const Text('Check now'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SignalBuilder(
|
||||||
|
builder: (context) => SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Check automatically on start'),
|
||||||
|
value: s.autoCheckUpdates.value,
|
||||||
|
onChanged: s.setAutoCheckUpdates,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShortcutList extends StatelessWidget {
|
||||||
|
const _ShortcutList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('While watching:', style: TextStyle(color: Colors.grey)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final (key, what) in sessionShortcuts)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 96,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(key,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(what)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Product name (working title). Change it here only.
|
||||||
|
const appName = 'Watch Together';
|
||||||
|
|
||||||
|
const repoUrl = 'https://git.senkochan.moe/Foxyz/sync_replay_project';
|
||||||
|
|
||||||
|
const kRadius = 12.0;
|
||||||
|
const kGap = 16.0;
|
||||||
|
const kPagePadding = EdgeInsets.fromLTRB(28, 20, 28, 28);
|
||||||
|
|
||||||
|
/// Rounded panel used by every Home/Settings section.
|
||||||
|
class Panel extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final bool enabled;
|
||||||
|
const Panel(
|
||||||
|
{super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(20),
|
||||||
|
this.enabled = true});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
// Material (not a DecoratedBox) so ink from tiles/chips inside shows.
|
||||||
|
final panel = Material(
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(kRadius),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(padding: padding, child: child),
|
||||||
|
);
|
||||||
|
if (enabled) return panel;
|
||||||
|
return Opacity(opacity: 0.5, child: IgnorePointer(child: panel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page heading + optional subtitle, same spacing everywhere.
|
||||||
|
class PageTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
const PageTitle(this.title, {super.key, this.subtitle});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = Theme.of(context).textTheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: kGap),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: t.headlineSmall),
|
||||||
|
if (subtitle != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(subtitle!,
|
||||||
|
style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small grey heading inside a panel.
|
||||||
|
class SectionLabel extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final Widget? trailing;
|
||||||
|
const SectionLabel(this.text, {super.key, this.trailing});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
?trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text field style shared by the connect cards.
|
||||||
|
InputDecoration fieldDecoration(String label, {String? hint, Widget? suffix}) =>
|
||||||
|
InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
isDense: true,
|
||||||
|
suffixIcon: suffix,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Single-item scrollable page body, width-capped so it doesn't sprawl
|
||||||
|
/// on wide windows.
|
||||||
|
class PageScroll extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final double maxWidth;
|
||||||
|
const PageScroll({super.key, required this.children, this.maxWidth = 920});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: kPagePadding,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../update/update_controller.dart';
|
||||||
|
import '../../widgets/update_banner.dart';
|
||||||
|
import 'ui.dart';
|
||||||
|
|
||||||
|
/// Compact "Update vX" pill for Home's top-right; hidden until there is
|
||||||
|
/// something to install. Click opens the full [UpdateBanner].
|
||||||
|
class UpdateIndicator extends StatelessWidget {
|
||||||
|
final UpdateController? controller;
|
||||||
|
const UpdateIndicator({super.key, this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = controller ?? UpdateController.instance;
|
||||||
|
return ValueListenableBuilder<UpdateStatus>(
|
||||||
|
valueListenable: c.status,
|
||||||
|
builder: (context, s, _) {
|
||||||
|
final m = s.manifest;
|
||||||
|
if (s.phase == UpdatePhase.idle || m == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final busy = s.phase == UpdatePhase.downloading ||
|
||||||
|
s.phase == UpdatePhase.applying ||
|
||||||
|
s.phase == UpdatePhase.restarting;
|
||||||
|
final label = switch (s.phase) {
|
||||||
|
UpdatePhase.downloading =>
|
||||||
|
'Updating ${((s.progress ?? 0) * 100).round()}%',
|
||||||
|
UpdatePhase.applying => 'Installing…',
|
||||||
|
UpdatePhase.restarting => 'Restarting…',
|
||||||
|
UpdatePhase.failed => 'Update failed',
|
||||||
|
_ => m.isRequired ? 'Required update' : 'Update v${m.version}',
|
||||||
|
};
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Show update',
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: m.isRequired || s.phase == UpdatePhase.failed
|
||||||
|
? scheme.errorContainer
|
||||||
|
: null,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
onPressed: () => showUpdateDialog(context, controller: c),
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.system_update_alt, size: 18),
|
||||||
|
label: Text(label),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The existing update card in a dialog; closes itself when the card
|
||||||
|
/// would render nothing (e.g. "Later").
|
||||||
|
Future<void> showUpdateDialog(BuildContext context, {UpdateController? controller}) {
|
||||||
|
final c = controller ?? UpdateController.instance;
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: ValueListenableBuilder<UpdateStatus>(
|
||||||
|
valueListenable: c.status,
|
||||||
|
builder: (context, s, _) {
|
||||||
|
if (s.phase == UpdatePhase.idle || s.manifest == null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) Navigator.of(context).maybePop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: UpdateBanner(controller: c),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UpdateCheck { notRun, checking, done }
|
||||||
|
|
||||||
|
/// Progress of this run's update check, for Settings/About wording.
|
||||||
|
final updateCheck = ValueNotifier(UpdateCheck.notRun);
|
||||||
|
|
||||||
|
/// [force] re-checks even if this process already checked ("Check now").
|
||||||
|
Future<void> runUpdateCheck([UpdateController? controller, bool force = false]) async {
|
||||||
|
final c = controller ?? UpdateController.instance;
|
||||||
|
updateCheck.value = UpdateCheck.checking;
|
||||||
|
try {
|
||||||
|
await (force ? c.checkNow() : c.checkOnce());
|
||||||
|
} finally {
|
||||||
|
updateCheck.value = UpdateCheck.done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line update state: "Up to date", "Update v1.2 available", ...
|
||||||
|
class UpdateStatusLine extends StatelessWidget {
|
||||||
|
final UpdateController? controller;
|
||||||
|
const UpdateStatusLine({super.key, this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = controller ?? UpdateController.instance;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: Listenable.merge([c.status, c.lastCheck, updateCheck]),
|
||||||
|
builder: (context, _) {
|
||||||
|
final s = c.status.value;
|
||||||
|
final m = s.manifest;
|
||||||
|
final String text;
|
||||||
|
if (c.skipDevBuilds && c.currentVersion.endsWith('-dev')) {
|
||||||
|
text = 'Dev build — update checks are off';
|
||||||
|
} else if (s.phase != UpdatePhase.idle && m != null) {
|
||||||
|
text = s.phase == UpdatePhase.failed
|
||||||
|
? 'Update to v${m.version} failed'
|
||||||
|
: 'Update v${m.version} available';
|
||||||
|
} else {
|
||||||
|
text = switch (updateCheck.value) {
|
||||||
|
UpdateCheck.notRun => 'Not checked yet',
|
||||||
|
UpdateCheck.checking => 'Checking…',
|
||||||
|
UpdateCheck.done => c.lastCheck.value ?? 'Not checked yet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Text(text,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: Colors.grey));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import '../state/session_state.dart';
|
|||||||
import '../update/updater.dart';
|
import '../update/updater.dart';
|
||||||
import '../widgets/dialogs.dart';
|
import '../widgets/dialogs.dart';
|
||||||
import '../widgets/member_avatar.dart';
|
import '../widgets/member_avatar.dart';
|
||||||
|
import '../widgets/shown_frame_probe.dart';
|
||||||
|
import '../widgets/text_focus.dart';
|
||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import '../widgets/window_drag_layer.dart';
|
import '../widgets/window_drag_layer.dart';
|
||||||
|
|
||||||
@@ -20,8 +22,12 @@ class SessionScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SessionScreenState extends State<SessionScreen> {
|
class _SessionScreenState extends State<SessionScreen> {
|
||||||
VideoController? _video;
|
// One key per player generation: a reloaded player needs a fresh
|
||||||
final _videoKey = GlobalKey<VideoState>();
|
// `Video` (it never swaps controllers in place).
|
||||||
|
var _videoGen = -1;
|
||||||
|
var _videoKey = GlobalKey<VideoState>();
|
||||||
|
bool _refullscreen = false;
|
||||||
|
final _frameProbe = ShownFrameProbe();
|
||||||
final _sheetOpen = signal(false);
|
final _sheetOpen = signal(false);
|
||||||
final _confirmLeave = signal(false);
|
final _confirmLeave = signal(false);
|
||||||
final _isFullscreen = signal(false);
|
final _isFullscreen = signal(false);
|
||||||
@@ -31,6 +37,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
final _swapLink = TextEditingController();
|
final _swapLink = TextEditingController();
|
||||||
late final void Function() _stopKickWatch;
|
late final void Function() _stopKickWatch;
|
||||||
late final void Function() _stopSheetWatch;
|
late final void Function() _stopSheetWatch;
|
||||||
|
late final void Function() _stopPlayerWatch;
|
||||||
late final FocusNode _noFocus;
|
late final FocusNode _noFocus;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -38,11 +45,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
final session = SessionState.instance;
|
final session = SessionState.instance;
|
||||||
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||||
_video = VideoController(session.coordinator.playback.player);
|
session.coordinator.playback.shownFrame = _frameProbe.sample;
|
||||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||||
_stopKickWatch = effect(() {
|
_stopKickWatch = effect(() {
|
||||||
if (session.coordinator.kickReason.value != null) {
|
if (session.coordinator.kickReason.value != null) {
|
||||||
session.route.value = AppRoute.connect;
|
session.route.value = AppRoute.home;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_stopSheetWatch = effect(() {
|
_stopSheetWatch = effect(() {
|
||||||
@@ -50,6 +57,35 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The fullscreen route holds its own `Video` of the old player: leave
|
||||||
|
// it before the player goes away, re-enter once the new one is up.
|
||||||
|
_stopPlayerWatch = effect(() {
|
||||||
|
final alive = session.coordinator.playback.alive.value;
|
||||||
|
if (!alive && _isFullscreen.peek()) {
|
||||||
|
_refullscreen = true;
|
||||||
|
if (mounted) Navigator.of(context, rootNavigator: true).maybePop();
|
||||||
|
} else if (alive && _refullscreen) {
|
||||||
|
_refullscreen = false;
|
||||||
|
// Entering before the new output's first frame leaves the
|
||||||
|
// fullscreen copy blank for good (seen in a real run).
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
await session.coordinator.playback.video.waitUntilFirstFrameRendered
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
} catch (_) {}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
if (mounted && !_isFullscreen.peek() && session.coordinator.playback.alive.peek()) {
|
||||||
|
_videoKey.currentState?.enterFullscreen();
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reloadVideo() {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
coord.markInput();
|
||||||
|
coord.reloadVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFullscreen() {
|
void _toggleFullscreen() {
|
||||||
@@ -65,8 +101,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||||
|
final playback = SessionState.instance.coordinator.playback;
|
||||||
|
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||||
_stopKickWatch();
|
_stopKickWatch();
|
||||||
_stopSheetWatch();
|
_stopSheetWatch();
|
||||||
|
_stopPlayerWatch();
|
||||||
_chat.dispose();
|
_chat.dispose();
|
||||||
_chatFocus.dispose();
|
_chatFocus.dispose();
|
||||||
_swapLink.dispose();
|
_swapLink.dispose();
|
||||||
@@ -74,25 +113,21 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
super.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) {
|
bool _globalKey(KeyEvent event) {
|
||||||
if (event is! KeyDownEvent) return false;
|
if (event is! KeyDownEvent) return false;
|
||||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||||
if (_editing) return false;
|
if (textInputFocused()) return false;
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
|
if (event.logicalKey == LogicalKeyboardKey.keyR &&
|
||||||
|
HardwareKeyboard.instance.isControlPressed) {
|
||||||
|
_lastKeyAt = now;
|
||||||
|
_reloadVideo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||||
_lastKeyAt = now;
|
_lastKeyAt = now;
|
||||||
coord.toggleReady();
|
coord.toggleReady();
|
||||||
@@ -174,6 +209,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
const MaterialDesktopVolumeButton(),
|
const MaterialDesktopVolumeButton(),
|
||||||
_ReadyControlButton(focusNode: _noFocus),
|
_ReadyControlButton(focusNode: _noFocus),
|
||||||
_MarkAllReadyControlButton(focusNode: _noFocus),
|
_MarkAllReadyControlButton(focusNode: _noFocus),
|
||||||
|
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
|
||||||
const MaterialDesktopPositionIndicator(),
|
const MaterialDesktopPositionIndicator(),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const MaterialDesktopFullscreenButton(),
|
const MaterialDesktopFullscreenButton(),
|
||||||
@@ -183,16 +219,27 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
descendantsAreTraversable: false,
|
descendantsAreTraversable: false,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: _video == null
|
// Player lifetime = session (and each "Reload video"): no live
|
||||||
? const Center(
|
// player ⇒ no `Video`, so no texture outlives its player.
|
||||||
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
body: SignalBuilder(
|
||||||
)
|
builder: (context) {
|
||||||
: MaterialDesktopVideoControlsTheme(
|
final playback = coord.playback;
|
||||||
|
final gen = playback.generation.value;
|
||||||
|
if (!playback.alive.value) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (gen != _videoGen) {
|
||||||
|
_videoGen = gen;
|
||||||
|
_videoKey = GlobalKey<VideoState>();
|
||||||
|
}
|
||||||
|
return MaterialDesktopVideoControlsTheme(
|
||||||
normal: controlsTheme,
|
normal: controlsTheme,
|
||||||
fullscreen: controlsTheme,
|
fullscreen: controlsTheme,
|
||||||
child: Video(
|
child: Video(
|
||||||
key: _videoKey,
|
key: _videoKey,
|
||||||
controller: _video!,
|
controller: playback.video,
|
||||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||||
controls: _sessionControls,
|
controls: _sessionControls,
|
||||||
onEnterFullscreen: () async {
|
onEnterFullscreen: () async {
|
||||||
@@ -204,7 +251,9 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
await defaultExitNativeFullscreen();
|
await defaultExitNativeFullscreen();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -212,6 +261,8 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
Widget _sessionControls(VideoState state) {
|
Widget _sessionControls(VideoState state) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
|
// Frozen-picture watchdog samples the route around this anchor.
|
||||||
|
_frameProbe.anchor(),
|
||||||
MaterialDesktopVideoControls(state),
|
MaterialDesktopVideoControls(state),
|
||||||
_healthOverlay(),
|
_healthOverlay(),
|
||||||
_waitingOverlay(),
|
_waitingOverlay(),
|
||||||
@@ -453,7 +504,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
title: const Text('Leave room?'),
|
title: const Text('Leave server?'),
|
||||||
content: const Text('You can rejoin right away.'),
|
content: const Text('You can rejoin right away.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
|
TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
|
||||||
@@ -519,6 +570,30 @@ class _MarkAllReadyControlButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ReloadVideoControlButton extends StatelessWidget {
|
||||||
|
final FocusNode focusNode;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
const _ReloadVideoControlButton({required this.focusNode, required this.onPressed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||||
|
child: TextButton.icon(
|
||||||
|
focusNode: focusNode,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
|
label: const Text('Reload video', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ToastCard extends StatefulWidget {
|
class _ToastCard extends StatefulWidget {
|
||||||
final String text;
|
final String text;
|
||||||
const _ToastCard({super.key, required this.text});
|
const _ToastCard({super.key, required this.text});
|
||||||
@@ -592,7 +667,7 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('Room', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_StatusChip(playing: playing, hasRoom: r != null),
|
_StatusChip(playing: playing, hasRoom: r != null),
|
||||||
],
|
],
|
||||||
@@ -663,11 +738,19 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
icon: const Icon(Icons.swap_horiz),
|
valueListenable: swapLink,
|
||||||
onPressed: () {
|
builder: (context, value, _) {
|
||||||
coord.swapLink(swapLink.text.trim());
|
final link = value.text.trim();
|
||||||
swapLink.clear();
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
onPressed: link.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
coord.swapLink(link);
|
||||||
|
swapLink.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -678,7 +761,7 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
label: const Text('Leave room'),
|
label: const Text('Leave server'),
|
||||||
onPressed: onLeave,
|
onPressed: onLeave,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../state/session_state.dart';
|
|
||||||
import '../update/updater.dart';
|
|
||||||
import '../widgets/window_chrome_bar.dart';
|
|
||||||
|
|
||||||
class WelcomeScreen extends StatelessWidget {
|
|
||||||
const WelcomeScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Scaffold(
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
WindowChromeBar(title: 'Watch Together'),
|
|
||||||
Expanded(child: _WelcomeBody()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WelcomeBody extends StatelessWidget {
|
|
||||||
const _WelcomeBody();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final session = SessionState.instance;
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text('Watch Together', style: TextStyle(fontSize: 24)),
|
|
||||||
Text('v$appVersion', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
enum IdleEvent { none, warn, unready }
|
||||||
|
|
||||||
|
/// Idle auto-unready clock. Idle time counts from the later of the user's
|
||||||
|
/// last input and the last moment playback was running: watching is
|
||||||
|
/// activity, so a long watch followed by a pause starts a fresh 10 min.
|
||||||
|
/// The unready also always trails its warning by the full grace period.
|
||||||
|
class IdleTracker {
|
||||||
|
static const warnAfter = Duration(minutes: 8);
|
||||||
|
static const unreadyAfter = Duration(minutes: 10);
|
||||||
|
|
||||||
|
DateTime _lastActive;
|
||||||
|
DateTime? _warnedAt;
|
||||||
|
|
||||||
|
IdleTracker(DateTime now) : _lastActive = now;
|
||||||
|
|
||||||
|
void activity(DateTime now) {
|
||||||
|
_lastActive = now;
|
||||||
|
_warnedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once per tick. Warn and unready only matter while self is
|
||||||
|
/// ready; while the room plays the ready flag gates nothing.
|
||||||
|
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
||||||
|
if (playing) {
|
||||||
|
activity(now);
|
||||||
|
return IdleEvent.none;
|
||||||
|
}
|
||||||
|
if (!selfReady) return IdleEvent.none;
|
||||||
|
final warnedAt = _warnedAt;
|
||||||
|
if (warnedAt != null) {
|
||||||
|
if (now.difference(warnedAt) >= unreadyAfter - warnAfter) {
|
||||||
|
activity(now);
|
||||||
|
return IdleEvent.unready;
|
||||||
|
}
|
||||||
|
return IdleEvent.none;
|
||||||
|
}
|
||||||
|
if (now.difference(_lastActive) >= warnAfter) {
|
||||||
|
_warnedAt = now;
|
||||||
|
return IdleEvent.warn;
|
||||||
|
}
|
||||||
|
return IdleEvent.none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,30 @@ import '../sfx/sfx.dart';
|
|||||||
import '../sync/room_diff.dart';
|
import '../sync/room_diff.dart';
|
||||||
import '../sync/sync_client.dart';
|
import '../sync/sync_client.dart';
|
||||||
import 'health.dart';
|
import 'health.dart';
|
||||||
|
import 'idle.dart';
|
||||||
|
|
||||||
|
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||||
|
class ConnectCancelled implements Exception {
|
||||||
|
const ConnectCancelled();
|
||||||
|
@override
|
||||||
|
String toString() => 'connection cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name for a link: last path segment, decoded, never the query
|
||||||
|
/// (Jellyfin-style links carry api_key there and names are broadcast).
|
||||||
|
String linkDisplayName(String url) {
|
||||||
|
final uri = Uri.tryParse(url.trim());
|
||||||
|
if (uri == null) return 'link';
|
||||||
|
final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||||
|
if (segs.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
return Uri.decodeComponent(segs.last);
|
||||||
|
} catch (_) {
|
||||||
|
return segs.last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||||
|
}
|
||||||
|
|
||||||
class ConnectParams {
|
class ConnectParams {
|
||||||
final String host;
|
final String host;
|
||||||
@@ -79,9 +103,17 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
bool _manualClose = false;
|
bool _manualClose = false;
|
||||||
bool _playerReady = false;
|
bool _playerReady = false;
|
||||||
bool _warnedIdle = false;
|
|
||||||
bool _wasPlayAllowed = false;
|
bool _wasPlayAllowed = false;
|
||||||
|
// Last ready flag the server reported for self; restored after a
|
||||||
|
// reconnect (the server forgets dropped members).
|
||||||
bool _lastReady = false;
|
bool _lastReady = false;
|
||||||
|
// Set after a reconnect until the server echoes the restored ready, so
|
||||||
|
// the fresh member's ready=false doesn't overwrite [_lastReady].
|
||||||
|
bool _restoringReady = false;
|
||||||
|
DateTime? _reassertAt;
|
||||||
|
// Bumped by every connect/leave/cancel/retry: stale attempts no-op.
|
||||||
|
int _epoch = 0;
|
||||||
|
Timer? _reconnectTimer;
|
||||||
int _mediaId = -1;
|
int _mediaId = -1;
|
||||||
String? _openUrl;
|
String? _openUrl;
|
||||||
String _wsHost = '';
|
String _wsHost = '';
|
||||||
@@ -91,16 +123,20 @@ class SessionCoordinator {
|
|||||||
DateTime? _pendingSeekAt;
|
DateTime? _pendingSeekAt;
|
||||||
int _seekRetries = 0;
|
int _seekRetries = 0;
|
||||||
DateTime? _lastRoomAt;
|
DateTime? _lastRoomAt;
|
||||||
DateTime _lastInput = DateTime.now().toUtc();
|
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||||
int _toastId = 0;
|
int _toastId = 0;
|
||||||
ConnectParams? _params;
|
ConnectParams? _params;
|
||||||
bool _everConnected = false;
|
bool _everConnected = false;
|
||||||
|
|
||||||
SessionCoordinator({this.sfx = const NoopSfxService()});
|
SessionCoordinator({this.sfx = const NoopSfxService()});
|
||||||
|
|
||||||
|
/// Player lifetime = one session: created here on connect, disposed by
|
||||||
|
/// [leave]. Never reused across sessions (a GPU-side video freeze sticks
|
||||||
|
/// to the player).
|
||||||
Future<void> initPlayer() async {
|
Future<void> initPlayer() async {
|
||||||
if (_playerReady) return;
|
if (_playerReady) return;
|
||||||
try {
|
try {
|
||||||
|
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||||
await playback.init();
|
await playback.init();
|
||||||
_playerReady = true;
|
_playerReady = true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -111,13 +147,56 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void markInput() {
|
bool _reloadingVideo = false;
|
||||||
_lastInput = DateTime.now().toUtc();
|
|
||||||
_warnedIdle = false;
|
/// "Reload video" (button, Ctrl+R, frozen-picture watchdog): fresh
|
||||||
|
/// player + video output, current media reopened. While it runs the
|
||||||
|
/// player counts as not ready, so reports/seek detection pause and the
|
||||||
|
/// next broadcast resyncs position and play/pause (diff reset + play
|
||||||
|
/// edge). The ready flag is never touched.
|
||||||
|
Future<void> reloadVideo({bool auto = false}) async {
|
||||||
|
// Also allowed after a failed reload (player gone, session still on).
|
||||||
|
if (_params == null || _reloadingVideo) return;
|
||||||
|
_reloadingVideo = true;
|
||||||
|
_playerReady = false;
|
||||||
|
try {
|
||||||
|
await playback.recreate();
|
||||||
|
final url = _openUrl;
|
||||||
|
_openUrl = null;
|
||||||
|
_pendingSeekTarget = null;
|
||||||
|
_pendingSeekAt = null;
|
||||||
|
_seekRetries = 0;
|
||||||
|
// New player starts at 0: without this the first tick would read
|
||||||
|
// the drop to 0 as a user seek and send everyone there.
|
||||||
|
_lastPos = 0;
|
||||||
|
if (url != null) await _openMedia(url);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('video reload failed: $e\n$st');
|
||||||
|
health.value =
|
||||||
|
HealthStatus(HealthSeverity.error, 'video reload failed: $e');
|
||||||
|
} finally {
|
||||||
|
_playerReady = playback.alive.peek();
|
||||||
|
_reloadingVideo = false;
|
||||||
|
}
|
||||||
|
if (auto) {
|
||||||
|
_toastId++;
|
||||||
|
toasts.value = [
|
||||||
|
...toasts.peek(),
|
||||||
|
ToastItem(
|
||||||
|
id: _toastId,
|
||||||
|
text: 'Video froze — reloaded',
|
||||||
|
at: DateTime.now().toUtc())
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void markInput() => _idle.activity(DateTime.now().toUtc());
|
||||||
|
|
||||||
Future<void> connect(ConnectParams params) async {
|
Future<void> connect(ConnectParams params) async {
|
||||||
await leave(silent: true);
|
// Own epoch first: a cancel during any await below makes this stale.
|
||||||
|
final epoch = ++_epoch;
|
||||||
|
await _teardown(silent: true);
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
_params = params;
|
_params = params;
|
||||||
_manualClose = false;
|
_manualClose = false;
|
||||||
kickReason.value = null;
|
kickReason.value = null;
|
||||||
@@ -127,6 +206,7 @@ class SessionCoordinator {
|
|||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
_openUrl = null;
|
_openUrl = null;
|
||||||
_lastReady = false;
|
_lastReady = false;
|
||||||
|
_restoringReady = false;
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_everConnected = false;
|
_everConnected = false;
|
||||||
markInput();
|
markInput();
|
||||||
@@ -134,6 +214,7 @@ class SessionCoordinator {
|
|||||||
feedLog.value = [];
|
feedLog.value = [];
|
||||||
toasts.value = [];
|
toasts.value = [];
|
||||||
await initPlayer();
|
await initPlayer();
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
health.value =
|
health.value =
|
||||||
const HealthStatus(HealthSeverity.info, 'connecting…');
|
const HealthStatus(HealthSeverity.info, 'connecting…');
|
||||||
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
|
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
|
||||||
@@ -144,6 +225,8 @@ class SessionCoordinator {
|
|||||||
name: params.name,
|
name: params.name,
|
||||||
avatar: params.avatar,
|
avatar: params.avatar,
|
||||||
);
|
);
|
||||||
|
// Whoever bumped the epoch (cancel/leave) also closed the socket.
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
@@ -157,10 +240,14 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
if (params.linkUrl.isNotEmpty) {
|
if (params.linkUrl.isNotEmpty) {
|
||||||
sync.sendSwap(HelloMedia(
|
sync.sendSwap(HelloMedia(
|
||||||
name: params.linkUrl, size: 0, duration: 0, url: params.linkUrl));
|
name: linkDisplayName(params.linkUrl),
|
||||||
|
size: 0,
|
||||||
|
duration: 0,
|
||||||
|
url: params.linkUrl));
|
||||||
}
|
}
|
||||||
_startTicker();
|
_startTicker();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
debugPrint('connect failed: $e');
|
debugPrint('connect failed: $e');
|
||||||
health.value =
|
health.value =
|
||||||
HealthStatus(HealthSeverity.error, _friendlyError(e));
|
HealthStatus(HealthSeverity.error, _friendlyError(e));
|
||||||
@@ -208,6 +295,7 @@ class SessionCoordinator {
|
|||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
_lastRoomAt = now;
|
_lastRoomAt = now;
|
||||||
room.value = r;
|
room.value = r;
|
||||||
|
_trackSelfReady(r, now);
|
||||||
if (r.mediaId != _mediaId) {
|
if (r.mediaId != _mediaId) {
|
||||||
_mediaId = r.mediaId;
|
_mediaId = r.mediaId;
|
||||||
var url = roomMediaUrl(r);
|
var url = roomMediaUrl(r);
|
||||||
@@ -257,6 +345,25 @@ class SessionCoordinator {
|
|||||||
_updateStarving(r, playback.bufferedSecsAhead);
|
_updateStarving(r, playback.bufferedSecsAhead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||||
|
/// by others included) and re-enters the set after a stale spell.
|
||||||
|
void _trackSelfReady(RoomBroadcast r, DateTime now) {
|
||||||
|
final self = selfMember.peek();
|
||||||
|
if (self == null) return;
|
||||||
|
if (r.mediaId != _mediaId) _restoringReady = false;
|
||||||
|
if (_restoringReady) {
|
||||||
|
if (self.ready) _restoringReady = false;
|
||||||
|
} else {
|
||||||
|
_lastReady = self.ready;
|
||||||
|
}
|
||||||
|
if (shouldReassertReady(self) &&
|
||||||
|
(_reassertAt == null ||
|
||||||
|
now.difference(_reassertAt!) >= const Duration(seconds: 3))) {
|
||||||
|
_reassertAt = now;
|
||||||
|
sync.sendReady(true, source: 'auto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
|
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
|
||||||
if (r.transport == 'playing') {
|
if (r.transport == 'playing') {
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
@@ -335,16 +442,22 @@ class SessionCoordinator {
|
|||||||
Future<void> _onKicked(String reason) async {
|
Future<void> _onKicked(String reason) async {
|
||||||
kickReason.value = reason;
|
kickReason.value = reason;
|
||||||
_manualClose = true;
|
_manualClose = true;
|
||||||
|
_epoch++;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
_ticker = null;
|
_ticker = null;
|
||||||
|
// Stop, not pause: a paused player keeps filling its cache from the
|
||||||
|
// host's media URL.
|
||||||
|
_openUrl = null;
|
||||||
try {
|
try {
|
||||||
await playback.apply(const [TransportAction(false)]);
|
await playback.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await sync.disconnect();
|
await sync.disconnect();
|
||||||
health.value = HealthStatus(
|
health.value = HealthStatus(
|
||||||
HealthSeverity.error,
|
HealthSeverity.error,
|
||||||
reason.isEmpty ? 'kicked from room' : 'kicked: $reason');
|
reason.isEmpty ? 'kicked from server' : 'kicked: $reason');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTicker() {
|
void _startTicker() {
|
||||||
@@ -385,17 +498,14 @@ class SessionCoordinator {
|
|||||||
_scheduleReconnect();
|
_scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final idleSecs = now.difference(_lastInput).inSeconds;
|
// No warning when there is nothing to lose (unready), and none while
|
||||||
// Steady playback counts as activity: only warn/unready when HELD.
|
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||||
// While the room is PLAYING the ready flag gates nothing, so an
|
// activity so a pause after a long watch starts a fresh idle clock.
|
||||||
// idle viewer must never lose it mid-watch.
|
final event = _idle.tick(now,
|
||||||
if (room.peek()?.transport != 'playing') {
|
playing: room.peek()?.transport == 'playing',
|
||||||
// No warning when there is nothing to lose: the 10-min unready
|
selfReady: selfMember.peek()?.ready ?? false);
|
||||||
// only fires while self is ready, so the 8-min warning must not
|
switch (event) {
|
||||||
// fire while unready either.
|
case IdleEvent.warn:
|
||||||
final selfReady = selfMember.peek()?.ready ?? false;
|
|
||||||
if (selfReady && idleSecs >= 8 * 60 && !_warnedIdle) {
|
|
||||||
_warnedIdle = true;
|
|
||||||
_toastId++;
|
_toastId++;
|
||||||
toasts.value = [
|
toasts.value = [
|
||||||
...toasts.peek(),
|
...toasts.peek(),
|
||||||
@@ -405,15 +515,11 @@ class SessionCoordinator {
|
|||||||
at: now)
|
at: now)
|
||||||
];
|
];
|
||||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||||
}
|
case IdleEvent.unready:
|
||||||
if (idleSecs >= 10 * 60) {
|
_lastReady = false;
|
||||||
final self = selfMember.peek();
|
sync.sendReady(false, source: 'auto');
|
||||||
if (self != null && self.ready) {
|
case IdleEvent.none:
|
||||||
_lastReady = false;
|
break;
|
||||||
sync.sendReady(false, source: 'auto');
|
|
||||||
markInput();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +565,9 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
void toggleReady() {
|
void toggleReady() {
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
final next = !(self?.ready ?? false);
|
// Ready but out of the set (dropped out while stale) gates like
|
||||||
|
// unready, so Space puts self back in instead of unreadying.
|
||||||
|
final next = !((self?.ready ?? false) && (self?.inSet ?? false));
|
||||||
markInput();
|
markInput();
|
||||||
_lastReady = next;
|
_lastReady = next;
|
||||||
sync.sendReady(next, source: 'manual');
|
sync.sendReady(next, source: 'manual');
|
||||||
@@ -506,8 +614,8 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
Future<void> swapLink(String url) async {
|
Future<void> swapLink(String url) async {
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendSwap(
|
sync.sendSwap(HelloMedia(
|
||||||
HelloMedia(name: url, size: 0, duration: 0, url: url));
|
name: linkDisplayName(url), size: 0, duration: 0, url: url));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleReconnect({bool manual = false}) {
|
void _scheduleReconnect({bool manual = false}) {
|
||||||
@@ -515,50 +623,79 @@ class SessionCoordinator {
|
|||||||
if (!manual && !_everConnected) return;
|
if (!manual && !_everConnected) return;
|
||||||
if (reconnecting.peek()) return;
|
if (reconnecting.peek()) return;
|
||||||
reconnecting.value = true;
|
reconnecting.value = true;
|
||||||
Future.delayed(const Duration(milliseconds: 1500), () async {
|
final epoch = _epoch;
|
||||||
if (_manualClose || kickReason.peek() != null) {
|
_reconnectTimer?.cancel();
|
||||||
reconnecting.value = false;
|
_reconnectTimer = Timer(
|
||||||
return;
|
const Duration(milliseconds: 1500), () => _reconnect(epoch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reconnect attempt. Every cancel/retry/leave/connect bumps
|
||||||
|
/// [_epoch]; an attempt from an older epoch must not touch state (it
|
||||||
|
/// would kill the newer connection and leave a ghost member).
|
||||||
|
Future<void> _reconnect(int epoch) async {
|
||||||
|
_reconnectTimer = null;
|
||||||
|
bool stale() => epoch != _epoch || kickReason.peek() != null;
|
||||||
|
final params = _params;
|
||||||
|
if (stale() || params == null) return;
|
||||||
|
if (_manualClose) {
|
||||||
|
reconnecting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final wantReady = _lastReady;
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||||
|
if (stale()) return;
|
||||||
|
reconnecting.value = false;
|
||||||
|
_everConnected = true;
|
||||||
|
_lastRoomAt = DateTime.now().toUtc();
|
||||||
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
|
final url = welcome.mediaUrl;
|
||||||
|
if (url != null && url.isNotEmpty && url != _openUrl) {
|
||||||
|
await _openMedia(url);
|
||||||
}
|
}
|
||||||
final params = _params;
|
if (stale()) return;
|
||||||
if (params == null) {
|
// The server forgot the dropped member; restore the ready flag it
|
||||||
reconnecting.value = false;
|
// last reported (not a local guess: others may have forced it).
|
||||||
return;
|
if (wantReady) {
|
||||||
|
_lastReady = true;
|
||||||
|
_restoringReady = true;
|
||||||
|
sync.sendReady(true, source: 'manual');
|
||||||
}
|
}
|
||||||
try {
|
_startTicker();
|
||||||
health.value = const HealthStatus(
|
} catch (e) {
|
||||||
HealthSeverity.info, 'reconnecting…');
|
if (stale()) return;
|
||||||
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
|
debugPrint('reconnect failed: $e');
|
||||||
final welcome = await sync.connect(
|
reconnecting.value = false;
|
||||||
wsUri: wsUri,
|
_scheduleReconnect();
|
||||||
secret: params.secret,
|
}
|
||||||
name: params.name,
|
}
|
||||||
avatar: params.avatar,
|
|
||||||
);
|
void _invalidateAttempts() {
|
||||||
reconnecting.value = false;
|
_epoch++;
|
||||||
_everConnected = true;
|
_reconnectTimer?.cancel();
|
||||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
_reconnectTimer = null;
|
||||||
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() {
|
void cancelReconnect() {
|
||||||
_manualClose = true;
|
_manualClose = true;
|
||||||
|
_invalidateAttempts();
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
|
// Abort an attempt already dialling (no live session while retrying).
|
||||||
|
unawaited(sync.disconnect().catchError((_) {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelConnect() async {
|
Future<void> cancelConnect() async {
|
||||||
_manualClose = true;
|
_manualClose = true;
|
||||||
|
_invalidateAttempts();
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
try {
|
try {
|
||||||
await sync.disconnect();
|
await sync.disconnect();
|
||||||
@@ -568,6 +705,7 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
void retry() {
|
void retry() {
|
||||||
if (kickReason.peek() != null || _params == null) return;
|
if (kickReason.peek() != null || _params == null) return;
|
||||||
|
_invalidateAttempts();
|
||||||
_manualClose = false;
|
_manualClose = false;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
health.value =
|
health.value =
|
||||||
@@ -578,7 +716,7 @@ class SessionCoordinator {
|
|||||||
String _friendlyError(Object e) {
|
String _friendlyError(Object e) {
|
||||||
final msg = '$e';
|
final msg = '$e';
|
||||||
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
|
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('wrong secret')) return 'wrong server password (401)';
|
||||||
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||||
return 'host offline — check address and port';
|
return 'host offline — check address and port';
|
||||||
}
|
}
|
||||||
@@ -586,12 +724,20 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> leave({bool silent = false}) async {
|
Future<void> leave({bool silent = false}) async {
|
||||||
|
_invalidateAttempts();
|
||||||
|
await _teardown(silent: silent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _teardown({bool silent = false}) async {
|
||||||
_manualClose = true;
|
_manualClose = true;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = null;
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
_ticker = null;
|
_ticker = null;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
|
_playerReady = false;
|
||||||
try {
|
try {
|
||||||
await playback.stop();
|
await playback.dispose();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
await sync.disconnect();
|
await sync.disconnect();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -9,14 +12,21 @@ import '../session/session_coordinator.dart';
|
|||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
|
|
||||||
enum AppRoute { welcome, connect, session }
|
enum AppRoute {
|
||||||
|
home,
|
||||||
|
session,
|
||||||
|
}
|
||||||
|
|
||||||
enum ConnectMode { host, join }
|
enum ConnectMode { host, join }
|
||||||
|
|
||||||
|
/// Both Home cards prefill from this. [host]/[port]/[secret] belong to
|
||||||
|
/// the Join card, [hostPort]/[hostSecret] to the Host card.
|
||||||
class ConnectForm {
|
class ConnectForm {
|
||||||
String host;
|
String host;
|
||||||
String port;
|
String port;
|
||||||
String secret;
|
String secret;
|
||||||
|
String hostPort;
|
||||||
|
String hostSecret;
|
||||||
String name;
|
String name;
|
||||||
String avatarPath;
|
String avatarPath;
|
||||||
String mediaPath;
|
String mediaPath;
|
||||||
@@ -25,36 +35,137 @@ class ConnectForm {
|
|||||||
this.host = '127.0.0.1',
|
this.host = '127.0.0.1',
|
||||||
this.port = '8080',
|
this.port = '8080',
|
||||||
this.secret = '',
|
this.secret = '',
|
||||||
|
this.hostPort = '8080',
|
||||||
|
this.hostSecret = '',
|
||||||
this.name = '',
|
this.name = '',
|
||||||
this.avatarPath = '',
|
this.avatarPath = '',
|
||||||
this.mediaPath = '',
|
this.mediaPath = '',
|
||||||
this.linkUrl = '',
|
this.linkUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ConnectForm copy() => ConnectForm(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
secret: secret,
|
||||||
|
hostPort: hostPort,
|
||||||
|
hostSecret: hostSecret,
|
||||||
|
name: name,
|
||||||
|
avatarPath: avatarPath,
|
||||||
|
mediaPath: mediaPath,
|
||||||
|
linkUrl: linkUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectKeyHostPort = 'hostPort';
|
||||||
|
const connectKeyHostSecret = 'hostSecret';
|
||||||
|
const connectKeyRecents = 'recents';
|
||||||
|
const maxRecentServers = 6;
|
||||||
|
|
||||||
|
/// A server the user got into, shown as a Recent chip on Home. Hosted
|
||||||
|
/// entries refill the Host card; joined ones join in one click.
|
||||||
|
class RecentServer {
|
||||||
|
final bool hosted;
|
||||||
|
final String host;
|
||||||
|
final String port;
|
||||||
|
final String secret;
|
||||||
|
const RecentServer(
|
||||||
|
{required this.hosted,
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
this.secret = ''});
|
||||||
|
|
||||||
|
String get key => hosted ? 'host:$port' : '${host.toLowerCase()}:$port';
|
||||||
|
|
||||||
|
Map<String, Object> toJson() =>
|
||||||
|
{'hosted': hosted, 'host': host, 'port': port, 'secret': secret};
|
||||||
|
|
||||||
|
static RecentServer? fromJson(Object? j) {
|
||||||
|
if (j is! Map) return null;
|
||||||
|
final host = j['host'], port = j['port'], secret = j['secret'];
|
||||||
|
if (host is! String || port is! String) return null;
|
||||||
|
return RecentServer(
|
||||||
|
hosted: j['hosted'] == true,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
secret: secret is String ? secret : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest first, one entry per server, capped at [maxRecentServers].
|
||||||
|
List<RecentServer> pushRecent(List<RecentServer> list, RecentServer s) => [
|
||||||
|
s,
|
||||||
|
...list.where((r) => r.key != s.key),
|
||||||
|
].take(maxRecentServers).toList();
|
||||||
|
|
||||||
|
String encodeRecents(List<RecentServer> list) =>
|
||||||
|
jsonEncode([for (final r in list) r.toJson()]);
|
||||||
|
|
||||||
|
List<RecentServer> decodeRecents(Object? raw) {
|
||||||
|
if (raw is! String) return const [];
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(raw);
|
||||||
|
if (list is! List) return const [];
|
||||||
|
return [
|
||||||
|
for (final j in list) ?RecentServer.fromJson(j),
|
||||||
|
].take(maxRecentServers).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before recents existed only the last form was stored: a saved remote
|
||||||
|
/// address becomes the first Recent chip.
|
||||||
|
List<RecentServer> migrateRecents(
|
||||||
|
{required bool hadForm,
|
||||||
|
required String host,
|
||||||
|
required String port,
|
||||||
|
required String secret}) {
|
||||||
|
final h = host.trim().toLowerCase();
|
||||||
|
if (!hadForm || h.isEmpty || h == '127.0.0.1' || h == 'localhost') {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionState {
|
class SessionState {
|
||||||
static final SessionState instance = SessionState._();
|
static final SessionState instance = SessionState._();
|
||||||
SessionState._();
|
SessionState._();
|
||||||
|
|
||||||
final route = signal(AppRoute.welcome);
|
final route = signal(AppRoute.home);
|
||||||
final connectMode = signal(ConnectMode.host);
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final room = signal<RoomBroadcast?>(null);
|
final room = signal<RoomBroadcast?>(null);
|
||||||
final SfxService sfx = const NoopSfxService();
|
final SfxService sfx = const NoopSfxService();
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||||
final lastForm = ConnectForm();
|
final lastForm = ConnectForm();
|
||||||
RoomServer? _server;
|
RoomServer? _server;
|
||||||
|
void Function()? _stopKickWatch;
|
||||||
|
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||||
|
// next step and unwinds instead of connecting.
|
||||||
|
int _op = 0;
|
||||||
|
|
||||||
Future<void> loadForm() async {
|
Future<void> loadForm() async {
|
||||||
final box = await openConnectFormBox();
|
final box = await openConnectFormBox();
|
||||||
lastForm
|
lastForm
|
||||||
..host = readString(box, connectKeyHost, '127.0.0.1')
|
..host = readString(box, connectKeyHost, '127.0.0.1')
|
||||||
..port = readString(box, connectKeyPort, '8080')
|
..port = readString(box, connectKeyPort, '8080')
|
||||||
..secret = readString(box, connectKeySecret, '')
|
..secret = readString(box, connectKeySecret, '');
|
||||||
|
// Old installs had one port/password for both modes.
|
||||||
|
lastForm
|
||||||
|
..hostPort = readString(box, connectKeyHostPort, lastForm.port)
|
||||||
|
..hostSecret = readString(box, connectKeyHostSecret, lastForm.secret)
|
||||||
..name = readString(box, connectKeyName, '')
|
..name = readString(box, connectKeyName, '')
|
||||||
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
||||||
..mediaPath = readString(box, connectKeyMediaPath, '')
|
..mediaPath = readString(box, connectKeyMediaPath, '')
|
||||||
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
..linkUrl = readString(box, connectKeyLinkUrl, '');
|
||||||
|
recents.value = box.containsKey(connectKeyRecents)
|
||||||
|
? decodeRecents(box.get(connectKeyRecents))
|
||||||
|
: migrateRecents(
|
||||||
|
hadForm: box.containsKey(connectKeyHost),
|
||||||
|
host: lastForm.host,
|
||||||
|
port: lastForm.port,
|
||||||
|
secret: lastForm.secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveForm() async {
|
Future<void> saveForm() async {
|
||||||
@@ -63,61 +174,117 @@ class SessionState {
|
|||||||
connectKeyHost: lastForm.host,
|
connectKeyHost: lastForm.host,
|
||||||
connectKeyPort: lastForm.port,
|
connectKeyPort: lastForm.port,
|
||||||
connectKeySecret: lastForm.secret,
|
connectKeySecret: lastForm.secret,
|
||||||
|
connectKeyHostPort: lastForm.hostPort,
|
||||||
|
connectKeyHostSecret: lastForm.hostSecret,
|
||||||
connectKeyName: lastForm.name,
|
connectKeyName: lastForm.name,
|
||||||
connectKeyAvatarPath: lastForm.avatarPath,
|
connectKeyAvatarPath: lastForm.avatarPath,
|
||||||
connectKeyMediaPath: lastForm.mediaPath,
|
connectKeyMediaPath: lastForm.mediaPath,
|
||||||
connectKeyLinkUrl: lastForm.linkUrl,
|
connectKeyLinkUrl: lastForm.linkUrl,
|
||||||
|
connectKeyRecents: encodeRecents(recents.peek()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> loadAvatar(String path) => encodeAvatar(path);
|
Future<String> loadAvatar(String path) => encodeAvatar(path);
|
||||||
|
|
||||||
void goConnect(ConnectMode mode) {
|
/// Called by Home after a host/join got through.
|
||||||
connectMode.value = mode;
|
Future<void> rememberServer(RecentServer server) async {
|
||||||
route.value = AppRoute.connect;
|
recents.value = pushRecent(recents.peek(), server);
|
||||||
|
await _saveFormQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> forgetServer(RecentServer server) async {
|
||||||
|
recents.value = [
|
||||||
|
for (final r in recents.peek())
|
||||||
|
if (r.key != server.key) r,
|
||||||
|
];
|
||||||
|
await _saveFormQuietly();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> host(ConnectParams params, ConnectForm form) async {
|
Future<void> host(ConnectParams params, ConnectForm form) async {
|
||||||
|
final op = ++_op;
|
||||||
_rememberForm(form);
|
_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 {
|
try {
|
||||||
await server.start();
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('server start failed: $e\n$st');
|
|
||||||
status.value =
|
status.value =
|
||||||
HealthStatus(HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||||
rethrow;
|
await stopServer();
|
||||||
}
|
if (op != _op) throw const ConnectCancelled();
|
||||||
_server = server;
|
final server = RoomServer(AppConfig(
|
||||||
status.value =
|
port: params.port,
|
||||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
secret: params.secret,
|
||||||
await coordinator.connect(ConnectParams(
|
name: 'room',
|
||||||
host: '127.0.0.1',
|
mediaPath: params.mediaPath,
|
||||||
port: server.boundPort,
|
));
|
||||||
secret: params.secret,
|
try {
|
||||||
name: params.name,
|
await server.start();
|
||||||
avatar: params.avatar,
|
} catch (e, st) {
|
||||||
linkUrl: params.linkUrl,
|
debugPrint('server start failed: $e\n$st');
|
||||||
));
|
status.value = HealthStatus(
|
||||||
route.value = AppRoute.session;
|
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||||
try {
|
rethrow;
|
||||||
await saveForm();
|
}
|
||||||
} catch (e, st) {
|
if (op != _op) {
|
||||||
debugPrint('save connect form failed: $e\n$st');
|
await server.stop();
|
||||||
|
throw const ConnectCancelled();
|
||||||
|
}
|
||||||
|
_server = server;
|
||||||
|
status.value =
|
||||||
|
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
||||||
|
try {
|
||||||
|
await coordinator.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: server.boundPort,
|
||||||
|
secret: params.secret,
|
||||||
|
name: params.name,
|
||||||
|
avatar: params.avatar,
|
||||||
|
linkUrl: params.linkUrl,
|
||||||
|
));
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
} catch (_) {
|
||||||
|
// Own join failed or was cancelled: don't leave the port open.
|
||||||
|
if (identical(_server, server)) await stopServer();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
_watchKick();
|
||||||
|
route.value = AppRoute.session;
|
||||||
|
} finally {
|
||||||
|
await _saveFormQuietly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
Future<void> join(ConnectParams params, ConnectForm form) async {
|
||||||
|
final op = ++_op;
|
||||||
_rememberForm(form);
|
_rememberForm(form);
|
||||||
await coordinator.connect(params);
|
try {
|
||||||
route.value = AppRoute.session;
|
await coordinator.connect(params);
|
||||||
|
if (op != _op) throw const ConnectCancelled();
|
||||||
|
route.value = AppRoute.session;
|
||||||
|
} finally {
|
||||||
|
await _saveFormQuietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel button during setup: abort whichever step is running (server
|
||||||
|
/// start, player init, socket dial) without connecting.
|
||||||
|
Future<void> cancelPending() async {
|
||||||
|
_op++;
|
||||||
|
try {
|
||||||
|
await coordinator.cancelConnect();
|
||||||
|
} catch (_) {}
|
||||||
|
await stopServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kicked host must not keep serving.
|
||||||
|
void _watchKick() {
|
||||||
|
_stopKickWatch?.call();
|
||||||
|
_stopKickWatch = effect(() {
|
||||||
|
// Microtask: stopServer disposes this effect, not from inside it.
|
||||||
|
if (coordinator.kickReason.value != null) scheduleMicrotask(stopServer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saved whatever the outcome: a failed join is exactly when the user
|
||||||
|
/// wants the form back.
|
||||||
|
Future<void> _saveFormQuietly() async {
|
||||||
try {
|
try {
|
||||||
await saveForm();
|
await saveForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -130,6 +297,8 @@ class SessionState {
|
|||||||
..host = form.host
|
..host = form.host
|
||||||
..port = form.port
|
..port = form.port
|
||||||
..secret = form.secret
|
..secret = form.secret
|
||||||
|
..hostPort = form.hostPort
|
||||||
|
..hostSecret = form.hostSecret
|
||||||
..name = form.name
|
..name = form.name
|
||||||
..avatarPath = form.avatarPath
|
..avatarPath = form.avatarPath
|
||||||
..mediaPath = form.mediaPath
|
..mediaPath = form.mediaPath
|
||||||
@@ -141,19 +310,22 @@ class SessionState {
|
|||||||
for (var i = 0; i < args.length - 1; i++) {
|
for (var i = 0; i < args.length - 1; i++) {
|
||||||
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
||||||
}
|
}
|
||||||
_server = RoomServer(AppConfig(port: port, secret: 'change-me', name: 'room'));
|
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
|
||||||
await _server!.start();
|
await _server!.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopServer() async {
|
Future<void> stopServer() async {
|
||||||
await _server?.stop();
|
_stopKickWatch?.call();
|
||||||
|
_stopKickWatch = null;
|
||||||
|
final server = _server;
|
||||||
_server = null;
|
_server = null;
|
||||||
|
await server?.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> leave() async {
|
Future<void> leave() async {
|
||||||
await coordinator.leave();
|
await coordinator.leave();
|
||||||
await stopServer();
|
await stopServer();
|
||||||
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
status.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||||
route.value = AppRoute.connect;
|
route.value = AppRoute.home;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
// App preferences (Settings page), one Hive box. Signals start at their
|
||||||
|
// defaults and take the stored values once [AppSettings.ensureLoaded]
|
||||||
|
// finishes; setters write through.
|
||||||
|
|
||||||
|
const settingsBoxName = 'settings';
|
||||||
|
|
||||||
|
const _keyHwdecMode = 'hwdecMode';
|
||||||
|
const _keyHwdecCustom = 'hwdecCustom';
|
||||||
|
const _keyAutoCheckUpdates = 'autoCheckUpdates';
|
||||||
|
|
||||||
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
|
class AppSettings {
|
||||||
|
static final AppSettings instance = AppSettings();
|
||||||
|
|
||||||
|
final hwdecMode = signal(HwdecMode.auto);
|
||||||
|
final hwdecCustom = signal('');
|
||||||
|
final autoCheckUpdates = signal(true);
|
||||||
|
|
||||||
|
/// mpv `hwdec` value: 'auto', 'no', or the custom value ('auto' when
|
||||||
|
/// custom is blank).
|
||||||
|
late final hwdec = computed(() => switch (hwdecMode.value) {
|
||||||
|
HwdecMode.auto => 'auto',
|
||||||
|
HwdecMode.off => 'no',
|
||||||
|
HwdecMode.custom =>
|
||||||
|
hwdecCustom.value.trim().isEmpty ? 'auto' : hwdecCustom.value.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void>? _load;
|
||||||
|
Box? _box;
|
||||||
|
|
||||||
|
Future<void> ensureLoaded() => _load ??= _loadNow();
|
||||||
|
|
||||||
|
Future<void> _loadNow() async {
|
||||||
|
try {
|
||||||
|
final box = _box = await Hive.openBox(settingsBoxName);
|
||||||
|
final mode = box.get(_keyHwdecMode);
|
||||||
|
hwdecMode.value = HwdecMode.values
|
||||||
|
.where((m) => m.name == mode)
|
||||||
|
.firstOrNull ??
|
||||||
|
HwdecMode.auto;
|
||||||
|
final custom = box.get(_keyHwdecCustom);
|
||||||
|
if (custom is String) hwdecCustom.value = custom;
|
||||||
|
final auto = box.get(_keyAutoCheckUpdates);
|
||||||
|
if (auto is bool) autoCheckUpdates.value = auto;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('load settings failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHwdecMode(HwdecMode mode) {
|
||||||
|
hwdecMode.value = mode;
|
||||||
|
_put(_keyHwdecMode, mode.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setHwdecCustom(String value) {
|
||||||
|
hwdecCustom.value = value;
|
||||||
|
_put(_keyHwdecCustom, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAutoCheckUpdates(bool on) {
|
||||||
|
autoCheckUpdates.value = on;
|
||||||
|
_put(_keyAutoCheckUpdates, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _put(String key, Object value) async {
|
||||||
|
try {
|
||||||
|
final box = _box ??
|
||||||
|
(Hive.isBoxOpen(settingsBoxName) ? Hive.box(settingsBoxName) : null);
|
||||||
|
await box?.put(key, value);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('save setting $key failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ bool localPlayAllowed(RoomBroadcast room, String memberId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Self is ready but out of the set and fresh again: the server's stale
|
||||||
|
/// sweep dropped it from the set during a signal gap and nothing puts it
|
||||||
|
/// back. Re-sending ready restores it without the user pressing Space.
|
||||||
|
bool shouldReassertReady(MemberView? self) =>
|
||||||
|
self != null && self.ready && !self.inSet && !self.stale;
|
||||||
|
|
||||||
class RoomDiff {
|
class RoomDiff {
|
||||||
static const debounceWindow = Duration(seconds: 2);
|
static const debounceWindow = Duration(seconds: 2);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../playback/frame_watchdog.dart';
|
||||||
|
|
||||||
|
/// Fingerprints what the compositor really shows of the video. Place
|
||||||
|
/// [anchor] in the video controls (built in the window and again in the
|
||||||
|
/// fullscreen route); [sample] captures the route around the newest
|
||||||
|
/// mounted anchor at ~200 px wide — the texture included — and hashes its
|
||||||
|
/// centre. This is the only per-presented-frame signal: mpv's own
|
||||||
|
/// counters keep ticking when the Flutter texture is stuck.
|
||||||
|
class ShownFrameProbe {
|
||||||
|
final _anchors = <_AnchorState>[];
|
||||||
|
|
||||||
|
Widget anchor() => _Anchor(probe: this);
|
||||||
|
|
||||||
|
Future<int?> sample() async {
|
||||||
|
for (final a in _anchors.reversed) {
|
||||||
|
if (!a.mounted) continue;
|
||||||
|
final boundary = a.context.findAncestorRenderObjectOfType<RenderRepaintBoundary>();
|
||||||
|
if (boundary == null || !boundary.attached || !boundary.hasSize) return null;
|
||||||
|
final width = boundary.size.width;
|
||||||
|
if (width <= 0) return null;
|
||||||
|
ui.Image? image;
|
||||||
|
try {
|
||||||
|
image = await boundary.toImage(pixelRatio: (200 / width).clamp(0.02, 1.0));
|
||||||
|
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||||
|
if (data == null) return null;
|
||||||
|
return hashCenterPixels(data, image.width, image.height);
|
||||||
|
} catch (_) {
|
||||||
|
// Route not painted (covered) or mid-teardown: no reading.
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
image?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Anchor extends StatefulWidget {
|
||||||
|
final ShownFrameProbe probe;
|
||||||
|
const _Anchor({required this.probe});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Anchor> createState() => _AnchorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnchorState extends State<_Anchor> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.probe._anchors.add(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.probe._anchors.remove(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// True while keyboard focus sits in a text field. The focused node's
|
||||||
|
/// context belongs to the `Focus` that `EditableText` builds, so look for
|
||||||
|
/// an `EditableText` above it (walking FocusNode parents never meets one).
|
||||||
|
bool textInputFocused([FocusNode? node]) {
|
||||||
|
final n = node ?? FocusManager.instance.primaryFocus;
|
||||||
|
return n?.context?.findAncestorWidgetOfExactType<EditableText>() != null;
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
import 'package:watch_together/src/sync/sync_client.dart';
|
||||||
|
|
||||||
Future<void> _poll(
|
Future<void> _poll(
|
||||||
FutureOr<bool> Function() cond,
|
FutureOr<bool> Function() cond,
|
||||||
@@ -56,4 +60,149 @@ void main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'cancel then retry within the reconnect delay dials only once',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
final first = RoomServer(
|
||||||
|
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||||
|
await first.start();
|
||||||
|
final port = first.boundPort;
|
||||||
|
final coord = SessionCoordinator();
|
||||||
|
RoomServer? second;
|
||||||
|
final observer = SyncClient();
|
||||||
|
try {
|
||||||
|
await coord.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1', port: port, secret: 'race', name: 'watcher'));
|
||||||
|
await coord.playback.player.setVolume(0);
|
||||||
|
await first.stop();
|
||||||
|
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||||
|
second = RoomServer(
|
||||||
|
AppConfig(port: port, secret: 'race', name: 'race'));
|
||||||
|
await second.start();
|
||||||
|
var joins = 0;
|
||||||
|
final sub = observer.feeds.listen((f) {
|
||||||
|
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||||
|
});
|
||||||
|
await observer.connect(
|
||||||
|
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||||
|
secret: 'race',
|
||||||
|
name: 'observer');
|
||||||
|
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
||||||
|
coord.cancelReconnect();
|
||||||
|
coord.retry();
|
||||||
|
await _poll(() => !coord.reconnecting.peek() && coord.room.peek() != null,
|
||||||
|
'reconnect to land');
|
||||||
|
await Future.delayed(const Duration(seconds: 4));
|
||||||
|
await sub.cancel();
|
||||||
|
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||||
|
expect(
|
||||||
|
second.hub.engine.members.values
|
||||||
|
.where((m) => m.name == 'watcher')
|
||||||
|
.length,
|
||||||
|
1);
|
||||||
|
} finally {
|
||||||
|
await observer.dispose();
|
||||||
|
await coord.dispose();
|
||||||
|
await second?.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'reconnect restores the server-side ready flag (force-all included)',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
final first = RoomServer(
|
||||||
|
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||||
|
await first.start();
|
||||||
|
final port = first.boundPort;
|
||||||
|
final coord = SessionCoordinator();
|
||||||
|
final friend = SyncClient();
|
||||||
|
RoomServer? second;
|
||||||
|
try {
|
||||||
|
await coord.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
||||||
|
await coord.playback.player.setVolume(0);
|
||||||
|
await friend.connect(
|
||||||
|
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||||
|
secret: 'force',
|
||||||
|
name: 'friend');
|
||||||
|
// Never pressed Space locally; a friend marks everyone ready.
|
||||||
|
friend.sendForce('all', true);
|
||||||
|
await _poll(() => coord.selfMember.peek()?.ready == true,
|
||||||
|
'forced ready to reach watcher');
|
||||||
|
await friend.disconnect();
|
||||||
|
await first.stop();
|
||||||
|
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||||
|
second = RoomServer(
|
||||||
|
AppConfig(port: port, secret: 'force', name: 'force'));
|
||||||
|
await second.start();
|
||||||
|
await _poll(
|
||||||
|
() => second!.hub.engine.members.values
|
||||||
|
.any((m) => m.name == 'watcher' && m.ready),
|
||||||
|
'watcher to come back ready');
|
||||||
|
} finally {
|
||||||
|
await friend.dispose();
|
||||||
|
await coord.dispose();
|
||||||
|
await second?.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'cancel during connect setup aborts without joining',
|
||||||
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
final server = RoomServer(
|
||||||
|
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||||
|
await server.start();
|
||||||
|
final coord = SessionCoordinator();
|
||||||
|
try {
|
||||||
|
final pending = coord.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: server.boundPort,
|
||||||
|
secret: '',
|
||||||
|
name: 'watcher'));
|
||||||
|
await coord.cancelConnect();
|
||||||
|
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
expect(server.hub.engine.members, isEmpty);
|
||||||
|
expect(coord.reconnecting.peek(), isFalse);
|
||||||
|
} finally {
|
||||||
|
await coord.dispose();
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'cancelling a host setup releases the port',
|
||||||
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
Hive.init('/home/foxyz/.claude/jobs/4898998e/tmp/hive_coord_test');
|
||||||
|
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
final port = probe.port;
|
||||||
|
await probe.close();
|
||||||
|
final session = SessionState.instance;
|
||||||
|
try {
|
||||||
|
final pending = session.host(
|
||||||
|
ConnectParams(host: '', port: port, secret: '', name: 'host'),
|
||||||
|
ConnectForm());
|
||||||
|
await session.cancelPending();
|
||||||
|
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||||
|
expect(session.route.peek(), isNot(AppRoute.session));
|
||||||
|
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||||
|
await rebind.close();
|
||||||
|
} finally {
|
||||||
|
await session.coordinator.leave(silent: true);
|
||||||
|
await session.stopServer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/playback/frame_watchdog.dart';
|
||||||
|
|
||||||
|
final _t0 = DateTime.utc(2026, 1, 1);
|
||||||
|
|
||||||
|
/// Feeds one sample per second; [frames]/[pos] give the value at second i.
|
||||||
|
List<bool> _run(
|
||||||
|
FrameWatchdog w,
|
||||||
|
int seconds, {
|
||||||
|
required int? Function(int i) frames,
|
||||||
|
double Function(int i)? pos,
|
||||||
|
bool Function(int i)? eligible,
|
||||||
|
int start = 0,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
for (var i = start; i < start + seconds; i++)
|
||||||
|
w.feed(FrameSample(
|
||||||
|
at: _t0.add(Duration(seconds: i)),
|
||||||
|
position: pos?.call(i) ?? i.toDouble(),
|
||||||
|
frames: frames(i),
|
||||||
|
eligible: eligible?.call(i) ?? true,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('hashes tell frames apart and ignore the edges', () {
|
||||||
|
final a = ByteData(100 * 60 * 4);
|
||||||
|
final b = ByteData(100 * 60 * 4);
|
||||||
|
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
||||||
|
b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area
|
||||||
|
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
||||||
|
b.setUint8((30 * 100 + 50) * 4, 255); // centre: video
|
||||||
|
expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60)));
|
||||||
|
final raw = Uint8List(1 << 20);
|
||||||
|
final h = hashSpread(raw);
|
||||||
|
raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1;
|
||||||
|
expect(hashSpread(raw), isNot(h));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frames advancing never fires', () {
|
||||||
|
final w = FrameWatchdog();
|
||||||
|
expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frames stuck while position advances fires once after ~3 s', () {
|
||||||
|
final w = FrameWatchdog();
|
||||||
|
final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150);
|
||||||
|
expect(fired.where((f) => f).length, 1);
|
||||||
|
expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause, buffering, missing data and seeks do not fire', () {
|
||||||
|
final w = FrameWatchdog();
|
||||||
|
// Paused / buffering: not eligible.
|
||||||
|
expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false),
|
||||||
|
everyElement(isFalse));
|
||||||
|
// No video output (headless / audio-only).
|
||||||
|
expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse));
|
||||||
|
// Position stuck too (network stall): not a frozen picture.
|
||||||
|
expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20),
|
||||||
|
everyElement(isFalse));
|
||||||
|
// Seeking every 2 s with frames stuck in between: jumps reset it.
|
||||||
|
expect(
|
||||||
|
_run(w, 12,
|
||||||
|
frames: (_) => 100,
|
||||||
|
pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9,
|
||||||
|
start: 30),
|
||||||
|
everyElement(isFalse));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no reload loop: re-arms only after 10 s of healthy frames', () {
|
||||||
|
final w = FrameWatchdog();
|
||||||
|
expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1);
|
||||||
|
w.reset(); // new player
|
||||||
|
// Brief healthy spell, then stuck again: must not fire.
|
||||||
|
expect(
|
||||||
|
_run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10)
|
||||||
|
.where((f) => f)
|
||||||
|
.length,
|
||||||
|
0);
|
||||||
|
// Healthy for 10 s+, then stuck: fires again.
|
||||||
|
w.reset();
|
||||||
|
expect(
|
||||||
|
_run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30)
|
||||||
|
.where((f) => f)
|
||||||
|
.length,
|
||||||
|
1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still scene: dismiss re-arms after another full stall', () {
|
||||||
|
final w = FrameWatchdog();
|
||||||
|
final fired = <int>[];
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
if (w.feed(FrameSample(
|
||||||
|
at: _t0.add(Duration(seconds: i)),
|
||||||
|
position: i.toDouble(),
|
||||||
|
frames: 5,
|
||||||
|
eligible: true))) {
|
||||||
|
fired.add(i);
|
||||||
|
w.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(fired, [3, 6, 9, 12, 15, 18]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/screens/home/home_screen.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
import 'package:watch_together/src/state/settings.dart';
|
||||||
|
import 'package:watch_together/src/update/update_controller.dart';
|
||||||
|
|
||||||
|
RecentServer join(String host, String port, [String secret = '']) =>
|
||||||
|
RecentServer(hosted: false, host: host, port: port, secret: secret);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('recent servers', () {
|
||||||
|
test('newest first, deduped by server, capped', () {
|
||||||
|
var list = <RecentServer>[];
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
list = pushRecent(list, join('10.0.0.$i', '8080'));
|
||||||
|
}
|
||||||
|
expect(list.length, maxRecentServers);
|
||||||
|
expect(list.first.host, '10.0.0.7');
|
||||||
|
list = pushRecent(list, join('10.0.0.4', '8080', 'pw'));
|
||||||
|
expect(list.length, maxRecentServers);
|
||||||
|
expect(list.first.secret, 'pw');
|
||||||
|
expect(list.where((r) => r.host == '10.0.0.4').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosted entries are one per port, separate from joins', () {
|
||||||
|
var list = <RecentServer>[];
|
||||||
|
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080'));
|
||||||
|
list = pushRecent(list, join('127.0.0.1', '8080'));
|
||||||
|
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080', secret: 'x'));
|
||||||
|
expect(list.length, 2);
|
||||||
|
expect(list.first.secret, 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips and tolerates junk', () {
|
||||||
|
final list = [join('a.example', '9000', 's'), const RecentServer(hosted: true, host: '', port: '8080')];
|
||||||
|
final back = decodeRecents(encodeRecents(list));
|
||||||
|
expect(back.map((r) => r.key), list.map((r) => r.key));
|
||||||
|
expect(back.first.secret, 's');
|
||||||
|
expect(back.last.hosted, isTrue);
|
||||||
|
expect(decodeRecents('not json'), isEmpty);
|
||||||
|
expect(decodeRecents(42), isEmpty);
|
||||||
|
expect(decodeRecents('[{"host":1},{"host":"h","port":"1"}]').single.host, 'h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old single form migrates to one recent unless it was localhost', () {
|
||||||
|
expect(migrateRecents(hadForm: true, host: 'friend.example', port: '8080', secret: 'pw').single.secret, 'pw');
|
||||||
|
expect(migrateRecents(hadForm: true, host: '127.0.0.1', port: '8080', secret: ''), isEmpty);
|
||||||
|
expect(migrateRecents(hadForm: false, host: 'friend.example', port: '8080', secret: ''), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hwdec value follows mode', () {
|
||||||
|
final s = AppSettings();
|
||||||
|
expect(s.hwdec.value, 'auto');
|
||||||
|
s.setHwdecMode(HwdecMode.off);
|
||||||
|
expect(s.hwdec.value, 'no');
|
||||||
|
s.setHwdecMode(HwdecMode.custom);
|
||||||
|
expect(s.hwdec.value, 'auto');
|
||||||
|
s.setHwdecCustom(' vaapi ');
|
||||||
|
expect(s.hwdec.value, 'vaapi');
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ThemeData.dark(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: HomeShell(updates: UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('home shows both cards side by side on a wide window', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 720));
|
||||||
|
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||||
|
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||||
|
expect(join.top, host.top);
|
||||||
|
expect(join.height, host.height);
|
||||||
|
expect(join.right, lessThan(host.left));
|
||||||
|
expect(find.text('No password — anyone can join'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('cards stack on a narrow window', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(560, 900));
|
||||||
|
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||||
|
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||||
|
expect(join.bottom, lessThan(host.top));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('join without a name errors inline in the join card', (tester) async {
|
||||||
|
SessionState.instance.lastForm.name = '';
|
||||||
|
await pumpShell(tester, const Size(1280, 720));
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('join-card')),
|
||||||
|
matching: find.textContaining('display name', findRichText: true),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rail switches to settings, integrations and about', (tester) async {
|
||||||
|
await pumpShell(tester, const Size(1280, 900));
|
||||||
|
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||||
|
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||||
|
expect(find.text('Toggle ready'), findsOneWidget);
|
||||||
|
await tester.tap(find.byIcon(Icons.extension_outlined));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Jellyfin'), findsOneWidget);
|
||||||
|
await tester.tap(find.byIcon(Icons.info_outline));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:watch_together/src/session/idle.dart';
|
||||||
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
import 'package:watch_together/src/sync/room_diff.dart';
|
||||||
|
|
||||||
|
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
|
||||||
|
MemberView(
|
||||||
|
id: 'm1',
|
||||||
|
name: 'me',
|
||||||
|
avatar: '',
|
||||||
|
ready: ready,
|
||||||
|
inSet: inSet,
|
||||||
|
buffered: 5,
|
||||||
|
stale: stale,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('IdleTracker', () {
|
||||||
|
final t0 = DateTime.utc(2026, 1, 1, 12);
|
||||||
|
|
||||||
|
List<IdleEvent> run(IdleTracker idle, DateTime from, Duration span,
|
||||||
|
{required bool playing, bool selfReady = true}) {
|
||||||
|
final out = <IdleEvent>[];
|
||||||
|
for (var s = 0; s <= span.inSeconds; s++) {
|
||||||
|
final e = idle.tick(from.add(Duration(seconds: s)),
|
||||||
|
playing: playing, selfReady: selfReady);
|
||||||
|
if (e != IdleEvent.none) out.add(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('long watch then pause: no instant warn+unready', () {
|
||||||
|
final idle = IdleTracker(t0);
|
||||||
|
// 15 min of playback without any input.
|
||||||
|
expect(run(idle, t0, const Duration(minutes: 15), playing: true), isEmpty);
|
||||||
|
final paused = t0.add(const Duration(minutes: 15));
|
||||||
|
// First minute after the pause: nothing fires.
|
||||||
|
expect(run(idle, paused, const Duration(minutes: 1), playing: false),
|
||||||
|
isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause measures from when playback stopped', () {
|
||||||
|
final idle = IdleTracker(t0);
|
||||||
|
run(idle, t0, const Duration(minutes: 15), playing: true);
|
||||||
|
// Last playing tick was at +15 min; count seconds from there.
|
||||||
|
final paused = t0.add(const Duration(minutes: 15));
|
||||||
|
final events = <(int, IdleEvent)>[];
|
||||||
|
for (var s = 1; s <= 11 * 60; s++) {
|
||||||
|
final e = idle.tick(paused.add(Duration(seconds: s)),
|
||||||
|
playing: false, selfReady: true);
|
||||||
|
if (e != IdleEvent.none) events.add((s, e));
|
||||||
|
}
|
||||||
|
expect(events, [(8 * 60, IdleEvent.warn), (10 * 60, IdleEvent.unready)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unready always trails the warning by 2 min', () {
|
||||||
|
final idle = IdleTracker(t0);
|
||||||
|
// Idle 20 min while unready (nothing to lose, nothing fires)...
|
||||||
|
expect(
|
||||||
|
run(idle, t0, const Duration(minutes: 20),
|
||||||
|
playing: false, selfReady: false),
|
||||||
|
isEmpty);
|
||||||
|
// ...then someone force-readies us: warn now, unready 2 min later.
|
||||||
|
final forced = t0.add(const Duration(minutes: 20, seconds: 1));
|
||||||
|
expect(idle.tick(forced, playing: false, selfReady: true), IdleEvent.warn);
|
||||||
|
expect(
|
||||||
|
idle.tick(forced.add(const Duration(seconds: 1)),
|
||||||
|
playing: false, selfReady: true),
|
||||||
|
IdleEvent.none);
|
||||||
|
expect(
|
||||||
|
idle.tick(forced.add(const Duration(minutes: 2)),
|
||||||
|
playing: false, selfReady: true),
|
||||||
|
IdleEvent.unready);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('input resets the clock and the warning', () {
|
||||||
|
final idle = IdleTracker(t0);
|
||||||
|
expect(idle.tick(t0.add(const Duration(minutes: 8)),
|
||||||
|
playing: false, selfReady: true), IdleEvent.warn);
|
||||||
|
idle.activity(t0.add(const Duration(minutes: 9)));
|
||||||
|
expect(idle.tick(t0.add(const Duration(minutes: 11)),
|
||||||
|
playing: false, selfReady: true), IdleEvent.none);
|
||||||
|
expect(idle.tick(t0.add(const Duration(minutes: 17)),
|
||||||
|
playing: false, selfReady: true), IdleEvent.warn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('linkDisplayName', () {
|
||||||
|
test('drops query (api_key) and fragment, decodes last segment', () {
|
||||||
|
expect(
|
||||||
|
linkDisplayName(
|
||||||
|
'https://jf.example.com/Videos/abc/My%20Movie.mkv?static=true&api_key=SECRET#t=5'),
|
||||||
|
'My Movie.mkv');
|
||||||
|
});
|
||||||
|
test('trailing slash and bare host', () {
|
||||||
|
expect(linkDisplayName('http://host.lan/media/show/'), 'show');
|
||||||
|
expect(linkDisplayName('http://host.lan/?api_key=SECRET'), 'host.lan');
|
||||||
|
});
|
||||||
|
test('never contains the key', () {
|
||||||
|
final n = linkDisplayName(
|
||||||
|
'https://jf.example.com/Items/1/Download?api_key=SECRET');
|
||||||
|
expect(n, 'Download');
|
||||||
|
expect(n.contains('SECRET'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shouldReassertReady', () {
|
||||||
|
test('ready, fresh, out of set (after stale sweep) -> reassert', () {
|
||||||
|
expect(shouldReassertReady(_self(inSet: false)), isTrue);
|
||||||
|
});
|
||||||
|
test('normal states -> no', () {
|
||||||
|
expect(shouldReassertReady(null), isFalse);
|
||||||
|
expect(shouldReassertReady(_self()), isFalse);
|
||||||
|
expect(shouldReassertReady(_self(ready: false, inSet: false)), isFalse);
|
||||||
|
expect(shouldReassertReady(_self(ready: false)), isFalse);
|
||||||
|
expect(shouldReassertReady(_self(inSet: false, stale: true)), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/widgets/text_focus.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Regression: the old FocusNode-parent walk never met EditableText, so
|
||||||
|
// session shortcuts (Space, arrows, F) fired while typing in chat.
|
||||||
|
testWidgets('text field focus is detected, buttons are not', (tester) async {
|
||||||
|
final field = FocusNode();
|
||||||
|
final button = FocusNode();
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Column(children: [
|
||||||
|
TextField(focusNode: field),
|
||||||
|
TextButton(focusNode: button, onPressed: () {}, child: const Text('b')),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
expect(textInputFocused(), isFalse);
|
||||||
|
|
||||||
|
field.requestFocus();
|
||||||
|
await tester.pump();
|
||||||
|
expect(field.hasPrimaryFocus, isTrue);
|
||||||
|
expect(textInputFocused(), isTrue);
|
||||||
|
|
||||||
|
button.requestFocus();
|
||||||
|
await tester.pump();
|
||||||
|
expect(textInputFocused(), isFalse);
|
||||||
|
|
||||||
|
field.dispose();
|
||||||
|
button.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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';
|
||||||
|
import 'package:watch_together/src/sync/room_diff.dart';
|
||||||
|
|
||||||
|
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
||||||
|
|
||||||
|
Future<void> _poll(
|
||||||
|
FutureOr<bool> 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'reload video: new player, same media, resynced, ready untouched; '
|
||||||
|
'leave disposes the player',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
|
() async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
final server = RoomServer(const AppConfig(
|
||||||
|
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||||
|
await server.start();
|
||||||
|
final c = SessionCoordinator();
|
||||||
|
try {
|
||||||
|
await c.connect(ConnectParams(
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: server.boundPort,
|
||||||
|
secret: 'reload',
|
||||||
|
name: 'solo'));
|
||||||
|
await c.playback.player.setVolume(0);
|
||||||
|
await _poll(() => c.room.peek() != null, 'room state');
|
||||||
|
c.toggleReady();
|
||||||
|
await _poll(
|
||||||
|
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
||||||
|
'solo playback past 2 s');
|
||||||
|
|
||||||
|
final old = c.playback.player;
|
||||||
|
final gen = c.playback.generation.peek();
|
||||||
|
await c.reloadVideo();
|
||||||
|
expect(identical(c.playback.player, old), isFalse);
|
||||||
|
expect(c.playback.generation.peek(), gen + 1);
|
||||||
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
|
|
||||||
|
await _poll(() {
|
||||||
|
final r = c.room.peek();
|
||||||
|
if (r == null || !c.playback.player.state.playing) return false;
|
||||||
|
final want = roomPositionAt(r, DateTime.now().toUtc());
|
||||||
|
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
||||||
|
c.playback.positionSecs > 2;
|
||||||
|
}, 'reloaded player playing at the synced position');
|
||||||
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
|
expect(c.room.peek()?.transport, 'playing');
|
||||||
|
|
||||||
|
await c.leave();
|
||||||
|
expect(c.playback.alive.peek(), isFalse);
|
||||||
|
} finally {
|
||||||
|
await c.dispose();
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user