Jellyfin in the app: account linking and library browser

Integrations links several Jellyfin servers (Quick Connect or
password, app-obtained tokens only, status/reconnect/sign out). The
Host tab's Jellyfin source is a full browser: continue watching, next
up, latest per library, library grids with sort/filter/infinite
scroll, series pages with smart resume/next-up, details, grouped
search, inspector pane on wide screens, keyboard navigation.
MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin
pick is interim (host's stream URL) until per-viewer playback lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:05:46 +03:00
1 parent 45f4c1bc84
commit 773a04369e
27 files changed
+5169 -228

No files matched your search

+6 -5
View File
@@ -85,12 +85,12 @@ void main() {
expect(c.source, MediaKind.file);
c.pasteText(' https://example.com/a.mkv ');
expect(c.source, MediaKind.link);
expect(c.selection, (mediaPath: '', linkUrl: 'https://example.com/a.mkv'));
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
c.pasteText('/home/me/b.mkv');
expect(c.source, MediaKind.file);
expect(c.selection, (mediaPath: '/home/me/b.mkv', linkUrl: ''));
expect(c.selection, const FileMedia('/home/me/b.mkv'));
c.source = MediaKind.jellyfin;
expect(c.selection, (mediaPath: '', linkUrl: ''));
expect(c.selection, isNull);
c.dispose();
});
@@ -238,10 +238,11 @@ void main() {
expect(find.text('Paste'), findsOneWidget);
await tester.tap(find.text('Jellyfin'));
await tester.pumpAndSettle();
expect(find.text('Link your Jellyfin in Integrations — coming soon'), findsOneWidget);
// No linked server: sign-in right in the picker.
expect(find.text('Add a Jellyfin server'), findsOneWidget);
await tester.tap(find.text('Open Integrations'));
await tester.pumpAndSettle();
expect(find.text('Link account'), findsOneWidget);
expect(find.text('Add server'), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -0,0 +1,374 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.dart';
import 'package:watch_together/src/screens/home/host_tab.dart' show interimJellyfinLink, mediaSummary;
import 'package:watch_together/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
String jellyfinFixture(String name) =>
File('../../packages/core/test/fixtures/jellyfin/$name.json').readAsStringSync();
const fixtureServerId = '122fce17c5b9413088af4be266bad6f4';
const fixtureUserId = 'f00703a1d1ac44fe8a8a0ddf2ca05757';
const arifuretaId = '550eff23b37ec61082c988678ccc214c';
const season2Id = '331a23499d051bc288d281f2c72557a9';
typedef FakeHandler = JellyfinHttpResponse Function(Uri uri, String? body);
/// Fake Jellyfin: routes by "METHOD /path" to fixtures, filters episode and
/// next-up lists by their query like the server does, records requests.
/// [approved] flips the Quick Connect state.
class FakeJellyfin implements JellyfinHttp {
final requests = <({String method, Uri uri, Map<String, String> headers})>[];
final routes = <String, FakeHandler>{};
bool approved = false;
bool offline = false;
int meStatus = 200;
FakeJellyfin() {
fixture('GET', '/System/Info/Public', 'pub');
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
fixture('POST', '/QuickConnect/Initiate', 'quickconnect_initiate');
on('GET', '/QuickConnect/Connect', (_, _) => JellyfinHttpResponse(
200, jsonEncode({'Authenticated': approved, 'Secret': 's', 'Code': '437392'})));
fixture('POST', '/Users/AuthenticateWithQuickConnect', 'auth_result');
on('POST', '/Users/AuthenticateByName', (_, body) {
final pw = (jsonDecode(body!) as Map)['Pw'];
return pw == 'right'
? JellyfinHttpResponse(200, jellyfinFixture('auth_result'))
: const JellyfinHttpResponse(401, '');
});
on('GET', '/Users/Me', (_, _) => JellyfinHttpResponse(
meStatus, meStatus == 200 ? jellyfinFixture('me') : ''));
on('POST', '/Sessions/Logout', (_, _) => const JellyfinHttpResponse(204, ''));
fixture('GET', '/UserViews', 'views');
on('GET', '/UserItems/Resume', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('resume')));
on('GET', '/Shows/NextUp', (uri, _) {
final series = uri.queryParameters['seriesId'];
if (series == null) return JellyfinHttpResponse(200, jellyfinFixture('nextup'));
// Arifureta's next up: S2E4.
final eps = _items('episodes').where((e) => e['SeriesId'] == series).toList();
return _page(eps.isEmpty ? const [] : [eps[3]]);
});
fixture('GET', '/Items/Latest', 'latest_shows');
on('GET', '/Items', (uri, _) {
final q = uri.queryParameters;
if ((q['searchTerm'] ?? '').isNotEmpty) {
return q['searchTerm']!.toLowerCase().startsWith('ari')
? JellyfinHttpResponse(200, jellyfinFixture('search'))
: _page(const []);
}
return JellyfinHttpResponse(
200, jellyfinFixture(q['includeItemTypes'] == 'Movie' ? 'movies_page' : 'series_page'));
});
on('GET', '/Items/$arifuretaId', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('series_detail')));
on('GET', '/Items/b18826be994070a79f9b52bc0f66f052',
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('episode_detail')));
on('GET', '/Shows/$arifuretaId/Seasons', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('seasons')));
on('GET', '/Shows/$arifuretaId/Episodes', (uri, _) {
final season = uri.queryParameters['seasonId'];
return _page(_items('episodes').where((e) => season == null || e['SeasonId'] == season).toList());
});
}
static List<Map<String, dynamic>> _items(String fixture) =>
((jsonDecode(jellyfinFixture(fixture)) as Map)['Items'] as List).cast<Map<String, dynamic>>();
static JellyfinHttpResponse _page(List<Object> items) =>
JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length}));
void on(String method, String path, FakeHandler h) => routes['$method $path'] = h;
void fixture(String method, String path, String name) =>
on(method, path, (_, _) => JellyfinHttpResponse(200, jellyfinFixture(name)));
int count(String method, String path) =>
requests.where((r) => r.method == method && r.uri.path == path).length;
@override
Future<JellyfinHttpResponse> send(String method, Uri uri,
{Map<String, String> headers = const {}, String? body}) async {
requests.add((method: method, uri: uri, headers: headers));
if (offline) throw JellyfinException('$method ${uri.path}: connection refused');
final h = routes['$method ${uri.path}'];
return h == null ? const JellyfinHttpResponse(404, '') : h(uri, body);
}
@override
void close() {}
}
JellyfinAuthResult fixtureAuth() =>
JellyfinAuthResult.fromJson(jsonDecode(jellyfinFixture('auth_result')) as Map<String, dynamic>);
JellyfinServerInfo fixtureServer() =>
JellyfinServerInfo.fromJson(jsonDecode(jellyfinFixture('pub')) as Map<String, dynamic>);
/// Accounts on a fake server with one linked account (no validation run).
Future<(JellyfinAccounts, FakeJellyfin)> linkedAccounts({FakeJellyfin? http}) async {
final fake = http ?? FakeJellyfin();
final accounts = JellyfinAccounts(
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test PC');
await accounts.ensureLoaded(validate: false);
await accounts.link(
baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
return (accounts, fake);
}
void main() {
group('accounts', () {
test('persist in their Hive box with a stable device id', () async {
final dir = Directory.systemTemp.createTempSync('jf_accounts_');
addTearDown(() => dir.deleteSync(recursive: true));
Hive.init(dir.path);
final fake = FakeJellyfin();
final a = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
await a.ensureLoaded(validate: false);
final deviceId = a.deviceId;
expect(deviceId, hasLength(32));
await a.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
await a.rememberPick(const JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 'Ep'));
await Hive.close();
final b = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
await b.ensureLoaded(validate: false);
expect(b.deviceId, deviceId);
final acc = b.accounts.single;
expect(acc.serverId, fixtureServerId);
expect(acc.serverName, 'Example Server');
expect(acc.userName, 'testuser');
expect(acc.token, 'TEST_ACCESS_TOKEN');
expect(b.defaultServerId, fixtureServerId);
expect(b.lastPick?.itemId, 'abc');
// The header names this app and install.
expect(b.clientInfo.client, 'Watch Together');
expect(b.clientInfo.deviceId, deviceId);
await Hive.close();
});
test('token check: online, expired stays linked, offline is not a sign-out', () async {
final (accounts, fake) = await linkedAccounts();
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.online);
expect(fake.requests.last.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
fake.meStatus = 401;
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.expired);
expect(accounts.accounts, hasLength(1));
fake.offline = true;
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.offline);
expect(accounts.errorOf(fixtureServerId), contains('connection refused'));
expect(accounts.accounts, hasLength(1));
});
test('ensureLoaded validates in the background', () async {
final fake = FakeJellyfin()..meStatus = 401;
final store = MemoryJellyfinStore();
final first = JellyfinAccounts(store: store, httpFactory: () => fake);
await first.ensureLoaded(validate: false);
await first.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
final again = JellyfinAccounts(store: store, httpFactory: () => fake);
await again.ensureLoaded();
await pumpEventQueue();
expect(again.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
});
test('sign out revokes the token and unlinks; default moves on', () async {
final (accounts, fake) = await linkedAccounts();
final otherAuth = JellyfinAuthResult(
accessToken: 'T2',
user: const JellyfinUser(id: 'u2', name: 'other'),
serverId: 'other-server');
await accounts.link(
baseUrl: 'https://two.invalid',
server: const JellyfinServerInfo(id: 'other-server', name: 'Two', version: '10.11'),
auth: otherAuth);
expect(accounts.defaultServerId, fixtureServerId);
await accounts.setDefault('other-server');
expect(accounts.defaultServerId, 'other-server');
await accounts.signOut('other-server');
expect(fake.count('POST', '/Sessions/Logout'), 1);
expect(fake.requests.last.headers['Authorization'], contains('Token="T2"'));
expect(accounts.accounts.map((a) => a.serverId), [fixtureServerId]);
expect(accounts.defaultServerId, fixtureServerId);
});
test('signing in again replaces the account and revokes the old token', () async {
final (accounts, fake) = await linkedAccounts();
final fresh = JellyfinAuthResult(
accessToken: 'NEW', user: fixtureAuth().user, serverId: fixtureServerId);
await accounts.link(baseUrl: 'https://alt.invalid', server: fixtureServer(), auth: fresh);
await pumpEventQueue();
expect(accounts.accounts.single.token, 'NEW');
expect(accounts.accounts.single.urls, ['https://alt.invalid', 'https://example.invalid']);
expect(fake.count('POST', '/Sessions/Logout'), 1);
});
});
group('sign-in flow', () {
late FakeJellyfin fake;
late JellyfinAccounts accounts;
setUp(() async {
fake = FakeJellyfin();
accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
await accounts.ensureLoaded(validate: false);
});
test('bad address and non-Jellyfin servers stay on the address step', () async {
final flow = JellyfinSignIn(accounts, pollInterval: Duration.zero);
await flow.probe(' ');
expect(flow.step, JellyfinSignInStep.address);
expect(flow.error, isNotNull);
fake.offline = true;
await flow.probe('jelly.example');
expect(flow.step, JellyfinSignInStep.address);
expect(flow.error, contains('No Jellyfin server'));
});
test('Quick Connect: code, poll until approved, linked', () async {
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
await flow.probe('example.invalid');
expect(flow.step, JellyfinSignInStep.method);
expect(flow.server?.name, 'Example Server');
expect(flow.quickConnectAvailable, isTrue);
final done = flow.startQuickConnect();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(flow.code, '437392');
expect(flow.step, JellyfinSignInStep.quickConnect);
fake.approved = true;
await done;
expect(flow.step, JellyfinSignInStep.done);
expect(accounts.accounts.single.userName, 'testuser');
expect(accounts.accounts.single.urls, ['https://example.invalid']);
});
test('Quick Connect cancel stops polling', () async {
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
await flow.probe('https://example.invalid');
final done = flow.startQuickConnect();
await Future<void>.delayed(const Duration(milliseconds: 20));
flow.cancel();
await done;
final polls = fake.count('GET', '/QuickConnect/Connect');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
expect(flow.step, JellyfinSignInStep.method);
expect(accounts.isEmpty, isTrue);
});
test('password: wrong then right', () async {
final flow = JellyfinSignIn(accounts);
await flow.probe('https://example.invalid');
flow.usePassword();
await flow.signIn('testuser', 'wrong');
expect(flow.step, JellyfinSignInStep.password);
expect(flow.error, 'Wrong username or password');
await flow.signIn('testuser', 'right');
expect(flow.step, JellyfinSignInStep.done);
expect(accounts.accounts.single.token, 'TEST_ACCESS_TOKEN');
});
test('reconnect refuses a different server', () async {
final (linked, _) = await linkedAccounts();
final other = JellyfinAccount(
serverId: 'someone-else', serverName: 'X', urls: const ['https://example.invalid'],
userId: 'u', userName: 'n', token: 't');
final flow = JellyfinSignIn(linked, reconnect: other);
await flow.probe(other.baseUrl);
expect(flow.step, JellyfinSignInStep.address);
expect(flow.error, contains('different Jellyfin server'));
});
});
group('picked media', () {
test('controller selection is a PickedMedia per source', () {
final c = MediaPickerController();
expect(c.selection, isNull);
c.pasteText('https://example.com/a.mkv');
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
c.pasteText('/home/me/b.mkv');
expect(c.selection, const FileMedia('/home/me/b.mkv'));
c.source = MediaKind.jellyfin;
expect(c.selection, isNull);
const m = JellyfinMedia(
serverId: 's', baseUrl: 'https://j.example', itemId: 'i', title: 'Ep', subtitle: 'Show · S1E6');
c.pickJellyfin(m);
expect(c.selection, m);
c.source = MediaKind.file;
expect(c.selection, const FileMedia('/home/me/b.mkv'));
expect(c.jellyfin, m, reason: 'kept while another source shows');
c.dispose();
});
test('a saved Jellyfin pick starts on the Jellyfin source', () {
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin);
expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file);
});
test('JellyfinMedia round-trips and labels', () {
const m = JellyfinMedia(
serverId: 's',
baseUrl: 'https://j.example',
itemId: 'i',
mediaSourceId: 'src',
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6',
runtime: Duration(minutes: 23));
final back = JellyfinMedia.fromJson(jsonDecode(jsonEncode(m.toJson())))!;
expect(back, m);
expect(back.runtime, const Duration(minutes: 23));
expect(back.subtitle, 'Black Summoner · S1E6');
expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6');
expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv');
expect(mediaSummary(null), 'No media selected (optional).');
expect(JellyfinMedia.fromJson({'serverId': 1}), isNull);
});
test('pickFor labels an episode "Series · S1E6"', () async {
final (accounts, _) = await linkedAccounts();
final ep = JellyfinItem.fromJson(jsonDecode(jellyfinFixture('episode_detail')) as Map<String, dynamic>);
final m = pickFor(accounts.accounts.single, ep);
expect(m.serverId, fixtureServerId);
expect(m.itemId, 'b18826be994070a79f9b52bc0f66f052');
expect(m.subtitle, 'Black Summoner · S1E6');
expect(m.runtime, isNotNull);
});
test('INTERIM: the host plays a Jellyfin pick as its authorised stream link', () async {
final (accounts, _) = await linkedAccounts();
const m = JellyfinMedia(
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 't');
final url = Uri.parse(interimJellyfinLink(accounts, m));
expect(url.path, '/Videos/abc/stream');
expect(url.queryParameters['static'], 'true');
expect(url.queryParameters['api_key'], 'TEST_ACCESS_TOKEN');
expect(
() => interimJellyfinLink(
accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
throwsFormatException);
accounts.markExpired(fixtureServerId);
expect(() => interimJellyfinLink(accounts, m), throwsFormatException);
});
});
group('format', () {
test('time left and subtitles', () {
final resume = JellyfinItemPage.fromJson(jsonDecode(jellyfinFixture('resume')) as Map<String, dynamic>).items;
final ep = resume[1];
expect(timeLeft(ep), endsWith('min left'));
expect(cardSubtitle(ep), startsWith('S1E10 · '));
expect(progressOf(ep), greaterThan(0));
expect(formatRuntime(const Duration(minutes: 102)), '1 h 42 min');
expect(formatRuntime(const Duration(minutes: 24)), '24 min');
});
});
}
@@ -0,0 +1,313 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/screens/home/connect_flow.dart';
import 'package:watch_together/src/screens/home/host_tab.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
Future<void> setSize(WidgetTester tester, Size size) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
}
Widget app(Widget child) => MaterialApp(
theme: ThemeData.dark(),
home: Scaffold(body: Padding(padding: const EdgeInsets.all(16), child: child)),
);
/// Lets fake requests, debounces and image errors settle without waiting
/// on the progress spinners (pumpAndSettle would never return).
Future<void> settle(WidgetTester tester, [int frames = 6]) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
}
/// MediaPicker on the Jellyfin source with one linked fake server.
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker(
WidgetTester tester, Size size) async {
await setSize(tester, size);
// Memory store + fake transport: no real IO, so no runAsync (futures
// made there would resume outside the test's fake clock).
final (accounts, fake) = await linkedAccounts();
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
await settle(tester);
return (c, accounts, fake);
}
Future<void> openArifureta(WidgetTester tester) async {
await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta');
await settle(tester, 10);
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('card-$arifuretaId')));
await settle(tester);
}
void main() {
testWidgets('home rows: continue watching with time left, next up, latest', (tester) async {
await pumpPicker(tester, const Size(1600, 2400));
expect(find.text('Continue watching'), findsOneWidget);
expect(find.text('Next up'), findsOneWidget);
expect(find.text('Latest in Shows'), findsOneWidget);
expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget);
// Continue watching: series name on top, "S1E10 · N min left · title" below.
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets);
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget);
expect(find.text('Black Summoner'), findsWidgets);
expect(find.text('Monogatari'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('series page: smart next button, season switching, pick an episode',
(tester) async {
final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000));
await openArifureta(tester);
// Opens on the season holding next up (S2E4).
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
expect(s2.selected, isTrue);
expect(find.text('1. Irregular'), findsOneWidget);
expect(find.textContaining('Next up: S2E4'), findsOneWidget);
await tester.tap(find.widgetWithText(ChoiceChip, 'Season 3'));
await settle(tester);
expect(find.text('1. Irregular'), findsNothing);
expect(find.text('1. Haulias Assemble'), findsWidgets);
// Picking an episode fills the controller.
await tester.tap(find.text('2. Roar of Revolution').first);
await tester.pump();
final sel = c.selection;
expect(sel, isA<JellyfinMedia>());
sel as JellyfinMedia;
expect(sel.serverId, fixtureServerId);
expect(sel.title, 'Roar of Revolution');
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2");
expect(sel.baseUrl, 'https://example.invalid');
// The smart button picks next up.
await tester.tap(find.byKey(const ValueKey('series-next')));
await tester.pump();
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4'));
expect(tester.takeException(), isNull);
});
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async {
await pumpPicker(tester, const Size(1600, 1000));
final search = find.byKey(const ValueKey('jf-search'));
// Focus a card, then arrow right moves to the next card.
final first = find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3'));
Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first))
.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!;
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7');
// "/" jumps to search.
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
await tester.pump();
expect(tester.widget<TextField>(search).focusNode!.hasFocus, isTrue);
await tester.enterText(search, 'arifureta');
await settle(tester, 10);
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byKey(const ValueKey('search-results')), findsNothing);
expect(find.text('Continue watching'), findsOneWidget);
// Into the series and back with Esc.
await openArifureta(tester);
expect(find.byKey(const ValueKey('series-next')), findsOneWidget);
expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(find.byKey(const ValueKey('series-next')), findsNothing);
expect(find.text('Continue watching'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('library grid with sort and filters', (tester) async {
final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000));
await tester.tap(find.widgetWithText(ActionChip, 'Shows'));
await settle(tester);
expect(find.text('218 items'), findsOneWidget);
expect(find.text('Alma-chan Wants to Be a Family!'), findsWidgets);
await tester.tap(find.byKey(const ValueKey('filter-unplayed')));
await settle(tester);
final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
expect(q['filters'], 'IsUnplayed');
expect(q['includeItemTypes'], 'Series');
expect(q['recursive'], 'true');
await tester.tap(find.byKey(const ValueKey('library-sort')));
await settle(tester, 2);
await tester.tap(find.byKey(const ValueKey('sort-added')));
await settle(tester);
final q2 = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
expect(q2['sortBy'], startsWith('DateCreated'));
expect(q2['sortOrder'], 'Descending');
expect(tester.takeException(), isNull);
});
testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns',
(tester) async {
await pumpPicker(tester, const Size(820, 700));
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
expect(tester.takeException(), isNull);
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')));
await pumpPicker(tester, const Size(3440, 1440));
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
expect(tester.takeException(), isNull);
// Rows fill the width with whole cards.
final wideCards = find.byWidgetPredicate((w) =>
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
expect(wideCards.evaluate().length, greaterThan(10));
expect(narrowCard.width, greaterThan(200));
// Hover feeds the inspector.
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.addPointer(location: Offset.zero);
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first));
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('jf-inspector')),
matching: find.textContaining('Rice and Thieves and Heroes')),
findsOneWidget);
});
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
(tester) async {
await setSize(tester, const Size(1200, 900));
final fake = FakeJellyfin();
final accounts = JellyfinAccounts(
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test');
await accounts.ensureLoaded(validate: false);
await tester.pumpWidget(app(SingleChildScrollView(
child: JellyfinAccountsPanel(
accounts: accounts, pollInterval: const Duration(milliseconds: 100)))));
await tester.tap(find.byKey(const ValueKey('jf-add-server')));
await tester.pump();
await tester.enterText(find.byKey(const ValueKey('jf-address')), 'example.invalid');
await tester.tap(find.byKey(const ValueKey('jf-connect')));
await settle(tester, 2);
expect(find.text('Example Server'), findsOneWidget);
expect(find.textContaining('Jellyfin 10.11.11'), findsOneWidget);
// Cancel: the code goes away and polling stops.
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
await settle(tester, 2);
expect(find.text('437392'), findsOneWidget);
expect(find.textContaining('Profile → Quick Connect'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('jf-qc-cancel')));
await settle(tester, 4);
final polls = fake.count('GET', '/QuickConnect/Connect');
await settle(tester, 6);
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
expect(find.text('437392'), findsNothing);
// Again, and approve it.
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
await settle(tester, 2);
expect(find.text('437392'), findsOneWidget);
fake.approved = true;
await settle(tester, 6);
expect(accounts.accounts.single.userName, 'testuser');
expect(find.byKey(const ValueKey('jf-account-$fixtureServerId')), findsOneWidget);
expect(find.text('testuser on Example Server'), findsOneWidget);
expect(find.byKey(const ValueKey('jf-sign-in')), findsNothing);
// Expired token: Reconnect shows, the account stays.
accounts.markExpired(fixtureServerId);
await tester.pump();
expect(find.byKey(const ValueKey('jf-reconnect-$fixtureServerId')), findsOneWidget);
// Sign out asks, revokes, unlinks.
await tester.tap(find.byKey(const ValueKey('jf-sign-out-$fixtureServerId')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('jf-sign-out-confirm')));
await settle(tester, 4);
expect(fake.count('POST', '/Sessions/Logout'), 1);
expect(accounts.isEmpty, isTrue);
expect(tester.takeException(), isNull);
});
testWidgets('no linked server: the picker offers sign-in inline', (tester) async {
await setSize(tester, const Size(1200, 900));
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
await accounts.ensureLoaded(validate: false);
final c = MediaPickerController(source: MediaKind.jellyfin);
addTearDown(c.dispose);
await tester.pumpWidget(app(SingleChildScrollView(
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
await tester.pump();
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
expect(find.text('Open Integrations'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('expired account shows a reconnect banner in the browser', (tester) async {
final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900));
accounts.markExpired(fixtureServerId);
await tester.pump();
expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget);
expect(find.text('Reconnect'), findsOneWidget);
expect(find.byType(JellyfinBrowser), findsOneWidget);
});
testWidgets('Host tab: browser beside the server column, enlarged with a host bar, stacked when narrow',
(tester) async {
await setSize(tester, const Size(1800, 1000));
final (accounts, _) = await linkedAccounts();
const pick = JellyfinMedia(
serverId: fixtureServerId,
baseUrl: 'https://example.invalid',
itemId: 'b18826be994070a79f9b52bc0f66f052',
title: 'Rice and Thieves and Heroes',
subtitle: 'Black Summoner · S1E6');
await accounts.rememberPick(pick);
final fields = HostFields(ConnectForm(), jellyfin: accounts);
final flow = ConnectFlow();
addTearDown(() {
fields.dispose();
flow.dispose();
});
await tester.pumpWidget(MaterialApp(
theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields))));
await settle(tester);
// The saved pick comes back on the Jellyfin source.
expect(fields.media.selection, pick);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(find.byKey(const ValueKey('host-column')), findsOneWidget);
expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('jf-expand')));
await settle(tester, 2);
expect(find.byKey(const ValueKey('host-column')), findsNothing);
expect(find.byKey(const ValueKey('host-bar')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const ValueKey('jf-expand')));
await settle(tester, 2);
tester.view.physicalSize = const Size(800, 900);
await settle(tester, 2);
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
expect(find.byType(JellyfinBrowser), findsOneWidget);
expect(tester.takeException(), isNull);
});
}