Desktop: video freeze recovery, session fixes, Home redesign

- Player + VideoController per session; Reload video (Ctrl+R) and a
  frozen-picture watchdog that reloads automatically.
- Shortcuts no longer fire while typing; empty swap disabled;
  hwdec setting/env actually applied.
- Idle unready measured from real inactivity; cancellable reconnects
  and setup; ready restored from server state; kicked/failed host
  stops its server; graceful leave on window close; no URLs with
  keys in display names.
- Welcome + connect screens replaced by one Home screen with Join and
  Host cards, recent servers, Settings, Integrations and About.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:41:02 +03:00
1 parent beae87b0cc
commit aeff087c7d
30 files changed
+3278 -601

No files matched your search

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