Desktop: video freeze recovery, session fixes, Home redesign
- Player + VideoController per session; Reload video (Ctrl+R) and a frozen-picture watchdog that reloads automatically. - Shortcuts no longer fire while typing; empty swap disabled; hwdec setting/env actually applied. - Idle unready measured from real inactivity; cancellable reconnects and setup; ready restored from server state; kicked/failed host stops its server; graceful leave on window close; no URLs with keys in display names. - Welcome + connect screens replaced by one Home screen with Join and Host cards, recent servers, Settings, Integrations and About. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beae87b0cc
commit
aeff087c7d
30 files changed
+3278
-601
No files matched your search
@@ -1,10 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/sync/sync_client.dart';
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
@@ -56,4 +60,149 @@ void main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel then retry within the reconnect delay dials only once',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = RoomServer(
|
||||
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
RoomServer? second;
|
||||
final observer = SyncClient();
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'race', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = RoomServer(
|
||||
AppConfig(port: port, secret: 'race', name: 'race'));
|
||||
await second.start();
|
||||
var joins = 0;
|
||||
final sub = observer.feeds.listen((f) {
|
||||
if (f.action == 'join' && f.actor == 'watcher') joins++;
|
||||
});
|
||||
await observer.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'race',
|
||||
name: 'observer');
|
||||
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
||||
coord.cancelReconnect();
|
||||
coord.retry();
|
||||
await _poll(() => !coord.reconnecting.peek() && coord.room.peek() != null,
|
||||
'reconnect to land');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
await sub.cancel();
|
||||
expect(joins, 1, reason: 'a stale attempt dialled a second time');
|
||||
expect(
|
||||
second.hub.engine.members.values
|
||||
.where((m) => m.name == 'watcher')
|
||||
.length,
|
||||
1);
|
||||
} finally {
|
||||
await observer.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'reconnect restores the server-side ready flag (force-all included)',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final first = RoomServer(
|
||||
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||
await first.start();
|
||||
final port = first.boundPort;
|
||||
final coord = SessionCoordinator();
|
||||
final friend = SyncClient();
|
||||
RoomServer? second;
|
||||
try {
|
||||
await coord.connect(ConnectParams(
|
||||
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
||||
await coord.playback.player.setVolume(0);
|
||||
await friend.connect(
|
||||
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
|
||||
secret: 'force',
|
||||
name: 'friend');
|
||||
// Never pressed Space locally; a friend marks everyone ready.
|
||||
friend.sendForce('all', true);
|
||||
await _poll(() => coord.selfMember.peek()?.ready == true,
|
||||
'forced ready to reach watcher');
|
||||
await friend.disconnect();
|
||||
await first.stop();
|
||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||
second = RoomServer(
|
||||
AppConfig(port: port, secret: 'force', name: 'force'));
|
||||
await second.start();
|
||||
await _poll(
|
||||
() => second!.hub.engine.members.values
|
||||
.any((m) => m.name == 'watcher' && m.ready),
|
||||
'watcher to come back ready');
|
||||
} finally {
|
||||
await friend.dispose();
|
||||
await coord.dispose();
|
||||
await second?.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancel during connect setup aborts without joining',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = RoomServer(
|
||||
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||
await server.start();
|
||||
final coord = SessionCoordinator();
|
||||
try {
|
||||
final pending = coord.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: '',
|
||||
name: 'watcher'));
|
||||
await coord.cancelConnect();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(coord.reconnecting.peek(), isFalse);
|
||||
} finally {
|
||||
await coord.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancelling a host setup releases the port',
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
Hive.init('/home/foxyz/.claude/jobs/4898998e/tmp/hive_coord_test');
|
||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final port = probe.port;
|
||||
await probe.close();
|
||||
final session = SessionState.instance;
|
||||
try {
|
||||
final pending = session.host(
|
||||
ConnectParams(host: '', port: port, secret: '', name: 'host'),
|
||||
ConnectForm());
|
||||
await session.cancelPending();
|
||||
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
|
||||
expect(session.route.peek(), isNot(AppRoute.session));
|
||||
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await rebind.close();
|
||||
} finally {
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/playback/frame_watchdog.dart';
|
||||
|
||||
final _t0 = DateTime.utc(2026, 1, 1);
|
||||
|
||||
/// Feeds one sample per second; [frames]/[pos] give the value at second i.
|
||||
List<bool> _run(
|
||||
FrameWatchdog w,
|
||||
int seconds, {
|
||||
required int? Function(int i) frames,
|
||||
double Function(int i)? pos,
|
||||
bool Function(int i)? eligible,
|
||||
int start = 0,
|
||||
}) {
|
||||
return [
|
||||
for (var i = start; i < start + seconds; i++)
|
||||
w.feed(FrameSample(
|
||||
at: _t0.add(Duration(seconds: i)),
|
||||
position: pos?.call(i) ?? i.toDouble(),
|
||||
frames: frames(i),
|
||||
eligible: eligible?.call(i) ?? true,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('hashes tell frames apart and ignore the edges', () {
|
||||
final a = ByteData(100 * 60 * 4);
|
||||
final b = ByteData(100 * 60 * 4);
|
||||
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
||||
b.setUint8((2 * 100 + 2) * 4, 255); // corner: overlay area
|
||||
expect(hashCenterPixels(a, 100, 60), hashCenterPixels(b, 100, 60));
|
||||
b.setUint8((30 * 100 + 50) * 4, 255); // centre: video
|
||||
expect(hashCenterPixels(a, 100, 60), isNot(hashCenterPixels(b, 100, 60)));
|
||||
final raw = Uint8List(1 << 20);
|
||||
final h = hashSpread(raw);
|
||||
raw[raw.length ~/ 2 - (raw.length ~/ 2) % (raw.length ~/ 4096)] = 1;
|
||||
expect(hashSpread(raw), isNot(h));
|
||||
});
|
||||
|
||||
test('frames advancing never fires', () {
|
||||
final w = FrameWatchdog();
|
||||
expect(_run(w, 30, frames: (i) => i * 30), everyElement(isFalse));
|
||||
});
|
||||
|
||||
test('frames stuck while position advances fires once after ~3 s', () {
|
||||
final w = FrameWatchdog();
|
||||
final fired = _run(w, 20, frames: (i) => i < 5 ? i * 30 : 150);
|
||||
expect(fired.where((f) => f).length, 1);
|
||||
expect(fired.indexOf(true), 8, reason: 'stuck from t=5, 3 s later');
|
||||
});
|
||||
|
||||
test('pause, buffering, missing data and seeks do not fire', () {
|
||||
final w = FrameWatchdog();
|
||||
// Paused / buffering: not eligible.
|
||||
expect(_run(w, 10, frames: (_) => 100, eligible: (_) => false),
|
||||
everyElement(isFalse));
|
||||
// No video output (headless / audio-only).
|
||||
expect(_run(w, 10, frames: (_) => null, start: 10), everyElement(isFalse));
|
||||
// Position stuck too (network stall): not a frozen picture.
|
||||
expect(_run(w, 10, frames: (_) => 100, pos: (_) => 50, start: 20),
|
||||
everyElement(isFalse));
|
||||
// Seeking every 2 s with frames stuck in between: jumps reset it.
|
||||
expect(
|
||||
_run(w, 12,
|
||||
frames: (_) => 100,
|
||||
pos: (i) => i.isEven ? i * 10.0 : i * 10.0 - 9,
|
||||
start: 30),
|
||||
everyElement(isFalse));
|
||||
});
|
||||
|
||||
test('no reload loop: re-arms only after 10 s of healthy frames', () {
|
||||
final w = FrameWatchdog();
|
||||
expect(_run(w, 10, frames: (_) => 7).where((f) => f).length, 1);
|
||||
w.reset(); // new player
|
||||
// Brief healthy spell, then stuck again: must not fire.
|
||||
expect(
|
||||
_run(w, 15, frames: (i) => i < 13 ? i : 13, start: 10)
|
||||
.where((f) => f)
|
||||
.length,
|
||||
0);
|
||||
// Healthy for 10 s+, then stuck: fires again.
|
||||
w.reset();
|
||||
expect(
|
||||
_run(w, 25, frames: (i) => i < 42 ? i : 42, start: 30)
|
||||
.where((f) => f)
|
||||
.length,
|
||||
1);
|
||||
});
|
||||
|
||||
test('still scene: dismiss re-arms after another full stall', () {
|
||||
final w = FrameWatchdog();
|
||||
final fired = <int>[];
|
||||
for (var i = 0; i < 20; i++) {
|
||||
if (w.feed(FrameSample(
|
||||
at: _t0.add(Duration(seconds: i)),
|
||||
position: i.toDouble(),
|
||||
frames: 5,
|
||||
eligible: true))) {
|
||||
fired.add(i);
|
||||
w.dismiss();
|
||||
}
|
||||
}
|
||||
expect(fired, [3, 6, 9, 12, 15, 18]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/screens/home/home_screen.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
import 'package:watch_together/src/state/settings.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
|
||||
RecentServer join(String host, String port, [String secret = '']) =>
|
||||
RecentServer(hosted: false, host: host, port: port, secret: secret);
|
||||
|
||||
void main() {
|
||||
group('recent servers', () {
|
||||
test('newest first, deduped by server, capped', () {
|
||||
var list = <RecentServer>[];
|
||||
for (var i = 0; i < 8; i++) {
|
||||
list = pushRecent(list, join('10.0.0.$i', '8080'));
|
||||
}
|
||||
expect(list.length, maxRecentServers);
|
||||
expect(list.first.host, '10.0.0.7');
|
||||
list = pushRecent(list, join('10.0.0.4', '8080', 'pw'));
|
||||
expect(list.length, maxRecentServers);
|
||||
expect(list.first.secret, 'pw');
|
||||
expect(list.where((r) => r.host == '10.0.0.4').length, 1);
|
||||
});
|
||||
|
||||
test('hosted entries are one per port, separate from joins', () {
|
||||
var list = <RecentServer>[];
|
||||
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080'));
|
||||
list = pushRecent(list, join('127.0.0.1', '8080'));
|
||||
list = pushRecent(list, const RecentServer(hosted: true, host: '', port: '8080', secret: 'x'));
|
||||
expect(list.length, 2);
|
||||
expect(list.first.secret, 'x');
|
||||
});
|
||||
|
||||
test('round-trips and tolerates junk', () {
|
||||
final list = [join('a.example', '9000', 's'), const RecentServer(hosted: true, host: '', port: '8080')];
|
||||
final back = decodeRecents(encodeRecents(list));
|
||||
expect(back.map((r) => r.key), list.map((r) => r.key));
|
||||
expect(back.first.secret, 's');
|
||||
expect(back.last.hosted, isTrue);
|
||||
expect(decodeRecents('not json'), isEmpty);
|
||||
expect(decodeRecents(42), isEmpty);
|
||||
expect(decodeRecents('[{"host":1},{"host":"h","port":"1"}]').single.host, 'h');
|
||||
});
|
||||
|
||||
test('old single form migrates to one recent unless it was localhost', () {
|
||||
expect(migrateRecents(hadForm: true, host: 'friend.example', port: '8080', secret: 'pw').single.secret, 'pw');
|
||||
expect(migrateRecents(hadForm: true, host: '127.0.0.1', port: '8080', secret: ''), isEmpty);
|
||||
expect(migrateRecents(hadForm: false, host: 'friend.example', port: '8080', secret: ''), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('hwdec value follows mode', () {
|
||||
final s = AppSettings();
|
||||
expect(s.hwdec.value, 'auto');
|
||||
s.setHwdecMode(HwdecMode.off);
|
||||
expect(s.hwdec.value, 'no');
|
||||
s.setHwdecMode(HwdecMode.custom);
|
||||
expect(s.hwdec.value, 'auto');
|
||||
s.setHwdecCustom(' vaapi ');
|
||||
expect(s.hwdec.value, 'vaapi');
|
||||
});
|
||||
|
||||
Future<void> pumpShell(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(
|
||||
body: HomeShell(updates: UpdateController(currentVersion: '0.0.0-dev'), autoUpdateCheck: false),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('home shows both cards side by side on a wide window', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 720));
|
||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||
expect(join.top, host.top);
|
||||
expect(join.height, host.height);
|
||||
expect(join.right, lessThan(host.left));
|
||||
expect(find.text('No password — anyone can join'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('cards stack on a narrow window', (tester) async {
|
||||
await pumpShell(tester, const Size(560, 900));
|
||||
final join = tester.getRect(find.byKey(const ValueKey('join-card')));
|
||||
final host = tester.getRect(find.byKey(const ValueKey('host-card')));
|
||||
expect(join.bottom, lessThan(host.top));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('join without a name errors inline in the join card', (tester) async {
|
||||
SessionState.instance.lastForm.name = '';
|
||||
await pumpShell(tester, const Size(1280, 720));
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('join-card')),
|
||||
matching: find.textContaining('display name', findRichText: true),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('rail switches to settings, integrations and about', (tester) async {
|
||||
await pumpShell(tester, const Size(1280, 900));
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Hardware decoding'), findsOneWidget);
|
||||
expect(find.text('Check automatically on start'), findsOneWidget);
|
||||
expect(find.text('Toggle ready'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.extension_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dev build — update checks are off'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/session/idle.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/sync/room_diff.dart';
|
||||
|
||||
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
|
||||
MemberView(
|
||||
id: 'm1',
|
||||
name: 'me',
|
||||
avatar: '',
|
||||
ready: ready,
|
||||
inSet: inSet,
|
||||
buffered: 5,
|
||||
stale: stale,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('IdleTracker', () {
|
||||
final t0 = DateTime.utc(2026, 1, 1, 12);
|
||||
|
||||
List<IdleEvent> run(IdleTracker idle, DateTime from, Duration span,
|
||||
{required bool playing, bool selfReady = true}) {
|
||||
final out = <IdleEvent>[];
|
||||
for (var s = 0; s <= span.inSeconds; s++) {
|
||||
final e = idle.tick(from.add(Duration(seconds: s)),
|
||||
playing: playing, selfReady: selfReady);
|
||||
if (e != IdleEvent.none) out.add(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('long watch then pause: no instant warn+unready', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// 15 min of playback without any input.
|
||||
expect(run(idle, t0, const Duration(minutes: 15), playing: true), isEmpty);
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
// First minute after the pause: nothing fires.
|
||||
expect(run(idle, paused, const Duration(minutes: 1), playing: false),
|
||||
isEmpty);
|
||||
});
|
||||
|
||||
test('pause measures from when playback stopped', () {
|
||||
final idle = IdleTracker(t0);
|
||||
run(idle, t0, const Duration(minutes: 15), playing: true);
|
||||
// Last playing tick was at +15 min; count seconds from there.
|
||||
final paused = t0.add(const Duration(minutes: 15));
|
||||
final events = <(int, IdleEvent)>[];
|
||||
for (var s = 1; s <= 11 * 60; s++) {
|
||||
final e = idle.tick(paused.add(Duration(seconds: s)),
|
||||
playing: false, selfReady: true);
|
||||
if (e != IdleEvent.none) events.add((s, e));
|
||||
}
|
||||
expect(events, [(8 * 60, IdleEvent.warn), (10 * 60, IdleEvent.unready)]);
|
||||
});
|
||||
|
||||
test('unready always trails the warning by 2 min', () {
|
||||
final idle = IdleTracker(t0);
|
||||
// Idle 20 min while unready (nothing to lose, nothing fires)...
|
||||
expect(
|
||||
run(idle, t0, const Duration(minutes: 20),
|
||||
playing: false, selfReady: false),
|
||||
isEmpty);
|
||||
// ...then someone force-readies us: warn now, unready 2 min later.
|
||||
final forced = t0.add(const Duration(minutes: 20, seconds: 1));
|
||||
expect(idle.tick(forced, playing: false, selfReady: true), IdleEvent.warn);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(seconds: 1)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.none);
|
||||
expect(
|
||||
idle.tick(forced.add(const Duration(minutes: 2)),
|
||||
playing: false, selfReady: true),
|
||||
IdleEvent.unready);
|
||||
});
|
||||
|
||||
test('input resets the clock and the warning', () {
|
||||
final idle = IdleTracker(t0);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 8)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
idle.activity(t0.add(const Duration(minutes: 9)));
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 11)),
|
||||
playing: false, selfReady: true), IdleEvent.none);
|
||||
expect(idle.tick(t0.add(const Duration(minutes: 17)),
|
||||
playing: false, selfReady: true), IdleEvent.warn);
|
||||
});
|
||||
});
|
||||
|
||||
group('linkDisplayName', () {
|
||||
test('drops query (api_key) and fragment, decodes last segment', () {
|
||||
expect(
|
||||
linkDisplayName(
|
||||
'https://jf.example.com/Videos/abc/My%20Movie.mkv?static=true&api_key=SECRET#t=5'),
|
||||
'My Movie.mkv');
|
||||
});
|
||||
test('trailing slash and bare host', () {
|
||||
expect(linkDisplayName('http://host.lan/media/show/'), 'show');
|
||||
expect(linkDisplayName('http://host.lan/?api_key=SECRET'), 'host.lan');
|
||||
});
|
||||
test('never contains the key', () {
|
||||
final n = linkDisplayName(
|
||||
'https://jf.example.com/Items/1/Download?api_key=SECRET');
|
||||
expect(n, 'Download');
|
||||
expect(n.contains('SECRET'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldReassertReady', () {
|
||||
test('ready, fresh, out of set (after stale sweep) -> reassert', () {
|
||||
expect(shouldReassertReady(_self(inSet: false)), isTrue);
|
||||
});
|
||||
test('normal states -> no', () {
|
||||
expect(shouldReassertReady(null), isFalse);
|
||||
expect(shouldReassertReady(_self()), isFalse);
|
||||
expect(shouldReassertReady(_self(ready: false, inSet: false)), isFalse);
|
||||
expect(shouldReassertReady(_self(ready: false)), isFalse);
|
||||
expect(shouldReassertReady(_self(inSet: false, stale: true)), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/widgets/text_focus.dart';
|
||||
|
||||
void main() {
|
||||
// Regression: the old FocusNode-parent walk never met EditableText, so
|
||||
// session shortcuts (Space, arrows, F) fired while typing in chat.
|
||||
testWidgets('text field focus is detected, buttons are not', (tester) async {
|
||||
final field = FocusNode();
|
||||
final button = FocusNode();
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(children: [
|
||||
TextField(focusNode: field),
|
||||
TextButton(focusNode: button, onPressed: () {}, child: const Text('b')),
|
||||
]),
|
||||
),
|
||||
));
|
||||
expect(textInputFocused(), isFalse);
|
||||
|
||||
field.requestFocus();
|
||||
await tester.pump();
|
||||
expect(field.hasPrimaryFocus, isTrue);
|
||||
expect(textInputFocused(), isTrue);
|
||||
|
||||
button.requestFocus();
|
||||
await tester.pump();
|
||||
expect(textInputFocused(), isFalse);
|
||||
|
||||
field.dispose();
|
||||
button.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/sync/room_diff.dart';
|
||||
|
||||
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
||||
|
||||
Future<void> _poll(
|
||||
FutureOr<bool> Function() cond,
|
||||
String what, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'reload video: new player, same media, resynced, ready untouched; '
|
||||
'leave disposes the player',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final server = RoomServer(const AppConfig(
|
||||
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||
await server.start();
|
||||
final c = SessionCoordinator();
|
||||
try {
|
||||
await c.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: 'reload',
|
||||
name: 'solo'));
|
||||
await c.playback.player.setVolume(0);
|
||||
await _poll(() => c.room.peek() != null, 'room state');
|
||||
c.toggleReady();
|
||||
await _poll(
|
||||
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
||||
'solo playback past 2 s');
|
||||
|
||||
final old = c.playback.player;
|
||||
final gen = c.playback.generation.peek();
|
||||
await c.reloadVideo();
|
||||
expect(identical(c.playback.player, old), isFalse);
|
||||
expect(c.playback.generation.peek(), gen + 1);
|
||||
expect(c.selfMember.peek()?.ready, isTrue);
|
||||
|
||||
await _poll(() {
|
||||
final r = c.room.peek();
|
||||
if (r == null || !c.playback.player.state.playing) return false;
|
||||
final want = roomPositionAt(r, DateTime.now().toUtc());
|
||||
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
||||
c.playback.positionSecs > 2;
|
||||
}, 'reloaded player playing at the synced position');
|
||||
expect(c.selfMember.peek()?.ready, isTrue);
|
||||
expect(c.room.peek()?.transport, 'playing');
|
||||
|
||||
await c.leave();
|
||||
expect(c.playback.alive.peek(), isFalse);
|
||||
} finally {
|
||||
await c.dispose();
|
||||
await server.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user