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

+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);