Solo watching and a clearer offline start

Putting anything up offline starts a solo session: an in-app server bound
to 127.0.0.1 only, joined like hosting, so tracking, next episode and the
player all work. Invite friends re-hosts on the Host card's port and
password with the same media and position; Stop needs no confirmation.
Offline, the Library opens with a welcome band (Join a friend, Host, Watch
solo); without a Jellyfin account it leads with Files and Link, Jellyfin
is an optional card. Play buttons work offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 23:08:11 +03:00
1 parent 8186fbb5d3
commit e0db5cc655
28 files changed
+1013 -345

No files matched your search

+2 -21
View File
@@ -44,7 +44,7 @@ Future<void> frames(WidgetTester tester, [int n = 10]) async {
Future<void> pumpPage(
WidgetTester tester, {
Size size = const Size(1280, 800),
bool online = true,
bool solo = false,
bool relayed = false,
Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails,
@@ -56,7 +56,7 @@ Future<void> pumpPage(
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: FilesPage(
online: online,
solo: solo,
relayed: relayed,
onPlay: onPlay ?? (_) async => null,
thumbnails: thumbnails ?? fakeThumb,
@@ -206,24 +206,6 @@ void main() {
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
});
testWidgets('offline: browsing works, playing is disabled', (tester) async {
store.addFolder(anime);
final played = <String>[];
await pumpPage(tester, online: false, onPlay: (p) async {
played.add(p);
return null;
});
await openFolder(tester, 'anime');
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
await tester.tap(find.text('E01'));
await tester.tap(find.text('Some Movie (2019)'));
await frames(tester);
expect(played, isEmpty);
expect(store.recent.value, isEmpty);
expect(find.byTooltip(offlinePlayHint), findsWidgets);
});
testWidgets('a play goes to onPlay and into Recent', (tester) async {
store.addFolder(anime);
final played = <String>[];
@@ -261,7 +243,6 @@ void main() {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: FilesPage(
online: true,
relayed: false,
onPlay: (_) async => null,
nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
+16 -11
View File
@@ -8,6 +8,7 @@ import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
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';
@@ -230,24 +231,28 @@ void main() {
expect(find.byKey(const ValueKey('join-row')), findsOneWidget);
});
testWidgets('library: sources switch; offline nothing goes up; Integrations opens Settings', (tester) async {
testWidgets('library offline: welcome band, sources that need nothing, Jellyfin optional', (tester) async {
await pumpShell(tester, const Size(1280, 900));
await tester.tap(find.text('Files'));
// The three ways in, Join first; none needs Jellyfin.
expect(find.byKey(const ValueKey('welcome-band')), findsOneWidget);
expect(find.text('Watch together, in sync'), findsOneWidget);
final join = tester.getTopLeft(find.byKey(const ValueKey('way-join')));
expect(join.dx, lessThan(tester.getTopLeft(find.byKey(const ValueKey('way-solo'))).dx));
// No linked server: Files and Link first, Jellyfin as an optional card.
expect(find.text('Pick something to watch'), findsOneWidget);
expect(find.text('Add a Jellyfin server'), findsNothing);
await tester.tap(find.byKey(const ValueKey('jellyfin-link')));
await tester.pumpAndSettle();
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('source-files')));
await tester.pumpAndSettle();
expect(find.byType(FilesPage), findsOneWidget);
await tester.tap(find.text('Link'));
await tester.pumpAndSettle();
expect(find.text('Watch from a link'), findsOneWidget);
// Offline nothing goes up.
expect(find.text('Join or host to watch together'), findsOneWidget);
// Alone: Play, no relay modes.
expect(find.text(soloPlayHint), findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
// No linked server: sign-in right in the library.
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.text('Open Integrations'));
await tester.pumpAndSettle();
expect(find.text('Add server'), findsOneWidget);
expect(tester.takeException(), isNull);
});
+21 -27
View File
@@ -48,16 +48,16 @@ FakeJellyfin libraryFake() {
}
class FilesStub extends StatelessWidget {
final bool online, relayed;
final bool solo, relayed;
final Future<String?> Function(String path) onPlay;
const FilesStub({super.key, required this.online, required this.relayed, required this.onPlay});
const FilesStub({super.key, required this.solo, required this.relayed, required this.onPlay});
@override
Widget build(BuildContext context) => Column(children: [
Text('files online=$online relayed=$relayed'),
Text('files solo=$solo relayed=$relayed'),
TextButton(
key: const ValueKey('stub-play'),
onPressed: online ? () => onPlay('/v/a.mkv') : null,
onPressed: () => onPlay('/v/a.mkv'),
child: const Text('play'),
),
]);
@@ -75,7 +75,7 @@ class Harness {
Future<Harness> pumpLibrary(
WidgetTester tester,
Size size, {
bool online = true,
bool solo = false,
bool isHost = false,
MediaKind source = MediaKind.jellyfin,
Harness? reuse,
@@ -103,7 +103,7 @@ Future<Harness> pumpLibrary(
key: UniqueKey(),
controller: h.controller,
accounts: h.accounts,
online: online,
solo: solo,
isHost: isHost,
jellyfinStart: start,
recentLinks: h.recents,
@@ -111,8 +111,8 @@ Future<Harness> pumpLibrary(
probe: (url) async => url.endsWith('.mkv')
? LinkProbe.file(Uri.parse(url), 1)
: LinkProbe.page(Uri.parse(url), info: const YtDlpInfo(title: 'A video', uploader: 'Chan')),
filesBuilder: (context, {required online, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(online: online, relayed: relayed, onPlay: onPlay),
filesBuilder: (context, {required solo, required relayed, required onPlay, nowPlayingPath}) =>
FilesStub(solo: solo, relayed: relayed, onPlay: onPlay),
onPutUp: (m) async {
h.put.add(m);
return null;
@@ -152,7 +152,7 @@ void main() {
await tester.tap(find.text('Files'));
await settle(tester, 10);
expect(find.text('files online=true relayed=true'), findsOneWidget);
expect(find.text('files solo=false relayed=true'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('stub-play')));
await settle(tester);
expect(h.put, [const FileMedia('/v/a.mkv')]);
@@ -247,32 +247,26 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('offline: everything browses, nothing goes up', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), online: false);
testWidgets('solo: buttons say Play and put up; a link goes direct, no relay modes', (tester) async {
final h = await pumpLibrary(tester, const Size(1600, 1000), solo: true);
await tester.tap(arifuretaCard());
await settle(tester, 10);
final go = find.byKey(const ValueKey('details-put-up'));
expect(tester.widget<ButtonStyleButton>(go).onPressed, isNull);
expect(find.descendant(of: go, matching: find.text('Play')), findsOneWidget);
expect(find.text(offlinePutUpHint), findsOneWidget);
await doubleTap(tester, arifuretaCard());
expect(h.put, isEmpty);
expect(find.descendant(of: go, matching: find.textContaining('Play')), findsOneWidget);
await tester.tap(go);
await settle(tester, 10);
expect(h.put.single, isA<JellyfinMedia>());
await tester.tap(find.text('Link'));
await settle(tester, 10);
h.controller.linkRelay = LinkRelay.me;
await tester.enterText(find.byKey(const ValueKey('link-field')), 'https://cdn.example/a.mkv');
await settle(tester, 10);
final linkGo = find.byKey(const ValueKey('link-put-up'));
expect(tester.widget<ButtonStyleButton>(linkGo).onPressed, isNull);
expect(find.descendant(of: find.byKey(const ValueKey('media-link')), matching: find.text(offlinePutUpHint)),
findsOneWidget);
expect(find.text('How the others get it'), findsNothing);
expect(find.text('a.mkv'), findsOneWidget, reason: 'the preview still works');
await tester.tap(find.text('Files'));
expect(find.text(soloPlayHint), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('link-put-up')));
await settle(tester, 10);
expect(find.text('files online=false relayed=true'), findsOneWidget);
expect(h.put, isEmpty);
expect(h.put.last, const LinkMedia('https://cdn.example/a.mkv'));
expect(tester.takeException(), isNull);
});
@@ -307,7 +301,7 @@ void main() {
testWidgets('hosting: files stream from here; now-playing file passes through', (tester) async {
await pumpLibrary(tester, const Size(1400, 900), source: MediaKind.file, isHost: true);
expect(find.text('files online=true relayed=false'), findsOneWidget);
expect(find.text('files solo=false relayed=false'), findsOneWidget);
});
testWidgets('the last pick comes back selected; "Change video" opens on its series', (tester) async {
@@ -325,7 +319,7 @@ void main() {
expect(c.selected!.itemId, bsEpisode);
expect(c.source, MediaKind.jellyfin);
final h = Harness(c, accounts, FakeJellyfin());
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, online: false);
await pumpLibrary(tester, const Size(1600, 1000), reuse: h, solo: true);
expect(find.byKey(const ValueKey('jf-details')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
+5 -5
View File
@@ -231,7 +231,7 @@ void main() {
WidgetTester tester, {
Size size = const Size(2560, 1080),
ScheduleSnapshot Function()? snap,
bool online = true,
bool solo = false,
JellyfinAccounts? accounts,
}) async {
toasts.clear();
@@ -245,7 +245,7 @@ void main() {
theme: ThemeData.dark(),
home: Scaffold(
body: SchedulePage(
online: online,
solo: solo,
repository: repo,
accounts: accounts,
onToast: toasts.add,
@@ -434,11 +434,11 @@ void main() {
await done(tester);
});
testWidgets('offline: Play is disabled', (tester) async {
await pumpPage(tester, online: false);
testWidgets('solo: the button says Play and works', (tester) async {
await pumpPage(tester, solo: true);
expect(find.text('Put it up'), findsNothing);
final b = widgetOf<ButtonStyleButton>(tester, action(111, 3));
expect(b.onPressed, isNull);
expect(b.onPressed, isNotNull);
expect(find.text('Play'), findsOneWidget);
await done(tester);
});
@@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart';
@@ -218,4 +219,45 @@ void main() {
if (file.existsSync()) file.deleteSync();
}
});
test('solo: loopback-only server; inviting friends hosts the same media',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Hive.init('$tmp/hive_isolate_solo_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
final file = File('$tmp/solo carry.mkv')..writeAsBytesSync(List.filled(4096, 7));
session.route.value = AppRoute.home; // the tests above leave it set
try {
await session.startSolo(const ConnectParams(host: '127.0.0.1', port: 0, secret: '', name: 'me'));
expect(session.route.peek(), AppRoute.session);
expect(session.solo.peek(), isTrue);
await session.coordinator.playback.player.setVolume(0);
final soloPort = session.hostedServer!.port;
// Reachable on loopback only: a LAN address of this machine is refused.
final lan = [
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4))
for (final a in i.addresses)
if (!a.isLoopback) a,
];
if (lan.isNotEmpty) {
await expectLater(Socket.connect(lan.first, soloPort, timeout: const Duration(seconds: 3)),
throwsA(isA<SocketException>()));
}
expect(await session.coordinator.swapPicked(FileMedia(file.path)), isNull);
await _poll(() => session.coordinator.serverState.peek()?.media?['name'] == 'solo carry.mkv', 'solo media');
await session.host(ConnectParams(host: '', port: port, secret: 'pw', name: 'me'), ConnectForm());
expect(session.solo.peek(), isFalse);
expect(session.route.peek(), AppRoute.session);
friend = await _Raw.join(port, 'friend', secret: 'pw');
await friend.next('state', where: (m) => (m['media'] as Map?)?['name'] == 'solo carry.mkv');
} finally {
await friend?.ws.close();
await session.leave();
if (file.existsSync()) file.deleteSync();
}
});
}
+23
View File
@@ -128,6 +128,29 @@ void main() {
expect(session.route.value, AppRoute.session);
});
testWidgets('solo: just you, Invite friends opens the host card, Stop asks nothing', (tester) async {
await pumpShell(tester, const Size(1280, 800));
session.solo.value = true;
addTearDown(() => session.solo.value = false);
online([member('me', 'Me')], media: false);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('solo-chip')), findsOneWidget);
expect(find.byKey(const ValueKey('party-avatars')), findsNothing);
expect(find.byKey(const ValueKey('strip-chat')), findsNothing);
expect(find.byKey(const ValueKey('welcome-band')), findsNothing);
await tester.tap(find.byKey(const ValueKey('strip-invite')));
await tester.pumpAndSettle();
expect(find.text('Invite friends'), findsWidgets);
expect(find.text('Start hosting'), findsOneWidget);
await tester.tapAt(const Offset(5, 790));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('strip-leave')));
await tester.pumpAndSettle();
expect(find.text('Leave server?'), findsNothing);
expect(session.route.value, AppRoute.home);
expect(session.solo.value, isFalse);
});
testWidgets('compact below 1100 px: 3 avatars, no server text', (tester) async {
await pumpShell(tester, const Size(1000, 800));
online(party, media: false);