diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d99c028..2181574 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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). diff --git a/apps/desktop/lib/app.dart b/apps/desktop/lib/app.dart index dc9079f..3f57e3d 100644 --- a/apps/desktop/lib/app.dart +++ b/apps/desktop/lib/app.dart @@ -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(), }; }, diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index b59b1cb..b9e4d3a 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List args) async { + launchArgs = List.unmodifiable(args); if (args.contains('--upgrade')) { exitCode = await runUpgrade(); exit(exitCode); @@ -39,19 +42,22 @@ Future main(List 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 runHeadless(List 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 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(); + } +} diff --git a/apps/desktop/lib/src/playback/frame_watchdog.dart b/apps/desktop/lib/src/playback/frame_watchdog.dart new file mode 100644 index 0000000..2c80f07 --- /dev/null +++ b/apps/desktop/lib/src/playback/frame_watchdog.dart @@ -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; + } +} diff --git a/apps/desktop/lib/src/playback/playback_controller.dart b/apps/desktop/lib/src/playback/playback_controller.dart index 9c0337e..6e52077 100644 --- a/apps/desktop/lib/src/playback/playback_controller.dart +++ b/apps/desktop/lib/src/playback/playback_controller.dart @@ -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 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 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 recreate() async { + final volume = _player?.state.volume; + await dispose(); + await init(); + if (volume != null) { + try { + await player.setVolume(volume); + } catch (_) {} + } } Future _pin(String property, String value) async { @@ -32,14 +102,81 @@ class PlaybackController { Future _pinBufferProps() async { await _pin('demuxer-readahead-secs', '10'); await _pin('cache-secs', '10'); - final hwdec = Platform.environment['SYNC_RELAY_HWDEC']; - if (hwdec != null && hwdec.isNotEmpty) { + 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 _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 _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 _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 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 apply(List 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 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 _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 (_) {} } diff --git a/apps/desktop/lib/src/screens/connect_screen.dart b/apps/desktop/lib/src/screens/connect_screen.dart deleted file mode 100644 index 6cfa051..0000000 --- a/apps/desktop/lib/src/screens/connect_screen.dart +++ /dev/null @@ -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 createState() => _ConnectScreenState(); -} - -class _ConnectScreenState extends State { - late final TextEditingController _host; - late final TextEditingController _port; - late final TextEditingController _secret; - late final TextEditingController _name; - late final TextEditingController _media; - late final void Function() _stopKickWatch; - String? _error; - String? _notice; - bool _busy = false; - bool _dialogOpen = false; - int _attempt = 0; - String? _lastKickShown; - - @override - void initState() { - super.initState(); - final f = SessionState.instance.lastForm; - _host = TextEditingController(text: f.host); - _port = TextEditingController(text: f.port); - _secret = TextEditingController(text: f.secret); - _name = TextEditingController(text: f.name); - _media = TextEditingController(text: f.linkUrl.isNotEmpty ? f.linkUrl : f.mediaPath); - _stopKickWatch = effect(() { - final kick = SessionState.instance.coordinator.kickReason.value; - if (kick == null || kick.isEmpty) { - _lastKickShown = null; - return; - } - if (kick == _lastKickShown) return; - _lastKickShown = kick; - final reason = kick; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _showKickedDialog(reason); - }); - }); - } - - @override - void dispose() { - _stopKickWatch(); - _host.dispose(); - _port.dispose(); - _secret.dispose(); - _name.dispose(); - _media.dispose(); - super.dispose(); - } - - String _actionable(Object e) { - if (e is FormatException) return e.message; - return 'check the form and try again'; - } - - Future _buildParams(ConnectMode mode) async { - final port = int.tryParse(_port.text.trim()); - if (port == null || port <= 0 || port > 65535) { - throw const FormatException('port must be 1–65535'); - } - if (_secret.text.isEmpty) { - throw const FormatException('room password required'); - } - if (_name.text.trim().isEmpty) { - throw const FormatException('display name required'); - } - String avatar = ''; - try { - avatar = await encodeAvatar(SessionState.instance.lastForm.avatarPath); - } on FormatException catch (e) { - throw FormatException(e.message); - } catch (_) { - throw const FormatException('could not process that avatar — pick a PNG, JPG or WebP photo'); - } - final parsed = parseMediaInput(_media.text); - if (mode == ConnectMode.host && parsed.mediaPath.isNotEmpty && !await File(parsed.mediaPath).exists()) { - throw const FormatException('media file not found — check the path or pick again with Browse'); - } - return ConnectParams( - host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(), - port: port, - secret: _secret.text, - name: _name.text.trim(), - avatar: avatar, - mediaPath: mode == ConnectMode.host ? parsed.mediaPath : '', - linkUrl: mode == ConnectMode.host ? parsed.linkUrl : '', - ); - } - - Future _submit(ConnectMode mode) async { - final myAttempt = ++_attempt; - final session = SessionState.instance; - setState(() { - _error = null; - _notice = null; - _busy = true; - }); - late final ConnectParams params; - try { - params = await _buildParams(mode); - } catch (e) { - if (!mounted || myAttempt != _attempt) return; - setState(() { - _error = _actionable(e); - _busy = false; - }); - return; - } - final parsed = parseMediaInput(_media.text); - final form = SessionState.instance.lastForm - ..host = _host.text.trim() - ..port = _port.text.trim() - ..secret = _secret.text - ..name = _name.text.trim() - ..mediaPath = parsed.mediaPath - ..linkUrl = parsed.linkUrl; - try { - if (mode == ConnectMode.host) { - await session.host(params, form); - } else { - await session.join(params, form); - } - } catch (e) { - if (!mounted || myAttempt != _attempt) return; - final kick = session.coordinator.kickReason.peek(); - setState(() => _busy = false); - if (kick != null && kick.isNotEmpty) { - await _showKickedDialog(kick); - } else { - await _showConnectionDialog('$e'); - } - return; - } - if (myAttempt != _attempt) { - try { - await session.leave(); - await session.stopServer(); - } catch (_) {} - return; - } - if (mounted) setState(() => _busy = false); - } - - Future _cancel() async { - _attempt++; - try { - await SessionState.instance.coordinator.cancelConnect(); - await SessionState.instance.stopServer(); - } catch (_) {} - if (!mounted) return; - setState(() { - _busy = false; - _notice = 'connection cancelled'; - }); - } - - Future _showKickedDialog(String reason) async { - if (!mounted || _dialogOpen) return; - _dialogOpen = true; - try { - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('You were removed'), - content: Text(reason.isEmpty ? 'The host removed you from the room.' : reason), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], - ), - ); - } finally { - _dialogOpen = false; - } - } - - Future _showConnectionDialog(String message) async { - if (!mounted || _dialogOpen) return; - _dialogOpen = true; - try { - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Could not connect'), - content: Text(message), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], - ), - ); - } finally { - _dialogOpen = false; - } - } - - Future _browse(TextEditingController target, {required String dialogTitle, List? allowedExtensions}) async { - try { - final files = await FilePicker.pickFiles( - dialogTitle: dialogTitle, - type: allowedExtensions == null ? FileType.any : FileType.custom, - allowedExtensions: allowedExtensions, - ); - if (files.isEmpty) return; - final path = files.first.path; - if (path != null && mounted) setState(() => target.text = path); - } catch (e) { - if (!mounted) return; - setState(() => _notice = 'file dialog unavailable (${e.runtimeType}) — paste the path manually'); - } - } - - @override - Widget build(BuildContext context) { - final session = SessionState.instance; - return Scaffold( - appBar: 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( - segments: const [ - ButtonSegment(value: ConnectMode.host, label: Text('Host')), - ButtonSegment(value: ConnectMode.join, label: Text('Join')), - ], - selected: {mode}, - onSelectionChanged: (s) => session.connectMode.value = s.first, - ); - }, - ), - const SizedBox(height: 16), - SignalBuilder( - builder: (context) { - if (session.connectMode.value == ConnectMode.host) { - return const SizedBox.shrink(); - } - return TextField( - controller: _host, - decoration: const InputDecoration(labelText: 'Host address'), - ); - }, - ), - TextField( - controller: _port, - decoration: const InputDecoration(labelText: 'Port'), - keyboardType: TextInputType.number, - ), - TextField( - controller: _secret, - decoration: const InputDecoration(labelText: 'Room password'), - obscureText: true, - ), - const SizedBox(height: 8), - SignalBuilder( - builder: (context) { - if (session.connectMode.value == ConnectMode.host) { - return Row( - children: [ - Expanded( - child: TextField( - controller: _media, - decoration: const InputDecoration(labelText: 'Media (file path or http(s) URL)'), - ), - ), - TextButton( - onPressed: () => _browse( - _media, - dialogTitle: 'Pick media file', - allowedExtensions: const ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'], - ), - child: const Text('Browse'), - ), - ], - ); - } - return const SizedBox.shrink(); - }, - ), - const SizedBox(height: 16), - SignalBuilder( - builder: (context) { - final mode = session.connectMode.value; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton( - onPressed: _busy ? null : () => _submit(mode), - child: Text( - _busy - ? 'Connecting…' - : mode == ConnectMode.host - ? 'Start hosting' - : 'Join', - ), - ), - if (_busy) TextButton(onPressed: _cancel, child: const Text('Cancel')), - ], - ); - }, - ), - const SizedBox(height: 12), - const Text('Host opens ports and serves. Clients open nothing.', textAlign: TextAlign.center), - ], - ), - ), - ], - ), - ); - } -} diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart new file mode 100644 index 0000000..2378782 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -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 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 _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'), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/connect_flow.dart b/apps/desktop/lib/src/screens/home/connect_flow.dart new file mode 100644 index 0000000..1c3c33e --- /dev/null +++ b/apps/desktop/lib/src/screens/home/connect_flow.dart @@ -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 = {}; + final notices = {}; + 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 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 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 cancel() async { + final mode = busy; + _attempt++; + try { + await _session.cancelPending(); + } catch (_) {} + busy = null; + if (mode != null) notices[mode] = 'Cancelled'; + _notify(); + } +} diff --git a/apps/desktop/lib/src/screens/home/home_page.dart b/apps/desktop/lib/src/screens/home/home_page.dart new file mode 100644 index 0000000..7550c2d --- /dev/null +++ b/apps/desktop/lib/src/screens/home/home_page.dart @@ -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 createState() => _HomePageState(); +} + +class _HomePageState extends State { + 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 _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 _showKickedDialog(String reason) async { + if (!mounted || _dialogOpen || reason == _lastKickShown) return; + _lastKickShown = reason; + _dialogOpen = true; + try { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('You were removed'), + content: Text(reason.isEmpty ? 'The host removed you from the 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 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), + ), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/home_screen.dart b/apps/desktop/lib/src/screens/home/home_screen.dart new file mode 100644 index 0000000..283bcd9 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/home_screen.dart @@ -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 createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + 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 _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), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/integrations_page.dart b/apps/desktop/lib/src/screens/home/integrations_page.dart new file mode 100644 index 0000000..449ebda --- /dev/null +++ b/apps/desktop/lib/src/screens/home/integrations_page.dart @@ -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')), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/profile.dart b/apps/desktop/lib/src/screens/home/profile.dart new file mode 100644 index 0000000..0cef6b8 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/profile.dart @@ -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(null); + final error = signal(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 _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 pickAvatar() async { + if (picking.peek()) return; + picking.value = true; + error.value = null; + try { + final List 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 clearAvatar() async { + SessionState.instance.lastForm.avatarPath = ''; + preview.value = null; + error.value = null; + await _save(); + } + + Future _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 createState() => _ProfileEditorState(); +} + +class _ProfileEditorState extends State { + 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 showProfileDialog(BuildContext context) { + return showDialog( + 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)), + ], + ); + }), + ], + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart new file mode 100644 index 0000000..9047571 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -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( + 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)), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/ui.dart b/apps/desktop/lib/src/screens/home/ui.dart new file mode 100644 index 0000000..a5ef2e7 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/ui.dart @@ -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 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, + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/update_indicator.dart b/apps/desktop/lib/src/screens/home/update_indicator.dart new file mode 100644 index 0000000..49d6018 --- /dev/null +++ b/apps/desktop/lib/src/screens/home/update_indicator.dart @@ -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( + 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 showUpdateDialog(BuildContext context, {UpdateController? controller}) { + final c = controller ?? UpdateController.instance; + return showDialog( + context: context, + builder: (context) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)), + child: SizedBox( + width: 420, + child: ValueListenableBuilder( + 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 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)); + }, + ); + } +} diff --git a/apps/desktop/lib/src/screens/session_screen.dart b/apps/desktop/lib/src/screens/session_screen.dart index 74d0503..27f594e 100644 --- a/apps/desktop/lib/src/screens/session_screen.dart +++ b/apps/desktop/lib/src/screens/session_screen.dart @@ -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 { - VideoController? _video; - final _videoKey = GlobalKey(); + // One key per player generation: a reloaded player needs a fresh + // `Video` (it never swaps controllers in place). + var _videoGen = -1; + var _videoKey = GlobalKey(); + 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 { 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 { 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 { 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 { @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 { 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 { 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 { 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(); + } + 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 { await defaultExitNativeFullscreen(); }, ), - ), + ); + }, + ), ), ); } @@ -212,6 +261,8 @@ class _SessionScreenState extends State { 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 { 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( + 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, ), ), diff --git a/apps/desktop/lib/src/screens/welcome_screen.dart b/apps/desktop/lib/src/screens/welcome_screen.dart deleted file mode 100644 index e51b97b..0000000 --- a/apps/desktop/lib/src/screens/welcome_screen.dart +++ /dev/null @@ -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, - ), - ], - ), - ), - ); - } -} diff --git a/apps/desktop/lib/src/session/idle.dart b/apps/desktop/lib/src/session/idle.dart new file mode 100644 index 0000000..37f5d18 --- /dev/null +++ b/apps/desktop/lib/src/session/idle.dart @@ -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; + } +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index da73cd0..c745592 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -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 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 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 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 _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 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 _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 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 leave({bool silent = false}) async { + _invalidateAttempts(); + await _teardown(silent: silent); + } + + Future _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(); diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index d63c08f..ad4b310 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -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 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 pushRecent(List list, RecentServer s) => [ + s, + ...list.where((r) => r.key != s.key), + ].take(maxRecentServers).toList(); + +String encodeRecents(List list) => + jsonEncode([for (final r in list) r.toJson()]); + +List 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 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>(const []); final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); final room = signal(null); final SfxService sfx = const NoopSfxService(); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); final lastForm = ConnectForm(); RoomServer? _server; + 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 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 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 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 rememberServer(RecentServer server) async { + recents.value = pushRecent(recents.peek(), server); + await _saveFormQuietly(); + } + + Future forgetServer(RecentServer server) async { + recents.value = [ + for (final r in recents.peek()) + if (r.key != server.key) r, + ]; + await _saveFormQuietly(); } Future 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 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 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 _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 stopServer() async { - await _server?.stop(); + _stopKickWatch?.call(); + _stopKickWatch = null; + final server = _server; _server = null; + await server?.stop(); } Future leave() async { await coordinator.leave(); await stopServer(); status.value = const HealthStatus(HealthSeverity.info, 'idle'); - route.value = AppRoute.connect; + route.value = AppRoute.home; } } diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart new file mode 100644 index 0000000..ae93fe7 --- /dev/null +++ b/apps/desktop/lib/src/state/settings.dart @@ -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? _load; + Box? _box; + + Future ensureLoaded() => _load ??= _loadNow(); + + Future _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 _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'); + } + } +} diff --git a/apps/desktop/lib/src/sync/room_diff.dart b/apps/desktop/lib/src/sync/room_diff.dart index d644bfc..2f5105f 100644 --- a/apps/desktop/lib/src/sync/room_diff.dart +++ b/apps/desktop/lib/src/sync/room_diff.dart @@ -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); diff --git a/apps/desktop/lib/src/widgets/shown_frame_probe.dart b/apps/desktop/lib/src/widgets/shown_frame_probe.dart new file mode 100644 index 0000000..31755a9 --- /dev/null +++ b/apps/desktop/lib/src/widgets/shown_frame_probe.dart @@ -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 sample() async { + for (final a in _anchors.reversed) { + if (!a.mounted) continue; + final boundary = a.context.findAncestorRenderObjectOfType(); + 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(); +} diff --git a/apps/desktop/lib/src/widgets/text_focus.dart b/apps/desktop/lib/src/widgets/text_focus.dart new file mode 100644 index 0000000..67d296d --- /dev/null +++ b/apps/desktop/lib/src/widgets/text_focus.dart @@ -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() != null; +} diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index a83d9e8..7732a3c 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -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 _poll( FutureOr 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())); + 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())); + 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(); + } + }, + ); } diff --git a/apps/desktop/test/frame_watchdog_test.dart b/apps/desktop/test/frame_watchdog_test.dart new file mode 100644 index 0000000..b247056 --- /dev/null +++ b/apps/desktop/test/frame_watchdog_test.dart @@ -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 _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 = []; + 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]); + }); +} diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart new file mode 100644 index 0000000..086adbc --- /dev/null +++ b/apps/desktop/test/home_test.dart @@ -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 = []; + 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 = []; + 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 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); + }); +} diff --git a/apps/desktop/test/session_logic_test.dart b/apps/desktop/test/session_logic_test.dart new file mode 100644 index 0000000..8319d04 --- /dev/null +++ b/apps/desktop/test/session_logic_test.dart @@ -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 run(IdleTracker idle, DateTime from, Duration span, + {required bool playing, bool selfReady = true}) { + final out = []; + 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); + }); + }); +} diff --git a/apps/desktop/test/text_focus_test.dart b/apps/desktop/test/text_focus_test.dart new file mode 100644 index 0000000..365afc5 --- /dev/null +++ b/apps/desktop/test/text_focus_test.dart @@ -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(); + }); +} diff --git a/apps/desktop/test/video_reload_e2e_test.dart b/apps/desktop/test/video_reload_e2e_test.dart new file mode 100644 index 0000000..978184c --- /dev/null +++ b/apps/desktop/test/video_reload_e2e_test.dart @@ -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 _poll( + FutureOr Function() cond, + String what, { + Duration timeout = const Duration(seconds: 30), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (await cond()) return; + await Future.delayed(const Duration(milliseconds: 200)); + } + fail('timed out waiting for $what'); +} + +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(); + } + }, + ); +}