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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
The Flutter desktop app (the only binary users run). Layout and rules:
|
||||
[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md). Build and test:
|
||||
[../../docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.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/welcome_screen.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
|
||||
class WatchTogetherApp extends StatelessWidget {
|
||||
@@ -13,13 +13,12 @@ class WatchTogetherApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final session = SessionState.instance;
|
||||
return MaterialApp(
|
||||
title: 'Watch Together',
|
||||
title: appName,
|
||||
theme: ThemeData.dark(),
|
||||
home: SignalBuilder(
|
||||
builder: (context) {
|
||||
return switch (session.route.value) {
|
||||
AppRoute.welcome => const WelcomeScreen(),
|
||||
AppRoute.connect => const ConnectScreen(),
|
||||
AppRoute.home => const HomeScreen(),
|
||||
AppRoute.session => const SessionScreen(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -7,10 +7,13 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/updater.dart';
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
if (args.contains('--upgrade')) {
|
||||
exitCode = await runUpgrade();
|
||||
exit(exitCode);
|
||||
@@ -39,19 +42,22 @@ Future<void> main(List<String> args) async {
|
||||
);
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
windowManager.addListener(_CloseGuard());
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1280, 720),
|
||||
minimumSize: Size(160, 90),
|
||||
center: true,
|
||||
backgroundColor: Color(0xFF000000),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
title: 'Watch Together',
|
||||
title: appName,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
});
|
||||
await Hive.initFlutter();
|
||||
await AppSettings.instance.ensureLoaded();
|
||||
try {
|
||||
await SessionState.instance.loadForm();
|
||||
} catch (e, st) {
|
||||
@@ -67,3 +73,22 @@ Future<void> runHeadless(List<String> args) async {
|
||||
await ProcessSignal.sigint.watch().first;
|
||||
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 'package:flutter/widgets.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 '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 {
|
||||
late final Player player;
|
||||
bool _ready = false;
|
||||
Player? _player;
|
||||
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 {
|
||||
player = Player(
|
||||
if (_player != null) return;
|
||||
_player = Player(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: 64 * 1024 * 1024,
|
||||
libass: true,
|
||||
vo: Platform.environment['SYNC_RELAY_VO'],
|
||||
));
|
||||
_ready = true;
|
||||
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 {
|
||||
@@ -32,14 +102,81 @@ class PlaybackController {
|
||||
Future<void> _pinBufferProps() async {
|
||||
await _pin('demuxer-readahead-secs', '10');
|
||||
await _pin('cache-secs', '10');
|
||||
final hwdec = Platform.environment['SYNC_RELAY_HWDEC'];
|
||||
if (hwdec != null && hwdec.isNotEmpty) {
|
||||
final hwdec = _hwdecOverride;
|
||||
if (hwdec != null) {
|
||||
await _pin('hwdec', hwdec);
|
||||
}
|
||||
await _pin('stream-lavf-o',
|
||||
'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 {
|
||||
if (!_ready) return;
|
||||
try {
|
||||
@@ -50,20 +187,28 @@ class PlaybackController {
|
||||
}
|
||||
}
|
||||
|
||||
double get positionSecs =>
|
||||
player.state.position.inMilliseconds / 1000.0;
|
||||
double get positionSecs {
|
||||
final p = _player;
|
||||
if (p == null) return 0;
|
||||
return p.state.position.inMilliseconds / 1000.0;
|
||||
}
|
||||
|
||||
double get bufferedSecsAhead {
|
||||
final ahead =
|
||||
(player.state.buffer - player.state.position).inMilliseconds / 1000.0;
|
||||
final p = _player;
|
||||
if (p == null) return 0;
|
||||
final ahead = (p.state.buffer - p.state.position).inMilliseconds / 1000.0;
|
||||
if (ahead.isNaN || ahead < 0) return 0;
|
||||
return ahead;
|
||||
}
|
||||
|
||||
double get durationSecs =>
|
||||
player.state.duration.inMilliseconds / 1000.0;
|
||||
double get durationSecs {
|
||||
final p = _player;
|
||||
if (p == null) return 0;
|
||||
return p.state.duration.inMilliseconds / 1000.0;
|
||||
}
|
||||
|
||||
Future<void> apply(List<PlayerAction> actions) async {
|
||||
if (!_ready) return;
|
||||
for (final action in actions) {
|
||||
try {
|
||||
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 {
|
||||
_ready = false;
|
||||
final p = _player;
|
||||
if (p == null) return;
|
||||
_watchTimer?.cancel();
|
||||
_watchTimer = null;
|
||||
_player = null;
|
||||
_video = null;
|
||||
alive.value = false;
|
||||
await _settleUi();
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '../widgets/dialogs.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_drag_layer.dart';
|
||||
|
||||
@@ -20,8 +22,12 @@ class SessionScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SessionScreenState extends State<SessionScreen> {
|
||||
VideoController? _video;
|
||||
final _videoKey = GlobalKey<VideoState>();
|
||||
// One key per player generation: a reloaded player needs a fresh
|
||||
// `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 _confirmLeave = signal(false);
|
||||
final _isFullscreen = signal(false);
|
||||
@@ -31,6 +37,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
final _swapLink = TextEditingController();
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopSheetWatch;
|
||||
late final void Function() _stopPlayerWatch;
|
||||
late final FocusNode _noFocus;
|
||||
|
||||
@override
|
||||
@@ -38,11 +45,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
super.initState();
|
||||
final session = SessionState.instance;
|
||||
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
_video = VideoController(session.coordinator.playback.player);
|
||||
session.coordinator.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
_stopKickWatch = effect(() {
|
||||
if (session.coordinator.kickReason.value != null) {
|
||||
session.route.value = AppRoute.connect;
|
||||
session.route.value = AppRoute.home;
|
||||
}
|
||||
});
|
||||
_stopSheetWatch = effect(() {
|
||||
@@ -50,6 +57,35 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
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() {
|
||||
@@ -65,8 +101,11 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
final playback = SessionState.instance.coordinator.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopKickWatch();
|
||||
_stopSheetWatch();
|
||||
_stopPlayerWatch();
|
||||
_chat.dispose();
|
||||
_chatFocus.dispose();
|
||||
_swapLink.dispose();
|
||||
@@ -74,25 +113,21 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _editing {
|
||||
var n = FocusManager.instance.primaryFocus;
|
||||
while (n != null) {
|
||||
final w = n.context?.widget;
|
||||
if (w is EditableText) return true;
|
||||
n = n.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _globalKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
||||
if (_editing) return false;
|
||||
if (textInputFocused()) return false;
|
||||
final now = DateTime.now().toUtc();
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
}
|
||||
final coord = SessionState.instance.coordinator;
|
||||
if (event.logicalKey == LogicalKeyboardKey.keyR &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_reloadVideo();
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.space) {
|
||||
_lastKeyAt = now;
|
||||
coord.toggleReady();
|
||||
@@ -174,6 +209,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
const MaterialDesktopVolumeButton(),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_MarkAllReadyControlButton(focusNode: _noFocus),
|
||||
_ReloadVideoControlButton(focusNode: _noFocus, onPressed: _reloadVideo),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
const MaterialDesktopFullscreenButton(),
|
||||
@@ -183,16 +219,27 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
descendantsAreTraversable: false,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: _video == null
|
||||
? const Center(
|
||||
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
||||
)
|
||||
: MaterialDesktopVideoControlsTheme(
|
||||
// Player lifetime = session (and each "Reload video"): no live
|
||||
// player ⇒ no `Video`, so no texture outlives its player.
|
||||
body: SignalBuilder(
|
||||
builder: (context) {
|
||||
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,
|
||||
fullscreen: controlsTheme,
|
||||
child: Video(
|
||||
key: _videoKey,
|
||||
controller: _video!,
|
||||
controller: playback.video,
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: _sessionControls,
|
||||
onEnterFullscreen: () async {
|
||||
@@ -204,7 +251,9 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
await defaultExitNativeFullscreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -212,6 +261,8 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
Widget _sessionControls(VideoState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Frozen-picture watchdog samples the route around this anchor.
|
||||
_frameProbe.anchor(),
|
||||
MaterialDesktopVideoControls(state),
|
||||
_healthOverlay(),
|
||||
_waitingOverlay(),
|
||||
@@ -453,7 +504,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: AlertDialog(
|
||||
title: const Text('Leave room?'),
|
||||
title: const Text('Leave server?'),
|
||||
content: const Text('You can rejoin right away.'),
|
||||
actions: [
|
||||
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 {
|
||||
final String text;
|
||||
const _ToastCard({super.key, required this.text});
|
||||
@@ -592,7 +667,7 @@ class _SheetPanel extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
_StatusChip(playing: playing, hasRoom: r != null),
|
||||
],
|
||||
@@ -663,11 +738,19 @@ class _SheetPanel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_horiz),
|
||||
onPressed: () {
|
||||
coord.swapLink(swapLink.text.trim());
|
||||
swapLink.clear();
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: swapLink,
|
||||
builder: (context, value, _) {
|
||||
final link = value.text.trim();
|
||||
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(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Leave room'),
|
||||
label: const Text('Leave server'),
|
||||
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/sync_client.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 {
|
||||
final String host;
|
||||
@@ -79,9 +103,17 @@ class SessionCoordinator {
|
||||
|
||||
bool _manualClose = false;
|
||||
bool _playerReady = false;
|
||||
bool _warnedIdle = false;
|
||||
bool _wasPlayAllowed = false;
|
||||
// Last ready flag the server reported for self; restored after a
|
||||
// reconnect (the server forgets dropped members).
|
||||
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;
|
||||
String? _openUrl;
|
||||
String _wsHost = '';
|
||||
@@ -91,16 +123,20 @@ class SessionCoordinator {
|
||||
DateTime? _pendingSeekAt;
|
||||
int _seekRetries = 0;
|
||||
DateTime? _lastRoomAt;
|
||||
DateTime _lastInput = DateTime.now().toUtc();
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
bool _everConnected = false;
|
||||
|
||||
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 {
|
||||
if (_playerReady) return;
|
||||
try {
|
||||
playback.onFrozen = () => unawaited(reloadVideo(auto: true));
|
||||
await playback.init();
|
||||
_playerReady = true;
|
||||
} catch (e, st) {
|
||||
@@ -111,13 +147,56 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void markInput() {
|
||||
_lastInput = DateTime.now().toUtc();
|
||||
_warnedIdle = false;
|
||||
bool _reloadingVideo = 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 {
|
||||
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;
|
||||
_manualClose = false;
|
||||
kickReason.value = null;
|
||||
@@ -127,6 +206,7 @@ class SessionCoordinator {
|
||||
_mediaId = -1;
|
||||
_openUrl = null;
|
||||
_lastReady = false;
|
||||
_restoringReady = false;
|
||||
_wasPlayAllowed = false;
|
||||
_everConnected = false;
|
||||
markInput();
|
||||
@@ -134,6 +214,7 @@ class SessionCoordinator {
|
||||
feedLog.value = [];
|
||||
toasts.value = [];
|
||||
await initPlayer();
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
health.value =
|
||||
const HealthStatus(HealthSeverity.info, 'connecting…');
|
||||
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
|
||||
@@ -144,6 +225,8 @@ class SessionCoordinator {
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
);
|
||||
// Whoever bumped the epoch (cancel/leave) also closed the socket.
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
@@ -157,10 +240,14 @@ class SessionCoordinator {
|
||||
}
|
||||
if (params.linkUrl.isNotEmpty) {
|
||||
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();
|
||||
} catch (e) {
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
debugPrint('connect failed: $e');
|
||||
health.value =
|
||||
HealthStatus(HealthSeverity.error, _friendlyError(e));
|
||||
@@ -208,6 +295,7 @@ class SessionCoordinator {
|
||||
final now = DateTime.now().toUtc();
|
||||
_lastRoomAt = now;
|
||||
room.value = r;
|
||||
_trackSelfReady(r, now);
|
||||
if (r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
var url = roomMediaUrl(r);
|
||||
@@ -257,6 +345,25 @@ class SessionCoordinator {
|
||||
_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) {
|
||||
if (r.transport == 'playing') {
|
||||
starving.value = null;
|
||||
@@ -335,16 +442,22 @@ class SessionCoordinator {
|
||||
Future<void> _onKicked(String reason) async {
|
||||
kickReason.value = reason;
|
||||
_manualClose = true;
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
reconnecting.value = false;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
// Stop, not pause: a paused player keeps filling its cache from the
|
||||
// host's media URL.
|
||||
_openUrl = null;
|
||||
try {
|
||||
await playback.apply(const [TransportAction(false)]);
|
||||
await playback.stop();
|
||||
} catch (_) {}
|
||||
await sync.disconnect();
|
||||
health.value = HealthStatus(
|
||||
HealthSeverity.error,
|
||||
reason.isEmpty ? 'kicked from room' : 'kicked: $reason');
|
||||
reason.isEmpty ? 'kicked from server' : 'kicked: $reason');
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
@@ -385,17 +498,14 @@ class SessionCoordinator {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
final idleSecs = now.difference(_lastInput).inSeconds;
|
||||
// Steady playback counts as activity: only warn/unready when HELD.
|
||||
// While the room is PLAYING the ready flag gates nothing, so an
|
||||
// idle viewer must never lose it mid-watch.
|
||||
if (room.peek()?.transport != 'playing') {
|
||||
// No warning when there is nothing to lose: the 10-min unready
|
||||
// only fires while self is ready, so the 8-min warning must not
|
||||
// fire while unready either.
|
||||
final selfReady = selfMember.peek()?.ready ?? false;
|
||||
if (selfReady && idleSecs >= 8 * 60 && !_warnedIdle) {
|
||||
_warnedIdle = true;
|
||||
// No warning when there is nothing to lose (unready), and none while
|
||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||
// activity so a pause after a long watch starts a fresh idle clock.
|
||||
final event = _idle.tick(now,
|
||||
playing: room.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
case IdleEvent.warn:
|
||||
_toastId++;
|
||||
toasts.value = [
|
||||
...toasts.peek(),
|
||||
@@ -405,15 +515,11 @@ class SessionCoordinator {
|
||||
at: now)
|
||||
];
|
||||
unawaited(sfx.play(const SfxCue(SfxKind.warn)));
|
||||
}
|
||||
if (idleSecs >= 10 * 60) {
|
||||
final self = selfMember.peek();
|
||||
if (self != null && self.ready) {
|
||||
_lastReady = false;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
markInput();
|
||||
}
|
||||
}
|
||||
case IdleEvent.unready:
|
||||
_lastReady = false;
|
||||
sync.sendReady(false, source: 'auto');
|
||||
case IdleEvent.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +565,9 @@ class SessionCoordinator {
|
||||
|
||||
void toggleReady() {
|
||||
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();
|
||||
_lastReady = next;
|
||||
sync.sendReady(next, source: 'manual');
|
||||
@@ -506,8 +614,8 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> swapLink(String url) async {
|
||||
markInput();
|
||||
sync.sendSwap(
|
||||
HelloMedia(name: url, size: 0, duration: 0, url: url));
|
||||
sync.sendSwap(HelloMedia(
|
||||
name: linkDisplayName(url), size: 0, duration: 0, url: url));
|
||||
}
|
||||
|
||||
void _scheduleReconnect({bool manual = false}) {
|
||||
@@ -515,50 +623,79 @@ class SessionCoordinator {
|
||||
if (!manual && !_everConnected) return;
|
||||
if (reconnecting.peek()) return;
|
||||
reconnecting.value = true;
|
||||
Future.delayed(const Duration(milliseconds: 1500), () async {
|
||||
if (_manualClose || kickReason.peek() != null) {
|
||||
reconnecting.value = false;
|
||||
return;
|
||||
final epoch = _epoch;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(
|
||||
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 (params == null) {
|
||||
reconnecting.value = false;
|
||||
return;
|
||||
if (stale()) return;
|
||||
// The server forgot the dropped member; restore the ready flag it
|
||||
// last reported (not a local guess: others may have forced it).
|
||||
if (wantReady) {
|
||||
_lastReady = true;
|
||||
_restoringReady = true;
|
||||
sync.sendReady(true, source: 'manual');
|
||||
}
|
||||
try {
|
||||
health.value = const HealthStatus(
|
||||
HealthSeverity.info, 'reconnecting…');
|
||||
final wsUri = Uri.parse('ws://${params.host}:${params.port}/sync');
|
||||
final welcome = await sync.connect(
|
||||
wsUri: wsUri,
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
);
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty && url != _openUrl) {
|
||||
await _openMedia(url);
|
||||
}
|
||||
if (_lastReady) sync.sendReady(true, source: 'manual');
|
||||
_startTicker();
|
||||
} catch (e) {
|
||||
debugPrint('reconnect failed: $e');
|
||||
reconnecting.value = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
});
|
||||
_startTicker();
|
||||
} catch (e) {
|
||||
if (stale()) return;
|
||||
debugPrint('reconnect failed: $e');
|
||||
reconnecting.value = false;
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void _invalidateAttempts() {
|
||||
_epoch++;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
void cancelReconnect() {
|
||||
_manualClose = true;
|
||||
_invalidateAttempts();
|
||||
reconnecting.value = false;
|
||||
// Abort an attempt already dialling (no live session while retrying).
|
||||
unawaited(sync.disconnect().catchError((_) {}));
|
||||
}
|
||||
|
||||
Future<void> cancelConnect() async {
|
||||
_manualClose = true;
|
||||
_invalidateAttempts();
|
||||
reconnecting.value = false;
|
||||
try {
|
||||
await sync.disconnect();
|
||||
@@ -568,6 +705,7 @@ class SessionCoordinator {
|
||||
|
||||
void retry() {
|
||||
if (kickReason.peek() != null || _params == null) return;
|
||||
_invalidateAttempts();
|
||||
_manualClose = false;
|
||||
reconnecting.value = false;
|
||||
health.value =
|
||||
@@ -578,7 +716,7 @@ class SessionCoordinator {
|
||||
String _friendlyError(Object e) {
|
||||
final msg = '$e';
|
||||
if (msg.contains('proto mismatch')) return 'host runs a different version — update the app';
|
||||
if (msg.contains('wrong secret')) return 'wrong room password (401)';
|
||||
if (msg.contains('wrong secret')) return 'wrong server password (401)';
|
||||
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
|
||||
return 'host offline — check address and port';
|
||||
}
|
||||
@@ -586,12 +724,20 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
Future<void> leave({bool silent = false}) async {
|
||||
_invalidateAttempts();
|
||||
await _teardown(silent: silent);
|
||||
}
|
||||
|
||||
Future<void> _teardown({bool silent = false}) async {
|
||||
_manualClose = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
reconnecting.value = false;
|
||||
_playerReady = false;
|
||||
try {
|
||||
await playback.stop();
|
||||
await playback.dispose();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await sync.disconnect();
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -9,14 +12,21 @@ import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
|
||||
enum AppRoute { welcome, connect, session }
|
||||
enum AppRoute {
|
||||
home,
|
||||
session,
|
||||
}
|
||||
|
||||
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 {
|
||||
String host;
|
||||
String port;
|
||||
String secret;
|
||||
String hostPort;
|
||||
String hostSecret;
|
||||
String name;
|
||||
String avatarPath;
|
||||
String mediaPath;
|
||||
@@ -25,36 +35,137 @@ class ConnectForm {
|
||||
this.host = '127.0.0.1',
|
||||
this.port = '8080',
|
||||
this.secret = '',
|
||||
this.hostPort = '8080',
|
||||
this.hostSecret = '',
|
||||
this.name = '',
|
||||
this.avatarPath = '',
|
||||
this.mediaPath = '',
|
||||
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 {
|
||||
static final SessionState instance = SessionState._();
|
||||
SessionState._();
|
||||
|
||||
final route = signal(AppRoute.welcome);
|
||||
final connectMode = signal(ConnectMode.host);
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final room = signal<RoomBroadcast?>(null);
|
||||
final SfxService sfx = const NoopSfxService();
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
final lastForm = ConnectForm();
|
||||
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 {
|
||||
final box = await openConnectFormBox();
|
||||
lastForm
|
||||
..host = readString(box, connectKeyHost, '127.0.0.1')
|
||||
..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, '')
|
||||
..avatarPath = readString(box, connectKeyAvatarPath, '')
|
||||
..mediaPath = readString(box, connectKeyMediaPath, '')
|
||||
..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 {
|
||||
@@ -63,61 +174,117 @@ class SessionState {
|
||||
connectKeyHost: lastForm.host,
|
||||
connectKeyPort: lastForm.port,
|
||||
connectKeySecret: lastForm.secret,
|
||||
connectKeyHostPort: lastForm.hostPort,
|
||||
connectKeyHostSecret: lastForm.hostSecret,
|
||||
connectKeyName: lastForm.name,
|
||||
connectKeyAvatarPath: lastForm.avatarPath,
|
||||
connectKeyMediaPath: lastForm.mediaPath,
|
||||
connectKeyLinkUrl: lastForm.linkUrl,
|
||||
connectKeyRecents: encodeRecents(recents.peek()),
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> loadAvatar(String path) => encodeAvatar(path);
|
||||
|
||||
void goConnect(ConnectMode mode) {
|
||||
connectMode.value = mode;
|
||||
route.value = AppRoute.connect;
|
||||
/// Called by Home after a host/join got through.
|
||||
Future<void> rememberServer(RecentServer server) async {
|
||||
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 {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
status.value = const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
await stopServer();
|
||||
final server = RoomServer(AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'room',
|
||||
mediaPath: params.mediaPath,
|
||||
));
|
||||
try {
|
||||
await server.start();
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||
rethrow;
|
||||
}
|
||||
_server = server;
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
linkUrl: params.linkUrl,
|
||||
));
|
||||
route.value = AppRoute.session;
|
||||
try {
|
||||
await saveForm();
|
||||
} catch (e, st) {
|
||||
debugPrint('save connect form failed: $e\n$st');
|
||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
await stopServer();
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
final server = RoomServer(AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'room',
|
||||
mediaPath: params.mediaPath,
|
||||
));
|
||||
try {
|
||||
await server.start();
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
status.value = HealthStatus(
|
||||
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||
rethrow;
|
||||
}
|
||||
if (op != _op) {
|
||||
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 {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
await coordinator.connect(params);
|
||||
route.value = AppRoute.session;
|
||||
try {
|
||||
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 {
|
||||
await saveForm();
|
||||
} catch (e, st) {
|
||||
@@ -130,6 +297,8 @@ class SessionState {
|
||||
..host = form.host
|
||||
..port = form.port
|
||||
..secret = form.secret
|
||||
..hostPort = form.hostPort
|
||||
..hostSecret = form.hostSecret
|
||||
..name = form.name
|
||||
..avatarPath = form.avatarPath
|
||||
..mediaPath = form.mediaPath
|
||||
@@ -141,19 +310,22 @@ class SessionState {
|
||||
for (var i = 0; i < args.length - 1; i++) {
|
||||
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
||||
}
|
||||
_server = RoomServer(AppConfig(port: port, secret: 'change-me', name: 'room'));
|
||||
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
|
||||
await _server!.start();
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
await _server?.stop();
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
final server = _server;
|
||||
_server = null;
|
||||
await server?.stop();
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
await coordinator.leave();
|
||||
await stopServer();
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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:io';
|
||||
|
||||
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:hive_ce/hive.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(
|
||||
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