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
+5129 -188

No files matched your search

+29 -7
View File
@@ -33,9 +33,9 @@ telling you to update.
## Host from the app
Home, Host tab. Pick what to watch (optional): Link (paste an http(s) URL;
Ctrl+V outside a field pastes it in) or File (path or Browse). Jellyfin is
coming. In the column beside it set a port and an optional password (blank =
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank =
anyone can join), then Host. Your name and avatar come from your profile
(the chip at the top left). The app starts a
server inside itself and joins it; you are the host and nobody can kick you.
@@ -84,7 +84,7 @@ x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
Profile, hardware decoding (auto, off or custom; the env var
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
updates, video shaders (see below), and the keyboard shortcuts (read-only).
Integrations (Jellyfin) shows "coming soon". About shows the version.
Integrations links Jellyfin servers (below). About shows the version.
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close
@@ -102,9 +102,31 @@ Only the host needs the port reachable.
## Jellyfin
Pasting a Jellyfin direct-play link works today like any link. Proper
Jellyfin support (per-person login, watched status, library browsing) is
planned: see [docs/ROADMAP.md](docs/ROADMAP.md).
Integrations > Jellyfin > Add server: type the address, then sign in with
Quick Connect (the app shows a code; approve it in Jellyfin under Profile >
Quick Connect) or your username and password. Several servers can be
linked; each shows its status, and a star picks the default. Sign out
revokes the app's sign-in on the server. If a sign-in expires the server
shows "Reconnect" instead of disappearing.
The Host tab's Jellyfin source is a library browser: Continue watching (with
time left), Next up and the latest per library on the start page; library
grids with sort (name, date added, release date, rating, random) and
Unwatched / Favorites filters; series pages with seasons, episode progress
and a "Next up" button; details with the file's tracks; search. Clicking a
movie or episode picks it; the button in the top right enlarges the browser
and moves port, password and Host into a bar below it. On wide windows a
side pane shows details of whatever is under the mouse.
Keys in the browser: arrows move, Enter picks or opens, I = details,
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
search.
For now a hosted Jellyfin pick is shared as a stream link carrying the
host's sign-in, so everyone watches through the host's account. Per-person
playback, watched status and progress sync are planned: see
[docs/ROADMAP.md](docs/ROADMAP.md). Pasting a Jellyfin direct-play link
still works like any link.
## Video quality and shaders
+500
View File
@@ -0,0 +1,500 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart' show appName;
import '../update/updater.dart' show appVersion;
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
//
// Only tokens this app obtained itself (Quick Connect or password through
// [JellyfinSignIn]) are ever stored or used: Jellyfin overwrites a token's
// device name and app version with whatever header comes with it, so
// borrowing another client's token would rename that client's device.
const jellyfinBoxName = 'jellyfin';
const _keyDeviceId = 'deviceId';
const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick';
/// One signed-in user on one server.
class JellyfinAccount {
final String serverId;
final String serverName;
final String serverVersion;
/// Addresses this server was reached at, preferred first.
final List<String> urls;
final String userId;
final String userName;
final String? userImageTag;
final String token;
const JellyfinAccount({
required this.serverId,
required this.serverName,
this.serverVersion = '',
required this.urls,
required this.userId,
required this.userName,
this.userImageTag,
required this.token,
});
String get baseUrl => urls.first;
JellyfinUser get user =>
JellyfinUser(id: userId, name: userName, serverId: serverId, primaryImageTag: userImageTag);
/// "Example Server" or the host when the server has no name.
String get displayName =>
serverName.isNotEmpty ? serverName : (Uri.tryParse(baseUrl)?.host ?? baseUrl);
JellyfinAccount copyWith({
String? serverName,
String? serverVersion,
List<String>? urls,
String? userName,
String? userImageTag,
}) =>
JellyfinAccount(
serverId: serverId,
serverName: serverName ?? this.serverName,
serverVersion: serverVersion ?? this.serverVersion,
urls: urls ?? this.urls,
userId: userId,
userName: userName ?? this.userName,
userImageTag: userImageTag ?? this.userImageTag,
token: token,
);
Map<String, Object?> toJson() => {
'serverId': serverId,
'serverName': serverName,
'serverVersion': serverVersion,
'urls': urls,
'userId': userId,
'userName': userName,
'userImageTag': ?userImageTag,
'token': token,
};
static JellyfinAccount? fromJson(Object? j) {
if (j is! Map) return null;
final sid = j['serverId'], uid = j['userId'], token = j['token'], urls = j['urls'];
if (sid is! String || uid is! String || token is! String || urls is! List) return null;
final list = urls.whereType<String>().toList();
if (sid.isEmpty || token.isEmpty || list.isEmpty) return null;
String s(Object? v) => v is String ? v : '';
return JellyfinAccount(
serverId: sid,
serverName: s(j['serverName']),
serverVersion: s(j['serverVersion']),
urls: list,
userId: uid,
userName: s(j['userName']),
userImageTag: j['userImageTag'] is String ? j['userImageTag'] as String : null,
token: token,
);
}
}
enum JellyfinAccountStatus {
/// Not checked yet this run.
unknown,
checking,
online,
/// Unreachable (network error); the token may still be fine.
offline,
/// The server rejected the token: sign in again ("Reconnect").
expired,
}
/// Key-value persistence behind [JellyfinAccounts]; tests use
/// [MemoryJellyfinStore].
abstract class JellyfinStore {
Future<void> open();
Object? get(String key);
Future<void> put(String key, Object? value);
}
class HiveJellyfinStore implements JellyfinStore {
Box? _box;
@override
Future<void> open() async => _box ??= await Hive.openBox(jellyfinBoxName);
@override
Object? get(String key) => _box?.get(key);
@override
Future<void> put(String key, Object? value) async {
final box = _box;
if (box == null) return;
value == null ? await box.delete(key) : await box.put(key, value);
}
}
class MemoryJellyfinStore implements JellyfinStore {
final Map<String, Object?> data;
MemoryJellyfinStore([Map<String, Object?>? data]) : data = data ?? {};
@override
Future<void> open() async {}
@override
Object? get(String key) => data[key];
@override
Future<void> put(String key, Object? value) async =>
value == null ? data.remove(key) : data[key] = value;
}
/// All linked Jellyfin servers, their live status and one authenticated
/// [JellyfinClient] each. [ensureLoaded] reads the box and then checks every
/// token in the background; a rejected token turns the account
/// [JellyfinAccountStatus.expired] (shown as "Reconnect"), never a silent
/// sign-out.
class JellyfinAccounts extends ChangeNotifier {
static final JellyfinAccounts instance = JellyfinAccounts();
JellyfinAccounts({
JellyfinStore? store,
this.httpFactory,
this.deviceName,
}) : _store = store ?? HiveJellyfinStore();
final JellyfinStore _store;
/// Transport for every client made here; null = real HTTP. Tests fake it.
final JellyfinHttp Function()? httpFactory;
/// Device name sent to Jellyfin; null = the hostname.
final String? deviceName;
List<JellyfinAccount> _accounts = const [];
final _status = <String, JellyfinAccountStatus>{};
final _errors = <String, String>{};
final _clients = <String, JellyfinClient>{};
final _memo = <String, ({DateTime at, Future<Object?> value})>{};
String? _defaultServerId;
String _deviceId = '';
JellyfinMedia? _lastPick;
Future<void>? _load;
bool _loaded = false;
bool _disposed = false;
List<JellyfinAccount> get accounts => _accounts;
bool get loaded => _loaded;
bool get isEmpty => _accounts.isEmpty;
JellyfinAccount? account(String serverId) =>
_accounts.where((a) => a.serverId == serverId).firstOrNull;
/// The chosen default, else the first linked server.
JellyfinAccount? get defaultAccount =>
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
String? get defaultServerId => defaultAccount?.serverId;
JellyfinAccountStatus statusOf(String serverId) =>
_status[serverId] ?? JellyfinAccountStatus.unknown;
/// Last failure text for an offline account.
String? errorOf(String serverId) => _errors[serverId];
/// The Host tab's last Jellyfin pick, restored on the next start.
JellyfinMedia? get lastPick => _lastPick;
/// Stable per install: Jellyfin keys devices and tokens by it.
String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName,
device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion,
);
static String _hostname() {
try {
final h = Platform.localHostname;
return h.isEmpty ? 'Desktop' : h;
} catch (_) {
return 'Desktop';
}
}
Future<void> ensureLoaded({bool validate = true}) =>
_load ??= _loadNow(validate: validate);
Future<void> _loadNow({required bool validate}) async {
try {
await _store.open();
final id = _store.get(_keyDeviceId);
if (id is String && id.isNotEmpty) {
_deviceId = id;
} else {
_deviceId = _newDeviceId();
await _store.put(_keyDeviceId, _deviceId);
}
_accounts = _decodeAccounts(_store.get(_keyAccounts));
final def = _store.get(_keyDefault);
_defaultServerId = def is String ? def : null;
final pick = _store.get(_keyLastPick);
_lastPick = pick is String ? JellyfinMedia.fromJson(_tryJson(pick)) : null;
} catch (e, st) {
debugPrint('load jellyfin accounts failed: $e\n$st');
if (_deviceId.isEmpty) _deviceId = _newDeviceId();
}
_loaded = true;
_notify();
if (validate) unawaited(validateAll());
}
static String _newDeviceId() {
final r = Random.secure();
return [for (var i = 0; i < 16; i++) r.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
}
static Object? _tryJson(String s) {
try {
return jsonDecode(s);
} catch (_) {
return null;
}
}
static List<JellyfinAccount> _decodeAccounts(Object? raw) {
if (raw is! String) return const [];
final list = _tryJson(raw);
if (list is! List) return const [];
final out = <JellyfinAccount>[];
for (final j in list) {
final a = JellyfinAccount.fromJson(j);
if (a != null && out.every((o) => o.serverId != a.serverId)) out.add(a);
}
return out;
}
Future<void> _save() async {
try {
await _store.put(_keyAccounts, jsonEncode([for (final a in _accounts) a.toJson()]));
await _store.put(_keyDefault, _defaultServerId);
} catch (e, st) {
debugPrint('save jellyfin accounts failed: $e\n$st');
}
}
void _notify() {
if (!_disposed) notifyListeners();
}
// ------------------------------------------------------------- clients
/// A client without a token, for probing an address and signing in.
/// The caller closes it.
JellyfinClient anonymousClient(String baseUrl) =>
JellyfinClient(baseUrl: baseUrl, clientInfo: clientInfo, http: httpFactory?.call());
/// The signed-in client of a linked server (null if not linked). Shared;
/// do not close it.
JellyfinClient? clientFor(String serverId) {
final a = account(serverId);
if (a == null) return null;
final c = _clients[serverId];
if (c != null && c.baseUrl == a.baseUrl && c.accessToken == a.token) return c;
c?.close();
return _clients[serverId] = JellyfinClient(
baseUrl: a.baseUrl,
clientInfo: clientInfo,
accessToken: a.token,
userId: a.userId,
http: httpFactory?.call(),
);
}
// ---------------------------------------------------------- validation
Future<void> validateAll() => Future.wait([for (final a in _accounts) validate(a.serverId)]);
/// Checks the saved token. Tries each known address in order and moves
/// the first that answers to the front.
Future<JellyfinAccountStatus> validate(String serverId) async {
final start = account(serverId);
if (start == null) return JellyfinAccountStatus.unknown;
_status[serverId] = JellyfinAccountStatus.checking;
_notify();
Object? lastError;
for (final url in start.urls) {
final client = JellyfinClient(
baseUrl: url,
clientInfo: clientInfo,
accessToken: start.token,
userId: start.userId,
http: httpFactory?.call(),
);
try {
final user = await client.validateToken();
if (user == null) return _setStatus(serverId, JellyfinAccountStatus.expired);
final a = account(serverId);
if (a == null) return JellyfinAccountStatus.unknown;
_replace(a.copyWith(
userName: user.name.isEmpty ? null : user.name,
userImageTag: user.primaryImageTag,
urls: [url, ...a.urls.where((u) => u != url)],
));
unawaited(_save());
_errors.remove(serverId);
return _setStatus(serverId, JellyfinAccountStatus.online);
} catch (e) {
lastError = e;
} finally {
client.close();
}
}
_errors[serverId] = _short(lastError);
return _setStatus(serverId, JellyfinAccountStatus.offline);
}
static String _short(Object? e) {
final s = '$e'.replaceFirst('JellyfinException: ', '');
return s.length > 160 ? '${s.substring(0, 160)}…' : s;
}
JellyfinAccountStatus _setStatus(String serverId, JellyfinAccountStatus s) {
_status[serverId] = s;
_notify();
return s;
}
/// A browse call got 401: the token is gone, ask to reconnect.
void markExpired(String serverId) {
if (statusOf(serverId) == JellyfinAccountStatus.expired) return;
_setStatus(serverId, JellyfinAccountStatus.expired);
}
void _replace(JellyfinAccount a) {
_accounts = [for (final o in _accounts) o.serverId == a.serverId ? a : o];
}
// ------------------------------------------------------- link / unlink
/// Stores a sign-in made by this app. Signing in again to a linked server
/// replaces its account (and best-effort revokes the old token).
Future<JellyfinAccount> link({
required String baseUrl,
required JellyfinServerInfo server,
required JellyfinAuthResult auth,
}) async {
await ensureLoaded(validate: false);
final serverId = auth.serverId.isNotEmpty ? auth.serverId : server.id;
final old = account(serverId);
final a = JellyfinAccount(
serverId: serverId,
serverName: server.name,
serverVersion: server.version,
urls: [baseUrl, ...?old?.urls.where((u) => u != baseUrl)],
userId: auth.user.id,
userName: auth.user.name,
userImageTag: auth.user.primaryImageTag,
token: auth.accessToken,
);
if (old != null && old.token != a.token) unawaited(_revoke(old));
_accounts = old == null ? [..._accounts, a] : [for (final o in _accounts) o.serverId == serverId ? a : o];
_defaultServerId ??= serverId;
_status[serverId] = JellyfinAccountStatus.online;
_errors.remove(serverId);
_clearMemo(serverId);
await _save();
_notify();
return a;
}
Future<void> _revoke(JellyfinAccount a) async {
final c = JellyfinClient(
baseUrl: a.baseUrl,
clientInfo: clientInfo,
accessToken: a.token,
userId: a.userId,
http: httpFactory?.call());
try {
await c.logout();
} catch (_) {
} finally {
c.close();
}
}
/// Revokes the token on the server (best effort: an offline server or an
/// already dead token still unlinks) and forgets the account.
Future<void> signOut(String serverId) async {
final a = account(serverId);
if (a == null) return;
final c = _clients.remove(serverId);
c?.close();
await _revoke(a).timeout(const Duration(seconds: 8), onTimeout: () {});
_accounts = [for (final o in _accounts) if (o.serverId != serverId) o];
_status.remove(serverId);
_errors.remove(serverId);
_clearMemo(serverId);
if (_defaultServerId == serverId) _defaultServerId = _accounts.firstOrNull?.serverId;
if (_lastPick?.serverId == serverId) await rememberPick(null);
await _save();
_notify();
}
Future<void> setDefault(String serverId) async {
if (account(serverId) == null || _defaultServerId == serverId) return;
_defaultServerId = serverId;
_notify();
await _save();
}
Future<void> rememberPick(JellyfinMedia? pick) async {
_lastPick = pick;
try {
await _store.put(_keyLastPick, pick == null ? null : jsonEncode(pick.toJson()));
} catch (e) {
debugPrint('save jellyfin pick failed: $e');
}
}
// --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch.
/// Failures are not kept; a 401 marks the account expired.
Future<T> cached<T>(String serverId, String key, Future<T> Function() load,
{Duration ttl = const Duration(minutes: 3)}) {
final k = '$serverId|$key';
final hit = _memo[k];
if (hit != null && DateTime.now().difference(hit.at) < ttl) {
return hit.value.then((v) => v as T);
}
final f = load();
_memo[k] = (at: DateTime.now(), value: f);
f.catchError((Object e) {
if (identical(_memo[k]?.value, f)) _memo.remove(k);
if (e is JellyfinException && e.isUnauthorized) markExpired(serverId);
throw e;
}).ignore();
return f;
}
void _clearMemo(String serverId) =>
_memo.removeWhere((k, _) => k.startsWith('$serverId|'));
/// Drops cached browse results (refresh button, after a pick).
void invalidate(String serverId) => _clearMemo(serverId);
@override
void dispose() {
_disposed = true;
for (final c in _clients.values) {
c.close();
}
_clients.clear();
super.dispose();
}
}
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browser.dart' show JellyfinAvatar, StatusDot, showReconnectDialog;
import 'sign_in_panel.dart';
/// Integrations > Jellyfin: linked servers (avatar, status, default,
/// reconnect, sign out) and the inline add-server flow.
class JellyfinAccountsPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final Duration pollInterval;
const JellyfinAccountsPanel({
super.key,
required this.accounts,
this.pollInterval = const Duration(seconds: 3),
});
@override
State<JellyfinAccountsPanel> createState() => _JellyfinAccountsPanelState();
}
class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
bool _adding = false;
@override
void initState() {
super.initState();
widget.accounts.ensureLoaded();
}
Future<void> _signOut(JellyfinAccount a) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Sign out of ${a.displayName}?'),
content: const Text(
'This app\'s sign-in is revoked on the server and the server is unlinked. '
'You can add it again any time.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
key: const ValueKey('jf-sign-out-confirm'),
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign out')),
],
),
);
if (ok == true) await widget.accounts.signOut(a.serverId);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: widget.accounts,
builder: (context, _) {
final list = widget.accounts.accounts;
return Panel(
key: const ValueKey('jf-accounts'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.video_library_outlined,
size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4),
const Text(
'Browse your libraries in the Host tab and put up movies and episodes. '
'Sign in with Quick Connect or your password; several servers are fine.',
style: TextStyle(color: Colors.grey),
),
],
),
),
const SizedBox(width: kGap),
if (!_adding)
FilledButton.icon(
key: const ValueKey('jf-add-server'),
onPressed: () => setState(() => _adding = true),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add server'),
),
],
),
if (list.isNotEmpty) const SizedBox(height: kGap),
for (final a in list)
_AccountTile(
accounts: widget.accounts,
account: a,
isDefault: widget.accounts.defaultServerId == a.serverId,
showDefault: list.length > 1,
onSignOut: () => _signOut(a),
),
if (_adding) ...[
const SizedBox(height: kGap),
const Divider(height: 1),
const SizedBox(height: kGap),
JellyfinSignInPanel(
accounts: widget.accounts,
pollInterval: widget.pollInterval,
onCancel: () => setState(() => _adding = false),
onDone: (_) => setState(() => _adding = false),
),
],
],
),
);
},
);
}
}
class _AccountTile extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final bool isDefault;
final bool showDefault;
final VoidCallback onSignOut;
const _AccountTile({
required this.accounts,
required this.account,
required this.isDefault,
required this.showDefault,
required this.onSignOut,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final status = accounts.statusOf(account.serverId);
final statusText = switch (status) {
JellyfinAccountStatus.online => 'Connected',
JellyfinAccountStatus.offline => 'Offline',
JellyfinAccountStatus.expired => 'Sign-in expired',
JellyfinAccountStatus.checking => 'Checking…',
JellyfinAccountStatus.unknown => 'Not checked',
};
return Padding(
key: ValueKey('jf-account-${account.serverId}'),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
JellyfinAvatar(accounts: accounts, account: account),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${account.userName} on ${account.displayName}', style: t.titleSmall),
Row(children: [
StatusDot(status: status),
const SizedBox(width: 6),
Flexible(
child: Text(
[
statusText,
if (account.serverVersion.isNotEmpty) 'Jellyfin ${account.serverVersion}',
account.baseUrl,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey),
),
),
]),
],
),
),
if (showDefault)
IconButton(
key: ValueKey('jf-default-${account.serverId}'),
tooltip: isDefault ? 'Default server' : 'Make default',
onPressed: () => accounts.setDefault(account.serverId),
icon: Icon(isDefault ? Icons.star : Icons.star_border,
color: isDefault ? Theme.of(context).colorScheme.primary : null),
),
if (status == JellyfinAccountStatus.expired)
FilledButton.tonal(
key: ValueKey('jf-reconnect-${account.serverId}'),
onPressed: () => showReconnectDialog(context, accounts, account),
child: const Text('Reconnect'),
)
else if (status == JellyfinAccountStatus.offline)
TextButton(
onPressed: () => accounts.validate(account.serverId),
child: const Text('Retry'),
),
const SizedBox(width: 4),
TextButton(
key: ValueKey('jf-sign-out-${account.serverId}'),
onPressed: onSignOut,
child: const Text('Sign out'),
),
],
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
import 'format.dart';
/// One level of the library browser's back stack.
sealed class BrowseRoute {
const BrowseRoute();
String get label;
/// Identity for keeping a page's state while it is in the stack.
String get key;
}
final class HomeRoute extends BrowseRoute {
const HomeRoute();
@override
String get label => 'Home';
@override
String get key => 'home';
}
/// A library or folder grid.
final class LibraryRoute extends BrowseRoute {
final JellyfinItem folder;
const LibraryRoute(this.folder);
@override
String get label => folder.name;
@override
String get key => 'lib:${folder.id}';
}
final class SeriesRoute extends BrowseRoute {
final JellyfinItem series;
/// Season to open on (from a season card or an episode's "open series").
final String? seasonId;
const SeriesRoute(this.series, {this.seasonId});
@override
String get label => series.name;
@override
String get key => 'series:${series.id}';
}
/// Movie / episode / video detail.
final class DetailRoute extends BrowseRoute {
final JellyfinItem item;
const DetailRoute(this.item);
@override
String get label => item.kind == JellyfinItemKind.episode
? (item.episodeLabel ?? item.name)
: item.name;
@override
String get key => 'item:${item.id}';
}
/// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinClient client;
/// Item id of the current pick on this server, highlighted everywhere.
final String? selectedItemId;
final ValueChanged<JellyfinItem> onPick;
final ValueChanged<BrowseRoute> onOpen;
/// Hover / focus: the inspector pane follows it.
final ValueChanged<JellyfinItem> onPeek;
const BrowseScope({
super.key,
required this.accounts,
required this.account,
required this.client,
required this.selectedItemId,
required this.onPick,
required this.onOpen,
required this.onPeek,
required super.child,
});
static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
/// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client));
/// Click / Enter on a card: playable items are picked, containers open.
void activate(JellyfinItem item) {
if (isPlayable(item)) {
onPick(item);
return;
}
switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season:
final sid = item.seriesId;
if (sid != null) {
onOpen(SeriesRoute(
JellyfinItem(id: sid, name: item.seriesName ?? item.name, type: 'Series'),
seasonId: item.id));
}
default:
onOpen(LibraryRoute(item));
}
}
/// Opens the series of an episode (or the item itself if it has none).
void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId;
if (sid == null) return;
onOpen(SeriesRoute(
JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'),
seasonId: episode.seasonId));
}
@override
bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId ||
old.account != account ||
old.client != client;
}
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
/// a spinner meanwhile, and a compact error with Retry on failure.
class Loader<T> extends StatefulWidget {
final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder;
final Widget? loading;
/// When false the error collapses to nothing (optional sections).
final bool showError;
const Loader({
super.key,
required this.load,
required this.builder,
this.loading,
this.showError = true,
});
@override
State<Loader<T>> createState() => _LoaderState<T>();
}
class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load();
void _retry() => setState(() => _future = widget.load());
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: _future,
builder: (context, snap) {
if (snap.hasData) return widget.builder(context, snap.data as T);
if (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry);
}
if (snap.connectionState == ConnectionState.done) {
return widget.builder(context, snap.data as T);
}
return widget.loading ??
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
},
);
}
}
class LoadError extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const LoadError({super.key, required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
final e = error;
final msg = e is JellyfinException
? (e.isUnauthorized ? 'Sign-in expired' : e.message)
: '$e';
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, size: 18, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
Flexible(
child: Text(msg.length > 160 ? '${msg.substring(0, 160)}…' : msg,
style: const TextStyle(color: Colors.grey)),
),
const SizedBox(width: 8),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
),
);
}
}
+546
View File
@@ -0,0 +1,546 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart';
import 'search_results.dart';
import 'series_page.dart';
import 'sign_in_panel.dart';
/// Browser width from which the inspector pane shows on the right.
const inspectorFrom = 1400.0;
const inspectorWidth = 340.0;
/// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back
/// keeps scroll position and loaded pages.
///
/// Keyboard: arrows move between cards, Enter picks / opens, I opens
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
/// Shown when given: toggles a bigger browser (the host hides its form).
final VoidCallback? onToggleExpanded;
final bool expanded;
final VoidCallback? onOpenIntegrations;
final Duration searchDebounce;
const JellyfinBrowser({
super.key,
required this.accounts,
required this.onPick,
this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations,
this.searchDebounce = const Duration(milliseconds: 300),
});
@override
State<JellyfinBrowser> createState() => _JellyfinBrowserState();
}
class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = '';
Timer? _debounce;
int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts;
@override
void initState() {
super.initState();
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
? widget.selected!.serverId
: _accounts.defaultServerId;
_accounts.addListener(_changed);
}
@override
void didUpdateWidget(JellyfinBrowser old) {
super.didUpdateWidget(old);
if (old.accounts != widget.accounts) {
old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed);
}
}
@override
void dispose() {
_accounts.removeListener(_changed);
_debounce?.cancel();
_search.dispose();
_searchFocus.dispose();
_body.dispose();
_peek.dispose();
super.dispose();
}
void _changed() {
if (mounted) setState(() {});
}
JellyfinAccount? get _account =>
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
void _switchServer(String serverId) {
if (serverId == _account?.serverId) return;
setState(() {
_serverId = serverId;
_stack = const [HomeRoute()];
_peek.value = null;
_clearSearch();
});
}
// ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_body.requestFocus();
}
bool _back() {
if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_body.requestFocus();
return true;
}
void _popTo(int index) {
setState(() {
_clearSearch();
_stack = _stack.sublist(0, index + 1);
});
_body.requestFocus();
}
void _onSearchChanged(String text) {
_debounce?.cancel();
_debounce = Timer(widget.searchDebounce, () {
if (mounted) setState(() => _term = text.trim());
});
}
void _clearSearch() {
_debounce?.cancel();
_search.clear();
_term = '';
}
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
final inField = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.escape) {
if (_search.text.isNotEmpty || _term.isNotEmpty) {
setState(_clearSearch);
_body.requestFocus();
return KeyEventResult.handled;
}
if (inField) {
_body.requestFocus();
return KeyEventResult.handled;
}
return _back() ? KeyEventResult.handled : KeyEventResult.ignored;
}
if (inField) return KeyEventResult.ignored;
if (e.character == '/' || (keys.isControlPressed && k == LogicalKeyboardKey.keyF)) {
_searchFocus.requestFocus();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.backspace ||
(keys.isAltPressed && k == LogicalKeyboardKey.arrowLeft) ||
k == LogicalKeyboardKey.browserBack) {
return _back() ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
}
// ------------------------------------------------------------------ build
Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
};
@override
Widget build(BuildContext context) {
final account = _account;
if (account == null) {
return const Center(child: Text('No Jellyfin server linked', style: TextStyle(color: Colors.grey)));
}
final client = _accounts.clientFor(account.serverId)!;
final status = _accounts.statusOf(account.serverId);
final sel = widget.selected;
final searching = _term.isNotEmpty;
final pages = Stack(
children: [
for (var i = 0; i < _stack.length; i++)
Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
visible: !searching && i == _stack.length - 1,
maintainState: true,
child: _page(_stack[i]),
),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
],
);
return BrowseScope(
accounts: _accounts,
account: account,
client: client,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick,
onOpen: _open,
onPeek: (item) => _peek.value = item,
child: Focus(
onKeyEvent: _onKey,
child: Column(
key: const ValueKey('jf-browser'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context, account),
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(builder: (context, box) {
final inspector = box.maxWidth >= inspectorFrom;
final body = FocusScope(node: _body, child: pages);
if (!inspector) return body;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: body),
const SizedBox(width: 16),
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
],
);
}),
),
],
),
),
);
}
Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context);
final t = theme.textTheme;
final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty;
if (i > 0) {
crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.chevron_right, size: 18, color: Colors.grey),
));
}
final label = Text(
_stack[i].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: last ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant,
fontWeight: last ? FontWeight.w600 : null),
);
crumbs.add(Flexible(
child: last
? label
: InkWell(
key: ValueKey('crumb-$i'),
borderRadius: BorderRadius.circular(6),
onTap: () => _popTo(i),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: label),
),
));
}
if (_term.isNotEmpty) {
crumbs
..add(const Icon(Icons.chevron_right, size: 18, color: Colors.grey))
..add(Flexible(
child: Text('Search “$_term”',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
}
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
onSelect: _switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
),
),
),
),
IconButton(
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
if (widget.onToggleExpanded != null)
IconButton(
key: const ValueKey('jf-expand'),
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
),
],
);
}
}
/// Current server (avatar + name); a menu when several are linked.
class _ServerSwitcher extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount current;
final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations;
const _ServerSwitcher({
required this.accounts,
required this.current,
required this.onSelect,
this.onOpenIntegrations,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final chip = Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
),
],
);
if (accounts.accounts.length < 2 && onOpenIntegrations == null) {
return Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: chip);
}
return MenuAnchor(
menuChildren: [
for (final a in accounts.accounts)
MenuItemButton(
key: ValueKey('server-${a.serverId}'),
leadingIcon: StatusDot(status: accounts.statusOf(a.serverId)),
trailingIcon: a.serverId == current.serverId ? const Icon(Icons.check, size: 18) : null,
onPressed: () => onSelect(a.serverId),
child: Text('${a.displayName} · ${a.userName}'),
),
if (onOpenIntegrations != null)
MenuItemButton(
leadingIcon: const Icon(Icons.add, size: 18),
onPressed: onOpenIntegrations,
child: const Text('Manage servers…'),
),
],
builder: (context, menu, _) => TextButton(
key: const ValueKey('jf-server-switch'),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
child: Row(mainAxisSize: MainAxisSize.min, children: [
chip,
const Icon(Icons.arrow_drop_down, size: 20),
]),
),
);
}
}
class StatusDot extends StatelessWidget {
final JellyfinAccountStatus status;
const StatusDot({super.key, required this.status});
@override
Widget build(BuildContext context) {
final color = switch (status) {
JellyfinAccountStatus.online => Colors.green,
JellyfinAccountStatus.offline => Colors.orange,
JellyfinAccountStatus.expired => Theme.of(context).colorScheme.error,
_ => Colors.grey,
};
return Container(
width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle));
}
}
class _StatusBanner extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinAccountStatus status;
const _StatusBanner({required this.accounts, required this.account, required this.status});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final expired = status == JellyfinAccountStatus.expired;
return Container(
key: const ValueKey('jf-status-banner'),
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: (expired ? scheme.errorContainer : Colors.orange.shade900).withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(expired ? Icons.lock_clock : Icons.cloud_off, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(expired
? 'Sign-in to ${account.displayName} expired. Reconnect to keep browsing.'
: "Can't reach ${account.displayName}: ${accounts.errorOf(account.serverId) ?? 'offline'}"),
),
if (expired)
FilledButton.tonal(
onPressed: () => showReconnectDialog(context, accounts, account),
child: const Text('Reconnect'),
)
else
TextButton(
onPressed: () => accounts.validate(account.serverId),
child: const Text('Retry'),
),
],
),
);
}
}
/// Sign in again to an expired account (same server, new token).
Future<void> showReconnectDialog(
BuildContext context, JellyfinAccounts accounts, JellyfinAccount account) {
return showDialog<void>(
context: context,
builder: (context) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(20),
child: JellyfinSignInPanel(
accounts: accounts,
reconnect: account,
onDone: (_) => Navigator.of(context).pop(),
onCancel: () => Navigator.of(context).pop(),
),
),
),
),
);
}
/// Small helper for other screens: the user's avatar on a linked server.
class JellyfinAvatar extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final double radius;
const JellyfinAvatar({super.key, required this.accounts, required this.account, this.radius = 18});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final initials = account.userName.isEmpty ? '?' : account.userName.characters.first.toUpperCase();
final fallback = CircleAvatar(
radius: radius,
backgroundColor: scheme.primaryContainer,
child: Text(initials, style: TextStyle(color: scheme.onPrimaryContainer)),
);
final client = accounts.clientFor(account.serverId);
final url = client?.userImageUrl(account.user, maxWidth: (radius * 4).round());
if (url == null) return fallback;
return ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: Image.network(url.toString(),
fit: BoxFit.cover,
cacheWidth: (radius * 4).round(),
errorBuilder: (_, _, _) => fallback),
),
);
}
}
+424
View File
@@ -0,0 +1,424 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
enum CardShape {
/// 2:3 poster (movies, series, latest).
poster(2 / 3, 150),
/// 16:9 thumbnail (continue watching, next up, episodes).
thumb(16 / 9, 260);
/// Image width / height.
final double aspect;
/// Smallest card width; rows and grids add columns past it.
final double minWidth;
const CardShape(this.aspect, this.minWidth);
}
/// Title + subtitle lines under a card image.
const cardTextHeight = 46.0;
const cardGap = 12.0;
/// Card image for [item] in [shape]: thumbs prefer the item's own
/// screenshot / thumb / backdrop, posters the primary (series poster for
/// episodes).
JellyfinImageRef? cardImage(JellyfinItem item, CardShape shape) {
if (shape == CardShape.poster) return item.image(JellyfinImageType.primary);
final own = item.ownImage(JellyfinImageType.primary);
final primaryIsWide = (item.primaryImageAspectRatio ?? 0) > 1.2 ||
item.kind == JellyfinItemKind.episode ||
item.kind == JellyfinItemKind.video;
if (own != null && primaryIsWide) return own;
return item.image(JellyfinImageType.thumb) ??
item.image(JellyfinImageType.backdrop) ??
item.image(JellyfinImageType.primary);
}
IconData kindIcon(JellyfinItem item) => switch (item.kind) {
JellyfinItemKind.series || JellyfinItemKind.season => Icons.live_tv_outlined,
JellyfinItemKind.episode => Icons.tv_outlined,
JellyfinItemKind.folder || JellyfinItemKind.collectionFolder => Icons.folder_outlined,
JellyfinItemKind.boxSet => Icons.collections_bookmark_outlined,
_ => Icons.movie_outlined,
};
/// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and
/// focus feed the inspector, "i" or the hover button opens the details.
class MediaCard extends StatefulWidget {
final JellyfinItem item;
final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top,
/// "S2E3 · 12 min left" below.
final bool seriesFirst;
final bool autofocus;
const MediaCard({
super.key,
required this.item,
required this.shape,
this.seriesFirst = false,
this.autofocus = false,
});
@override
State<MediaCard> createState() => _MediaCardState();
}
class _MediaCardState extends State<MediaCard> {
bool _hover = false;
bool _focus = false;
JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
_details(scope);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final selected = scope.selectedItemId == item.id;
final played = item.userData.played;
final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount;
final ring = selected
? scheme.primary
: _focus
? scheme.onSurface
: Colors.transparent;
final image = AspectRatio(
aspectRatio: widget.shape.aspect,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ring, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: cardImage(item, widget.shape),
icon: kindIcon(item),
),
if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor: Colors.black54,
),
),
Positioned(
top: 6,
right: 6,
child: selected
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
: unplayed != null && unplayed > 0 && !isPlayable(item)
? _Badge(
color: scheme.primaryContainer,
child: Text('$unplayed',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(),
),
if (_hover && isPlayable(item))
Positioned(
top: 4,
left: 4,
child: IconButton.filledTonal(
key: ValueKey('card-info-${item.id}'),
tooltip: 'Details (I)',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: () => _details(scope),
icon: const Icon(Icons.info_outline),
),
),
],
),
),
),
);
final title = cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => scope.activate(item),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
],
),
),
),
);
}
}
class _Badge extends StatelessWidget {
final Color color;
final Widget child;
const _Badge({required this.color, required this.child});
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minWidth: 22, minHeight: 22),
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(11)),
child: child,
);
}
/// Columns and card width that fill [width] exactly with cards at least
/// [minWidth] wide: no half-cut card at the edge, any window width.
({int cols, double itemWidth}) fitColumns(double width, double minWidth, {double gap = cardGap}) {
final cols = math.max(1, ((width + gap) / (minWidth + gap)).floor());
return (cols: cols, itemWidth: (width - gap * (cols - 1)) / cols);
}
/// Grid layout for cards: as many columns as fit, height = image + text.
class CardGridDelegate extends SliverGridDelegate {
final CardShape shape;
final double minWidth;
CardGridDelegate(this.shape, {double? minWidth}) : minWidth = minWidth ?? shape.minWidth;
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
final fit = fitColumns(constraints.crossAxisExtent, minWidth);
final h = fit.itemWidth / shape.aspect + cardTextHeight;
return SliverGridRegularTileLayout(
crossAxisCount: fit.cols,
mainAxisStride: h + 18,
crossAxisStride: fit.itemWidth + cardGap,
childMainAxisExtent: h,
childCrossAxisExtent: fit.itemWidth,
reverseCrossAxis: false,
);
}
@override
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
}
/// A titled horizontal row of cards sized to fill the width; arrows (on
/// hover) page through it. Lazy: only visible cards are built.
class MediaRow extends StatefulWidget {
final String title;
final List<JellyfinItem> items;
final CardShape shape;
final bool seriesFirst;
final Widget? trailing;
const MediaRow({
super.key,
required this.title,
required this.items,
required this.shape,
this.seriesFirst = false,
this.trailing,
});
@override
State<MediaRow> createState() => _MediaRowState();
}
class _MediaRowState extends State<MediaRow> {
final _scroll = ScrollController();
bool _hover = false;
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _page(int dir, double width) {
if (!_scroll.hasClients) return;
final p = _scroll.position;
final to = (p.pixels + dir * (width + cardGap)).clamp(0.0, p.maxScrollExtent);
_scroll.animateTo(to, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(widget.title, style: t.titleMedium),
const SizedBox(width: 8),
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
const Spacer(),
?widget.trailing,
],
),
),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols;
return SizedBox(
height: h,
child: Stack(
children: [
ListView.separated(
controller: _scroll,
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: widget.items[i],
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
),
),
if (overflow && _hover) ...[
_PageArrow(left: true, height: h - cardTextHeight, onTap: () => _page(-1, box.maxWidth)),
_PageArrow(left: false, height: h - cardTextHeight, onTap: () => _page(1, box.maxWidth)),
],
],
),
);
}),
],
),
);
}
}
class _PageArrow extends StatelessWidget {
final bool left;
final double height;
final VoidCallback onTap;
const _PageArrow({required this.left, required this.height, required this.onTap});
@override
Widget build(BuildContext context) {
return Positioned(
left: left ? 0 : null,
right: left ? null : 0,
top: 0,
height: height,
child: Center(
child: ExcludeFocus(
child: IconButton.filledTonal(
tooltip: left ? 'Previous' : 'Next',
onPressed: onTap,
icon: Icon(left ? Icons.chevron_left : Icons.chevron_right),
),
),
),
);
}
}
/// Section heading used above grids and rows.
class SectionHeader extends StatelessWidget {
final String title;
final String? count;
final Widget? trailing;
const SectionHeader(this.title, {super.key, this.count, this.trailing});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
children: [
Text(title, style: t.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
const Spacer(),
?trailing,
],
),
);
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Movie / episode detail: art, title, meta, overview, the pick button and
/// what is in the file (video, audio and subtitle tracks).
class JellyfinDetailPage extends StatelessWidget {
final JellyfinItem item;
const JellyfinDetailPage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<JellyfinItem>(
key: ValueKey('detail-${item.id}-${scope.account.serverId}'),
load: () => scope.load('item:${item.id}', (c) => c.item(item.id)),
builder: (context, full) => _Detail(item: full),
);
}
}
class _Detail extends StatelessWidget {
final JellyfinItem item;
const _Detail({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final episode = item.kind == JellyfinItemKind.episode;
final selected = scope.selectedItemId == item.id;
final meta = [
?item.episodeLabel,
if (item.productionYear != null) '${item.productionYear}',
if (item.runTime != null) formatRuntime(item.runTime!),
?timeLeft(item),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (item.userData.played) 'Watched',
].join(' · ');
final art = episode
? AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client,
image: item.ownImage(JellyfinImageType.primary) ??
item.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined),
),
)
: AspectRatio(
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client, image: item.image(JellyfinImageType.primary)),
),
);
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
TextButton.icon(
key: const ValueKey('detail-series'),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () => scope.openSeriesOf(item),
icon: const Icon(Icons.live_tv_outlined, size: 18),
label: Text(item.seriesName!, style: t.titleMedium),
),
SelectableText(item.name, style: t.headlineSmall),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (progressOf(item) > 0) ...[
const SizedBox(height: 8),
SizedBox(
width: 240,
child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)),
],
const SizedBox(height: 16),
FilledButton.icon(
key: const ValueKey('detail-pick'),
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow),
label: Text(selected ? 'Picked' : 'Pick to watch'),
),
if (item.overview != null) ...[
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: SelectableText(item.overview!, style: t.bodyMedium),
),
],
const SizedBox(height: 16),
_Tracks(item: item),
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: LayoutBuilder(builder: (context, box) {
final wide = box.maxWidth >= 760;
final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0;
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: episode ? 320 : 180, child: art)),
const SizedBox(height: 16),
info,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: artWidth, child: art),
const SizedBox(width: 24),
Expanded(child: info),
],
);
}),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
/// "1 h 42 min", "24 min", "45 s".
String formatRuntime(Duration d) {
final h = d.inHours, m = d.inMinutes % 60;
if (h > 0) return m == 0 ? '$h h' : '$h h $m min';
if (d.inMinutes > 0) return '${d.inMinutes} min';
return '${d.inSeconds} s';
}
/// Watched share of a resumable item, 0 when there is no resume point.
double progressOf(JellyfinItem item) {
final run = item.runTimeTicks;
final pos = item.userData.playbackPositionTicks;
if (run == null || run <= 0 || pos <= 0) return 0;
return (pos / run).clamp(0.0, 1.0);
}
/// "12 min left" for a resumable item, else null.
String? timeLeft(JellyfinItem item) {
final run = item.runTimeTicks;
if (run == null || !item.userData.isResumable) return null;
final left = ticksToDuration(run - item.userData.playbackPositionTicks);
if (left.inMinutes < 1) return 'almost done';
return '${formatRuntime(left)} left';
}
bool isPlayable(JellyfinItem item) => switch (item.kind) {
JellyfinItemKind.movie || JellyfinItemKind.episode || JellyfinItemKind.video => true,
_ => !item.isFolder && item.mediaType == 'Video',
};
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
switch (item.kind) {
case JellyfinItemKind.episode:
if (withSeries && item.seriesName != null) parts.add(item.seriesName!);
parts.add(item.episodeLabel ?? item.seasonName ?? 'Episode');
case JellyfinItemKind.series:
if (item.productionYear != null) parts.add('${item.productionYear}');
final eps = item.recursiveItemCount, seasons = item.childCount;
if (eps != null && eps > 0) {
parts.add('$eps eps');
} else if (seasons != null && seasons > 0) {
parts.add(seasons == 1 ? '1 season' : '$seasons seasons');
}
case JellyfinItemKind.season:
final n = item.childCount ?? item.recursiveItemCount;
if (n != null) parts.add('$n eps');
default:
if (item.productionYear != null) parts.add('${item.productionYear}');
}
final left = timeLeft(item);
if (left != null) {
parts.add(left);
} else if (isPlayable(item) && item.runTime != null) {
parts.add(formatRuntime(item.runTime!));
}
return parts.join(' · ');
}
/// Title on a card: episodes in rows that mix series show the series name
/// on top and the episode below.
String cardTitle(JellyfinItem item, {bool seriesFirst = false}) =>
seriesFirst && item.kind == JellyfinItemKind.episode && item.seriesName != null
? item.seriesName!
: item.name;
/// The selection for a playable [item] of [account].
JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
final String subtitle;
if (item.kind == JellyfinItemKind.episode) {
subtitle = [?item.seriesName, ?item.episodeLabel].join(' · ');
} else {
subtitle = item.productionYear?.toString() ?? '';
}
return JellyfinMedia(
serverId: account.serverId,
baseUrl: account.baseUrl,
itemId: item.id,
mediaSourceId: item.mediaSources.length > 1 ? item.mediaSources.first.id : null,
title: item.name,
subtitle: subtitle,
runtime: item.runTime,
);
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
/// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser.
bool isVideoLibrary(JellyfinItem view) => switch (view.collectionType) {
'movies' || 'tvshows' || 'homevideos' || 'boxsets' || 'mixed' || 'musicvideos' || null => true,
_ => false,
};
IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
'movies' => Icons.movie_outlined,
'tvshows' => Icons.live_tv_outlined,
'boxsets' => Icons.collections_bookmark_outlined,
'homevideos' => Icons.videocam_outlined,
_ => Icons.folder_outlined,
};
/// Start page: library shortcuts, then Continue watching, Next up and the
/// latest additions of each library. Each row loads on its own, so a slow
/// library does not hold up the rest.
class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()),
builder: (context, all) {
final views = all.where(isVideoLibrary).toList();
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
_LibraryStrip(views: views),
const SizedBox(height: 20),
_Section(
key: const ValueKey('row-resume'),
title: 'Continue watching',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
),
_Section(
key: const ValueKey('row-nextup'),
title: 'Next up',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
),
for (final v in views)
_Section(
key: ValueKey('row-latest-${v.id}'),
title: 'Latest in ${v.name}',
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
trailing: TextButton(
onPressed: () => scope.onOpen(LibraryRoute(v)),
child: const Text('Show all'),
),
),
],
);
},
);
}
}
class _LibraryStrip extends StatelessWidget {
final List<JellyfinItem> views;
const _LibraryStrip({required this.views});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (views.isEmpty) {
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final v in views)
ActionChip(
key: ValueKey('library-${v.id}'),
avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)),
),
],
);
}
}
class _Section extends StatelessWidget {
final String title;
final CardShape shape;
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load;
final Widget? trailing;
const _Section({
super.key,
required this.title,
required this.shape,
required this.load,
this.seriesFirst = false,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Loader<List<JellyfinItem>>(
load: load,
showError: false,
loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
items: items,
shape: shape,
seriesFirst: seriesFirst,
trailing: trailing,
),
),
);
}
}
class _RowSkeleton extends StatelessWidget {
final String title;
final CardShape shape;
const _RowSkeleton({required this.title, required this.shape});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionHeader(title),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, shape.minWidth);
return Row(
children: [
for (var i = 0; i < fit.cols; i++) ...[
if (i > 0) const SizedBox(width: cardGap),
Expanded(child: SkeletonCard(shape: shape)),
],
],
);
}),
],
),
);
}
}
class SkeletonCard extends StatelessWidget {
final CardShape shape;
const SkeletonCard({super.key, required this.shape});
@override
Widget build(BuildContext context) {
final c = Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.6);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: shape.aspect,
child: DecoratedBox(
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8))),
),
const SizedBox(height: cardTextHeight),
],
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
/// Server-side scale steps: nearby sizes share one URL (and one cache
/// entry) instead of fetching per-pixel variants on every resize.
const _widthSteps = [160, 240, 320, 480, 640, 960, 1280, 1920, 2560];
int imageWidthStep(double physicalWidth) {
for (final s in _widthSteps) {
if (s >= physicalWidth) return s;
}
return _widthSteps.last;
}
/// A Jellyfin image sized to its box. The URL carries the image tag and the
/// width step, so Flutter's image cache (keyed by URL) reuses it until the
/// image changes on the server; decode is capped at the same width. Shows
/// [placeholder] (or a tinted box with [icon]) while loading and on error,
/// and fades in when ready.
class JellyfinImage extends StatelessWidget {
final JellyfinClient client;
final JellyfinImageRef? image;
final BoxFit fit;
final IconData icon;
final Alignment alignment;
final Widget? placeholder;
const JellyfinImage({
super.key,
required this.client,
required this.image,
this.fit = BoxFit.cover,
this.icon = Icons.movie_outlined,
this.alignment = Alignment.center,
this.placeholder,
});
@override
Widget build(BuildContext context) {
final ref = image;
final fallback = placeholder ?? _Placeholder(icon: icon);
if (ref == null) return fallback;
return LayoutBuilder(builder: (context, box) {
final dpr = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1;
final w = box.hasBoundedWidth ? box.maxWidth : 480.0;
final step = imageWidthStep(w * dpr);
final url = client.imageUrl(ref, maxWidth: step, quality: 85).toString();
return Image.network(
url,
key: ValueKey(url),
fit: fit,
alignment: alignment,
width: double.infinity,
height: double.infinity,
cacheWidth: step,
filterQuality: FilterQuality.medium,
frameBuilder: (context, child, frame, sync) {
if (sync) return child;
return Stack(fit: StackFit.passthrough, children: [
if (frame == null) fallback,
AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 180),
child: child,
),
]);
},
errorBuilder: (context, _, _) => fallback,
);
});
}
}
class _Placeholder extends StatelessWidget {
final IconData icon;
const _Placeholder({required this.icon});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ColoredBox(
color: scheme.surfaceContainerHigh,
child: Center(
child: Icon(icon, size: 28, color: scheme.onSurfaceVariant.withValues(alpha: 0.35)),
),
);
}
}
@@ -0,0 +1,128 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Side pane on wide windows: details of whatever card is hovered or
/// focused, so browsing needs no extra clicks. Pick / Open act on it.
class JellyfinInspector extends StatelessWidget {
final ValueListenable<JellyfinItem?> item;
const JellyfinInspector({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
key: const ValueKey('jf-inspector'),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: ValueListenableBuilder<JellyfinItem?>(
valueListenable: item,
builder: (context, it, _) => it == null
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('Hover or focus a title to see its details here.',
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
),
)
: _Body(item: it),
),
);
}
}
class _Body extends StatelessWidget {
final JellyfinItem item;
const _Body({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final t = Theme.of(context).textTheme;
final playable = isPlayable(item);
final selected = scope.selectedItemId == item.id;
final episode = item.kind == JellyfinItemKind.episode;
final art = episode
? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop)
: item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb);
final meta = [
if (item.productionYear != null) '${item.productionYear}',
if (playable && item.runTime != null) formatRuntime(item.runTime!),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (!playable && (item.userData.unplayedItemCount ?? 0) > 0)
'${item.userData.unplayedItemCount} unwatched',
if (item.userData.played) 'Watched',
].join(' · ');
return ListView(
padding: EdgeInsets.zero,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: JellyfinImage(client: scope.client, image: art),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)),
Text(
episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name,
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (timeLeft(item) != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressOf(item), minHeight: 3),
const SizedBox(height: 4),
Text(timeLeft(item)!, style: t.bodySmall),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
if (playable)
FilledButton.icon(
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18),
label: Text(selected ? 'Picked' : 'Pick'),
)
else
FilledButton.tonalIcon(
onPressed: () => scope.activate(item),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Open'),
),
if (playable)
OutlinedButton(
onPressed: () => scope.onOpen(DetailRoute(item)),
child: const Text('Details'),
),
if (episode && item.seriesId != null)
OutlinedButton(
onPressed: () => scope.openSeriesOf(item),
child: const Text('Series'),
),
]),
if (item.overview != null) ...[
const SizedBox(height: 12),
Text(item.overview!, style: t.bodyMedium),
],
],
),
),
],
);
}
}
@@ -0,0 +1,276 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_page.dart' show SkeletonCard;
enum LibrarySort {
name('Name', JellyfinSortBy.sortName, false),
added('Date added', JellyfinSortBy.dateCreated, true),
premiere('Release date', JellyfinSortBy.premiereDate, true),
rating('Rating', JellyfinSortBy.communityRating, true),
random('Random', JellyfinSortBy.random, false);
final String label;
final String apiName;
/// Natural direction: newest / best first.
final bool descending;
const LibrarySort(this.label, this.apiName, this.descending);
}
/// Item types and recursion for a library: movie and show libraries list
/// their titles flat; anything else is browsed folder by folder.
({List<String> types, bool recursive}) libraryShape(JellyfinItem folder) =>
switch (folder.collectionType) {
'movies' => (types: const [JellyfinItemType.movie], recursive: true),
'tvshows' => (types: const [JellyfinItemType.series], recursive: true),
'boxsets' => (types: const [JellyfinItemType.boxSet], recursive: true),
_ => (types: const <String>[], recursive: false),
};
const libraryPageSize = 100;
/// One library (or folder) as a grid that fills the width, with sort,
/// unplayed / favourite filters and infinite scroll. The scrollbar spans
/// the full count from the start: not-yet-loaded cells are skeletons and
/// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder});
@override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
bool _unplayed = false;
bool _favorites = false;
final _items = <JellyfinItem>[];
int? _total;
bool _loading = false;
Object? _error;
int _gen = 0;
final _scroll = ScrollController();
BrowseScope? _scope;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final scope = BrowseScope.of(context);
final first = _scope == null;
final changedServer = _scope != null && _scope!.client != scope.client;
_scope = scope;
if (first || changedServer) _reload();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
JellyfinItemQuery get _query {
final shape = libraryShape(widget.folder);
return JellyfinItemQuery(
parentId: widget.folder.id,
includeItemTypes: shape.types,
recursive: shape.recursive,
sortBy: [
// Folders first when browsing a folder tree.
if (!shape.recursive && _sort == LibrarySort.name) 'IsFolder',
_sort.apiName,
if (_sort != LibrarySort.name) JellyfinSortBy.sortName,
],
descending: _descending,
filters: [
if (_unplayed) JellyfinFilter.isUnplayed,
if (_favorites) JellyfinFilter.isFavorite,
],
);
}
void _reload() {
_gen++;
_items.clear();
_total = null;
_error = null;
_loading = false;
if (_scroll.hasClients) _scroll.jumpTo(0);
_loadMore();
}
Future<void> _loadMore() async {
final scope = _scope;
if (scope == null || _loading) return;
if (_total != null && _items.length >= _total!) return;
final gen = _gen;
setState(() {
_loading = true;
_error = null;
});
try {
final page = await scope.client.items(_query.page(_items.length, libraryPageSize));
if (gen != _gen || !mounted) return;
setState(() {
_items.addAll(page.items);
// An empty page before the advertised end: the library shrank.
_total = page.items.isEmpty ? _items.length : page.totalRecordCount;
_loading = false;
});
} catch (e) {
if (gen != _gen || !mounted) return;
if (e is JellyfinException && e.isUnauthorized) {
scope.accounts.markExpired(scope.account.serverId);
}
setState(() {
_loading = false;
_error = e;
});
}
}
void _setSort(LibrarySort s) {
setState(() {
_sort = s;
_descending = s.descending;
});
_reload();
}
CardShape get _shape =>
widget.folder.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final total = _total;
final toolbar = Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(widget.folder.name, style: t.titleLarge),
const SizedBox(width: 4),
Text(total == null ? '' : '$total ${total == 1 ? 'item' : 'items'}',
key: const ValueKey('library-count'),
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(width: 12),
_SortMenu(sort: _sort, onSelected: _setSort),
if (_sort != LibrarySort.random)
IconButton(
tooltip: _descending ? 'Descending' : 'Ascending',
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _descending = !_descending);
_reload();
},
icon: Icon(_descending ? Icons.arrow_downward : Icons.arrow_upward, size: 18),
)
else
IconButton(
tooltip: 'Shuffle again',
visualDensity: VisualDensity.compact,
onPressed: _reload,
icon: const Icon(Icons.shuffle, size: 18),
),
FilterChip(
key: const ValueKey('filter-unplayed'),
label: const Text('Unwatched'),
selected: _unplayed,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _unplayed = v);
_reload();
},
),
FilterChip(
key: const ValueKey('filter-favorites'),
label: const Text('Favorites'),
selected: _favorites,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _favorites = v);
_reload();
},
),
],
);
final count = total ?? (_loading ? libraryPageSize ~/ 4 : 0);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(padding: const EdgeInsets.only(bottom: 12), child: toolbar),
if (_error != null && _items.isEmpty)
LoadError(error: _error!, onRetry: _loadMore)
else if (total == 0)
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: Text('Nothing here', style: TextStyle(color: Colors.grey))),
)
else
Expanded(
child: Scrollbar(
controller: _scroll,
child: CustomScrollView(
controller: _scroll,
slivers: [
SliverGrid(
gridDelegate: CardGridDelegate(_shape),
delegate: SliverChildBuilderDelegate(
(context, i) {
if (i >= _items.length - 40 && _error == null) {
// Near the loaded edge: fetch the next page.
scheduleMicrotask(_loadMore);
}
if (i >= _items.length) return SkeletonCard(shape: _shape);
return MediaCard(item: _items[i], shape: _shape);
},
childCount: count,
),
),
if (_error != null && _items.isNotEmpty)
SliverToBoxAdapter(child: LoadError(error: _error!, onRetry: _loadMore)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
),
],
);
}
}
class _SortMenu extends StatelessWidget {
final LibrarySort sort;
final ValueChanged<LibrarySort> onSelected;
const _SortMenu({required this.sort, required this.onSelected});
@override
Widget build(BuildContext context) {
return MenuAnchor(
menuChildren: [
for (final s in LibrarySort.values)
MenuItemButton(
key: ValueKey('sort-${s.name}'),
leadingIcon: Icon(s == sort ? Icons.check : null, size: 18),
onPressed: () => onSelected(s),
child: Text(s.label),
),
],
builder: (context, menu, _) => OutlinedButton.icon(
key: const ValueKey('library-sort'),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(Icons.sort, size: 18),
label: Text(sort.label),
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browser.dart';
import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server
/// is linked, else an inline add-server panel.
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
const JellyfinPickerSource({
super.key,
required this.accounts,
required this.onPick,
this.selected,
this.onOpenIntegrations,
this.onToggleExpanded,
this.expanded = false,
});
@override
State<JellyfinPickerSource> createState() => _JellyfinPickerSourceState();
}
class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
@override
void initState() {
super.initState();
widget.accounts.ensureLoaded();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.accounts,
builder: (context, _) {
final a = widget.accounts;
if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
accounts: a,
selected: widget.selected,
onPick: widget.onPick,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
);
},
);
}
}
class _LinkFirst extends StatelessWidget {
final JellyfinAccounts accounts;
final VoidCallback? onOpenIntegrations;
const _LinkFirst({required this.accounts, this.onOpenIntegrations});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
key: const ValueKey('media-jellyfin'),
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Link a Jellyfin server to browse it here. '
'You can manage linked servers in Integrations.',
style: TextStyle(color: Colors.grey)),
const SizedBox(height: kGap),
JellyfinSignInPanel(accounts: accounts),
if (onOpenIntegrations != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
),
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
/// Search results grouped by kind: shows and movies as posters, episodes
/// as thumbnails, each a grid that fills the width.
class JellyfinSearchResults extends StatelessWidget {
final String term;
const JellyfinSearchResults({super.key, required this.term});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('search-$term-${scope.account.serverId}'),
load: () => scope.load('search:$term', (c) => c.search(term, limit: 80)),
builder: (context, items) {
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text('Nothing found for "$term"',
key: const ValueKey('search-empty'),
style: const TextStyle(color: Colors.grey)),
),
);
}
final groups = [
('Shows', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.series).toList()),
('Movies', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.movie).toList()),
('Episodes', CardShape.thumb, items.where((i) => i.kind == JellyfinItemKind.episode).toList()),
(
'Other',
CardShape.poster,
items
.where((i) => !const [
JellyfinItemKind.series,
JellyfinItemKind.movie,
JellyfinItemKind.episode
].contains(i.kind))
.toList()
),
].where((g) => g.$3.isNotEmpty);
return CustomScrollView(
key: const ValueKey('search-results'),
slivers: [
for (final (title, shape, list) in groups) ...[
SliverToBoxAdapter(child: SectionHeader(title, count: '${list.length}')),
SliverGrid(
gridDelegate: CardGridDelegate(shape),
delegate: SliverChildBuilderDelegate(
(context, i) => MediaCard(
item: list[i],
shape: shape,
seriesFirst: shape == CardShape.thumb,
),
childCount: list.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
);
},
);
}
}
@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Series page: backdrop + logo header with overview and the smart "next
/// unwatched" button, a season selector and the season's episodes as dense
/// rows (several columns on wide windows).
class JellyfinSeriesPage extends StatefulWidget {
final JellyfinItem series;
final String? seasonId;
const JellyfinSeriesPage({super.key, required this.series, this.seasonId});
@override
State<JellyfinSeriesPage> createState() => _JellyfinSeriesPageState();
}
typedef _SeriesData = ({JellyfinItem series, List<JellyfinItem> seasons, JellyfinItem? next});
class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
String? _seasonId;
@override
void initState() {
super.initState();
_seasonId = widget.seasonId;
}
Future<_SeriesData> _load(BrowseScope scope) async {
final id = widget.series.id;
final results = await Future.wait<Object?>([
scope.load('item:$id', (c) => c.item(id)),
scope.load('seasons:$id', (c) => c.seasons(id)),
scope
.load('next:$id', (c) => c.nextUp(seriesId: id, limit: 1, enableResumable: true))
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = results[2] as List<JellyfinItem>?;
return (
series: results[0] as JellyfinItem,
seasons: results[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
/// Season to show first: the one holding the next episode, else the first
/// with something unwatched, else the first.
String? _defaultSeason(_SeriesData d) {
if (d.seasons.isEmpty) return null;
final nextSeason = d.next?.seasonId;
if (nextSeason != null && d.seasons.any((s) => s.id == nextSeason)) return nextSeason;
return (d.seasons.where((s) => !s.userData.played).firstOrNull ?? d.seasons.first).id;
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
? _seasonId
: _defaultSeason(d);
return CustomScrollView(
key: PageStorageKey('series-${widget.series.id}'),
slivers: [
SliverToBoxAdapter(child: _SeriesHeader(data: d)),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final s in d.seasons)
ChoiceChip(
key: ValueKey('season-${s.id}'),
label: Text(s.name),
avatar: s.userData.played
? const Icon(Icons.check, size: 16)
: null,
showCheckmark: false,
selected: s.id == seasonId,
onSelected: (_) => setState(() => _seasonId = s.id),
tooltip: s.userData.unplayedItemCount != null
? '${s.userData.unplayedItemCount} unwatched'
: null,
),
],
),
),
),
if (seasonId != null)
_EpisodeList(
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
}
}
class _SeriesHeader extends StatefulWidget {
final _SeriesData data;
const _SeriesHeader({required this.data});
@override
State<_SeriesHeader> createState() => _SeriesHeaderState();
}
class _SeriesHeaderState extends State<_SeriesHeader> {
bool _more = false;
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final s = widget.data.series;
final next = widget.data.next;
final logo = s.image(JellyfinImageType.logo);
final meta = [
if (s.productionYear != null) '${s.productionYear}',
if (s.officialRating != null) s.officialRating!,
if (s.communityRating != null) '★ ${s.communityRating!.toStringAsFixed(1)}',
if (widget.data.seasons.isNotEmpty)
'${widget.data.seasons.length} ${widget.data.seasons.length == 1 ? 'season' : 'seasons'}',
if ((s.userData.unplayedItemCount ?? 0) > 0) '${s.userData.unplayedItemCount} unwatched',
].join(' · ');
final String nextLabel;
if (next == null) {
nextLabel = 'All watched';
} else if (next.userData.isResumable) {
nextLabel = 'Resume ${next.episodeLabel ?? next.name}';
} else {
nextLabel = 'Next up: ${next.episodeLabel ?? ''} ${next.name}'.trim();
}
final selectedNext = next != null && scope.selectedItemId == next.id;
return LayoutBuilder(builder: (context, box) {
final h = (box.maxWidth * 0.28).clamp(220.0, 380.0);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: h,
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: s.image(JellyfinImageType.backdrop),
alignment: Alignment.topCenter,
icon: Icons.live_tv_outlined,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
theme.colorScheme.surface.withValues(alpha: 0.96),
theme.colorScheme.surface.withValues(alpha: 0.75),
theme.colorScheme.surface.withValues(alpha: 0.1),
],
stops: const [0, 0.45, 1],
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (logo != null)
SizedBox(
height: 72,
width: 320,
child: JellyfinImage(
client: scope.client,
image: logo,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
placeholder: Align(
alignment: Alignment.centerLeft,
child: Text(s.name, style: t.headlineMedium)),
),
)
else
Text(s.name, style: t.headlineMedium),
const SizedBox(height: 8),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (s.overview != null) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _more = !_more),
child: Text(s.overview!,
maxLines: _more ? null : 3,
overflow: _more ? null : TextOverflow.ellipsis,
style: t.bodyMedium),
),
],
const SizedBox(height: 14),
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onPick(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
]),
],
),
),
),
),
),
],
),
),
);
});
}
}
/// Episodes of one season as a lazy grid of rows: one column, more past
/// ~760 px each.
class _EpisodeList extends StatefulWidget {
final String seriesId;
final String seasonId;
const _EpisodeList({super.key, required this.seriesId, required this.seasonId});
@override
State<_EpisodeList> createState() => _EpisodeListState();
}
class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future;
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (_future == null) _load(scope);
return FutureBuilder<List<JellyfinItem>>(
future: _future,
builder: (context, snap) {
if (snap.hasError) {
return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
}
final eps = snap.data;
if (eps == null) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
}
if (eps.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
),
);
}
return SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 760,
mainAxisExtent: episodeTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate(
(context, i) => EpisodeTile(episode: eps[i]),
childCount: eps.length,
),
);
},
);
}
}
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click picks it; I opens details.
class EpisodeTile extends StatefulWidget {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@override
State<EpisodeTile> createState() => _EpisodeTileState();
}
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false;
bool _hover = false;
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = theme.textTheme;
final e = widget.episode;
final selected = scope.selectedItemId == e.id;
final played = e.userData.played;
final progress = progressOf(e);
final meta = [
?e.episodeLabel,
?timeLeft(e),
if (timeLeft(e) == null && e.runTime != null) formatRuntime(e.runTime!),
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, k) {
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
scope.onOpen(DetailRoute(e));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => scope.onPick(e),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
if (_hover)
IconButton(
tooltip: 'Details (I)',
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
iconSize: 18,
onPressed: () => scope.onOpen(DetailRoute(e)),
icon: const Icon(Icons.info_outline),
),
]),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
],
),
),
],
),
),
),
),
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
enum JellyfinSignInStep {
/// Typing the server address.
address,
probing,
/// Server found: choose Quick Connect or password.
method,
/// Code shown, polling until the user approves it in Jellyfin.
quickConnect,
password,
signingIn,
/// Linked; [JellyfinSignIn.linked] is the account.
done,
}
/// Add-server / reconnect flow: address -> /System/Info/Public -> Quick
/// Connect (code + poll, cancellable) or username/password -> linked in
/// [JellyfinAccounts]. UI-free so tests drive it with a fake transport.
class JellyfinSignIn extends ChangeNotifier {
JellyfinSignIn(this.accounts, {this.pollInterval = const Duration(seconds: 3), this.reconnect});
final JellyfinAccounts accounts;
final Duration pollInterval;
/// Set when re-signing an expired account: the address is prefilled.
final JellyfinAccount? reconnect;
JellyfinSignInStep _step = JellyfinSignInStep.address;
String? _error;
JellyfinServerInfo? _server;
bool _quickConnectAvailable = false;
String? _code;
JellyfinAccount? _linked;
JellyfinClient? _client;
int _gen = 0;
bool _disposed = false;
JellyfinSignInStep get step => _step;
String? get error => _error;
JellyfinServerInfo? get server => _server;
String? get baseUrl => _client?.baseUrl;
bool get quickConnectAvailable => _quickConnectAvailable;
/// The Quick Connect code to show.
String? get code => _code;
JellyfinAccount? get linked => _linked;
bool get busy =>
_step == JellyfinSignInStep.probing || _step == JellyfinSignInStep.signingIn;
void _set(JellyfinSignInStep s, {String? error}) {
_step = s;
_error = error;
if (!_disposed) notifyListeners();
}
/// Checks [input] is a Jellyfin server and whether Quick Connect is on.
Future<void> probe(String input) async {
final url = normalizeServerUrl(input);
if (url == null) {
_set(JellyfinSignInStep.address, error: 'Enter the server address, e.g. jellyfin.example.com');
return;
}
final gen = ++_gen;
_client?.close();
final client = _client = accounts.anonymousClient(url);
_set(JellyfinSignInStep.probing);
try {
final info = await client.publicInfo();
if (gen != _gen) return;
if (info.id.isEmpty) throw const JellyfinException('not a Jellyfin server');
if (reconnect != null && info.id != reconnect!.serverId) {
_set(JellyfinSignInStep.address,
error: 'That address is a different Jellyfin server (${info.name}).');
return;
}
var qc = false;
try {
qc = await client.quickConnectEnabled();
} catch (_) {}
if (gen != _gen) return;
_server = info;
_quickConnectAvailable = qc;
_set(JellyfinSignInStep.method);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.address, error: 'No Jellyfin server at $url (${_short(e)})');
}
}
/// Starts Quick Connect: shows the code, polls until approved, links.
Future<void> startQuickConnect() async {
final client = _client;
if (client == null || _server == null) return;
final gen = ++_gen;
_code = null;
_set(JellyfinSignInStep.quickConnect);
try {
final session = await client.quickConnectInitiate();
if (gen != _gen) return;
_code = session.code;
_set(JellyfinSignInStep.quickConnect);
final auth = await client.waitForQuickConnect(session,
interval: pollInterval, isCancelled: () => gen != _gen || _disposed);
if (auth == null || gen != _gen) return;
await _finish(auth);
} on TimeoutException {
if (gen == _gen) _set(JellyfinSignInStep.method, error: 'The code expired. Try again.');
} catch (e) {
if (gen != _gen) return;
final msg = e is JellyfinException && e.isUnauthorized
? 'Quick Connect is turned off on this server. Use your password.'
: e is JellyfinException && e.statusCode == 404
? 'The code expired. Try again.'
: 'Quick Connect failed: ${_short(e)}';
_set(JellyfinSignInStep.method, error: msg);
}
}
/// Stops waiting for the Quick Connect code.
void cancel() {
_gen++;
_code = null;
_set(_server == null ? JellyfinSignInStep.address : JellyfinSignInStep.method);
}
void usePassword() => _set(JellyfinSignInStep.password);
/// Back to the address field.
void changeAddress() {
_gen++;
_server = null;
_code = null;
_set(JellyfinSignInStep.address);
}
Future<void> signIn(String username, String password) async {
final client = _client;
if (client == null) return;
if (username.trim().isEmpty) {
_set(JellyfinSignInStep.password, error: 'Enter your Jellyfin username');
return;
}
final gen = ++_gen;
_set(JellyfinSignInStep.signingIn);
try {
final auth = await client.authenticateByName(username.trim(), password);
if (gen != _gen) return;
await _finish(auth);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.password,
error: e is JellyfinException && e.isUnauthorized
? 'Wrong username or password'
: 'Sign-in failed: ${_short(e)}');
}
}
Future<void> _finish(JellyfinAuthResult auth) async {
final server = _server!;
if (auth.accessToken.isEmpty) throw const JellyfinException('no token in the answer');
_linked = await accounts.link(baseUrl: _client!.baseUrl, server: server, auth: auth);
_code = null;
_set(JellyfinSignInStep.done);
}
static String _short(Object e) {
final s = '$e'.replaceFirst(RegExp(r'^JellyfinException( \d+)?: '), '');
return s.length > 140 ? '${s.substring(0, 140)}…' : s;
}
@override
void dispose() {
_disposed = true;
_gen++;
_client?.close();
super.dispose();
}
}
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'sign_in.dart';
/// Add a Jellyfin server (or reconnect an expired one): address, then Quick
/// Connect (big code, approve it in Jellyfin) or username and password.
class JellyfinSignInPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinAccount? reconnect;
final ValueChanged<JellyfinAccount>? onDone;
final VoidCallback? onCancel;
final Duration pollInterval;
const JellyfinSignInPanel({
super.key,
required this.accounts,
this.reconnect,
this.onDone,
this.onCancel,
this.pollInterval = const Duration(seconds: 3),
});
@override
State<JellyfinSignInPanel> createState() => _JellyfinSignInPanelState();
}
class _JellyfinSignInPanelState extends State<JellyfinSignInPanel> {
late final JellyfinSignIn _flow =
JellyfinSignIn(widget.accounts, pollInterval: widget.pollInterval, reconnect: widget.reconnect);
late final _address = TextEditingController(text: widget.reconnect?.baseUrl ?? '');
late final _user = TextEditingController(text: widget.reconnect?.userName ?? '');
final _password = TextEditingController();
bool _reported = false;
@override
void initState() {
super.initState();
_flow.addListener(_changed);
if (widget.reconnect != null) _flow.probe(widget.reconnect!.baseUrl);
}
@override
void dispose() {
_flow.removeListener(_changed);
_flow.dispose();
_address.dispose();
_user.dispose();
_password.dispose();
super.dispose();
}
void _changed() {
if (!mounted) return;
setState(() {});
final linked = _flow.linked;
if (_flow.step == JellyfinSignInStep.done && linked != null && !_reported) {
_reported = true;
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onDone?.call(linked));
}
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final f = _flow;
final body = switch (f.step) {
JellyfinSignInStep.address || JellyfinSignInStep.probing => _addressStep(),
JellyfinSignInStep.method => _methodStep(),
JellyfinSignInStep.quickConnect => _quickConnectStep(),
JellyfinSignInStep.password || JellyfinSignInStep.signingIn => _passwordStep(),
JellyfinSignInStep.done => Row(children: [
const Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text('Linked as ${f.linked?.userName} on ${f.linked?.displayName}',
key: const ValueKey('jf-linked'))),
]),
};
return Column(
key: const ValueKey('jf-sign-in'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.reconnect == null ? 'Add a Jellyfin server' : 'Reconnect ${widget.reconnect!.displayName}',
style: t.titleMedium),
const SizedBox(height: 12),
if (f.server != null && f.step != JellyfinSignInStep.address && f.step != JellyfinSignInStep.done)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _ServerLine(
name: f.server!.name,
version: f.server!.version,
url: f.baseUrl ?? '',
onChange: widget.reconnect == null ? f.changeAddress : null,
),
),
body,
if (f.error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(f.error!,
key: const ValueKey('jf-sign-in-error'),
style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
);
}
Widget _cancelButton() => widget.onCancel == null
? const SizedBox.shrink()
: TextButton(onPressed: widget.onCancel, child: const Text('Cancel'));
Widget _addressStep() {
final probing = _flow.step == JellyfinSignInStep.probing;
return Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('jf-address'),
controller: _address,
autofocus: true,
enabled: !probing,
decoration: fieldDecoration('Server address', hint: 'jellyfin.example.com'),
keyboardType: TextInputType.url,
onSubmitted: (v) => _flow.probe(v),
),
),
const SizedBox(width: 12),
FilledButton(
key: const ValueKey('jf-connect'),
onPressed: probing ? null : () => _flow.probe(_address.text),
child: probing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Connect'),
),
_cancelButton(),
],
);
}
Widget _methodStep() {
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (_flow.quickConnectAvailable)
FilledButton.icon(
key: const ValueKey('jf-quick-connect'),
onPressed: _flow.startQuickConnect,
icon: const Icon(Icons.qr_code_2, size: 18),
label: const Text('Quick Connect'),
),
if (_flow.quickConnectAvailable)
OutlinedButton.icon(
key: const ValueKey('jf-use-password'),
onPressed: _flow.usePassword,
icon: const Icon(Icons.password, size: 18),
label: const Text('Username & password'),
)
else
FilledButton.icon(
key: const ValueKey('jf-use-password'),
onPressed: _flow.usePassword,
icon: const Icon(Icons.password, size: 18),
label: const Text('Username & password'),
),
_cancelButton(),
],
);
}
Widget _quickConnectStep() {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final code = _flow.code;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (code == null)
const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: SelectableText(
code,
key: const ValueKey('jf-qc-code'),
style: t.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 8,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Copy code',
onPressed: () => Clipboard.setData(ClipboardData(text: code)),
icon: const Icon(Icons.copy, size: 18),
),
],
),
const SizedBox(height: 12),
const Text('Approve it in Jellyfin: Profile → Quick Connect, then enter this code.'),
const SizedBox(height: 12),
Row(
children: [
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 10),
const Text('Waiting for approval…', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 16),
TextButton(
key: const ValueKey('jf-qc-cancel'),
onPressed: _flow.cancel,
child: const Text('Cancel'),
),
],
),
],
);
}
Widget _passwordStep() {
final busy = _flow.step == JellyfinSignInStep.signingIn;
void submit() => _flow.signIn(_user.text, _password.text);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('jf-username'),
controller: _user,
autofocus: true,
enabled: !busy,
decoration: fieldDecoration('Username'),
onSubmitted: (_) => submit(),
),
const SizedBox(height: 10),
TextField(
key: const ValueKey('jf-password'),
controller: _password,
enabled: !busy,
obscureText: true,
decoration: fieldDecoration('Password'),
onSubmitted: (_) => submit(),
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
key: const ValueKey('jf-sign-in-submit'),
onPressed: busy ? null : submit,
child: busy
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sign in'),
),
const SizedBox(width: 8),
if (_flow.quickConnectAvailable)
TextButton(onPressed: _flow.cancel, child: const Text('Back')),
_cancelButton(),
],
),
],
);
}
}
class _ServerLine extends StatelessWidget {
final String name;
final String version;
final String url;
final VoidCallback? onChange;
const _ServerLine({required this.name, required this.version, required this.url, this.onChange});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
children: [
Icon(Icons.dns_outlined, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name.isEmpty ? 'Jellyfin server' : name,
key: const ValueKey('jf-server-name'), style: t.titleSmall),
Text('Jellyfin $version · $url', style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
if (onChange != null) TextButton(onPressed: onChange, child: const Text('Change')),
],
);
}
}
@@ -91,8 +91,11 @@ class ConnectFlow extends ChangeNotifier {
}
/// [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 {
/// other card's saved values survive. [linkOverride] (host only) is played
/// as the link but never stored in the form: the interim Jellyfin stream
/// URL carries a token.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{String? linkOverride}) async {
if (busy != null) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
@@ -112,7 +115,13 @@ class ConnectFlow extends ChangeNotifier {
final ConnectParams params;
try {
params = await buildParams(mode, form);
params = await buildParams(
mode,
linkOverride == null || mode != ConnectMode.host
? form
: (form.copy()
..mediaPath = ''
..linkUrl = linkOverride));
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
@@ -34,12 +35,15 @@ class HomeShell extends StatefulWidget {
final bool autoUpdateCheck;
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const HomeShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = false,
this.healthGet = defaultHealthGet,
this.jellyfin,
});
@override
@@ -50,7 +54,8 @@ class _HomeShellState extends State<HomeShell> {
HomeView _view = HomeView.join;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
@@ -60,6 +65,8 @@ class _HomeShellState extends State<HomeShell> {
void initState() {
super.initState();
Profile.instance.ensureLoaded();
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKickedDialog;
// Back from a session that ended in a kick: say so.
@@ -134,7 +141,7 @@ class _HomeShellState extends State<HomeShell> {
onOpenIntegrations: () => _select(HomeView.integrations),
),
HomeView.settings => SettingsPage(updates: widget.updates),
HomeView.integrations => const IntegrationsPage(),
HomeView.integrations => IntegrationsPage(jellyfin: _jellyfin),
HomeView.about => AboutPage(updates: widget.updates),
};
return CallbackShortcuts(
+183 -25
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
@@ -9,21 +10,60 @@ import 'ui.dart';
/// Host form state, owned by the Home shell so it survives tab switches.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
HostFields(ConnectForm f, {JellyfinAccounts? jellyfin})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret),
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
// The last Jellyfin pick lives in the Jellyfin box (the connect form
// only knows links and files); restore it once that box is read.
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
this.jellyfin.ensureLoaded().then((_) {
final pick = this.jellyfin.lastPick;
if (_disposed || pick == null || media.jellyfin != null) return;
if (this.jellyfin.account(pick.serverId) == null) return;
if (noMedia) {
media.pickJellyfin(pick);
} else {
final source = media.source;
media.pickJellyfin(pick);
media.source = source;
}
});
}
final JellyfinAccounts jellyfin;
final TextEditingController port;
final TextEditingController secret;
final MediaPickerController media;
/// Jellyfin browser enlarged: the server column shrinks to a bar.
final browserExpanded = ValueNotifier(false);
bool _disposed = false;
void dispose() {
_disposed = true;
port.dispose();
secret.dispose();
media.dispose();
browserExpanded.dispose();
}
}
/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item,
/// the host plays it as a link: this install's authorised static stream URL
/// (token in the query, so viewers stream with the host's sign-in and the
/// URL must not be persisted). Throws [FormatException] with a user-facing
/// message when the server is not linked any more.
String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) {
final client = accounts.clientFor(media.serverId);
if (client == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
}
return client.streamUrl(media.itemId, mediaSourceId: media.mediaSourceId).toString();
}
/// Below this width the server column moves under the media picker.
const hostStackBelow = 860.0;
const _columnWidth = 340.0;
@@ -36,47 +76,102 @@ class HostTab extends StatelessWidget {
const HostTab(
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
void _host() => flow.submit(ConnectMode.host, (f) {
void _host() {
if (flow.busy != null) return;
final media = fields.media.selection;
f
String? link;
if (media is JellyfinMedia) {
try {
link = interimJellyfinLink(fields.jellyfin, media);
} on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return;
}
fields.jellyfin.rememberPick(media);
}
flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media.mediaPath
..linkUrl = media.linkUrl;
});
..mediaPath = media is FileMedia ? media.path : ''
..linkUrl = media is LinkMedia ? media.url : '',
linkOverride: link,
);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: flow,
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
builder: (context, _) {
final busy = flow.busy == ConnectMode.host;
final picker = Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
MediaPicker(
final jellyfin = fields.media.source == MediaKind.jellyfin;
final expanded = jellyfin && fields.browserExpanded.value;
Widget picker({required bool fill}) {
final p = MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
),
jellyfin: fields.jellyfin,
expanded: expanded,
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
);
return Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
fill ? Expanded(child: p) : p,
],
);
}
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
return LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < hostStackBelow;
// Jellyfin browser: fills the height (lazy grids need a bounded
// viewport); the form sits beside it or, enlarged, in a bar below.
if (expanded) {
return Padding(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(height: 12),
_HostBar(flow: flow, fields: fields, onHost: _host),
],
),
);
}
if (jellyfin && !stacked) {
return Padding(
padding: kPagePadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(width: 24),
SizedBox(
width: _columnWidth,
child: SingleChildScrollView(child: column),
),
],
),
);
}
return SingleChildScrollView(
padding: kPagePadding,
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
picker,
picker(fill: false),
const SizedBox(height: 24),
Align(
alignment: Alignment.topLeft,
@@ -90,7 +185,7 @@ class HostTab extends StatelessWidget {
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: picker),
Expanded(child: picker(fill: false)),
const SizedBox(width: 24),
SizedBox(width: _columnWidth, child: column),
],
@@ -168,7 +263,7 @@ class _ServerColumn extends StatelessWidget {
listenable: fields.media,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_mediaSummary(fields.media.selection),
child: Text(mediaSummary(fields.media.selection),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
@@ -184,11 +279,74 @@ class _ServerColumn extends StatelessWidget {
);
}
static String _mediaSummary(MediaSelection s) {
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
if (raw.isEmpty) return 'No media selected (optional).';
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
return 'Playing: $name';
}
String mediaSummary(PickedMedia? s) =>
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
/// Enlarged-browser mode: what is picked, port, password and Host in one
/// row under the browser.
class _HostBar extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _HostBar({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('host-bar'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(mediaSummary(fields.media.selection),
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
),
const SizedBox(width: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 100,
child: TextField(
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 200,
child: TextField(
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'none = open'),
onSubmitted: (_) => onHost(),
),
),
],
),
),
const SizedBox(width: 12),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
],
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
],
),
);
}
}
@@ -1,44 +1,22 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/accounts_panel.dart';
import 'ui.dart';
class IntegrationsPage extends StatelessWidget {
const IntegrationsPage({super.key});
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const IntegrationsPage({super.key, this.jellyfin});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return PageScroll(
maxWidth: 680,
maxWidth: 820,
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')),
],
),
),
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
],
);
}
@@ -2,23 +2,32 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/picker_source.dart';
import '../../widgets/media_input.dart';
import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
enum MediaKind { jellyfin, link, file }
/// What to play: at most one of the two is non-empty; both empty = none.
typedef MediaSelection = ({String mediaPath, String linkUrl});
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
/// later the session's swap panel) so it survives rebuilds.
class MediaPickerController extends ChangeNotifier {
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
_jellyfin = jellyfin,
_source = source ??
(linkUrl.isNotEmpty
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
@@ -27,6 +36,19 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController file;
MediaKind _source;
String? _notice;
JellyfinMedia? _jellyfin;
/// The Jellyfin item picked in the browser (kept while another source
/// is shown).
JellyfinMedia? get jellyfin => _jellyfin;
/// Picks a Jellyfin item and switches to the Jellyfin source.
void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media;
_source = MediaKind.jellyfin;
_notice = null;
notifyListeners();
}
MediaKind get source => _source;
set source(MediaKind v) {
@@ -43,11 +65,19 @@ class MediaPickerController extends ChangeNotifier {
notifyListeners();
}
MediaSelection get selection => switch (_source) {
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
};
/// What the visible source has selected; null = nothing.
PickedMedia? get selection {
switch (_source) {
case MediaKind.link:
final url = link.text.trim();
return url.isEmpty ? null : LinkMedia(url);
case MediaKind.file:
final path = file.text.trim();
return path.isEmpty ? null : FileMedia(path);
case MediaKind.jellyfin:
return _jellyfin;
}
}
/// Drops pasted text where it belongs: http(s) into Link, else File.
void pasteText(String text) {
@@ -73,12 +103,17 @@ class MediaPickerController extends ChangeNotifier {
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<MediaSelection>? onChanged;
final ValueChanged<PickedMedia?>? onChanged;
/// Enter in a field.
final VoidCallback? onSubmit;
final VoidCallback? onOpenIntegrations;
final bool enabled;
final bool autofocus;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded;
final bool expanded;
const MediaPicker({
super.key,
required this.controller,
@@ -87,6 +122,9 @@ class MediaPicker extends StatefulWidget {
this.onOpenIntegrations,
this.enabled = true,
this.autofocus = false,
this.jellyfin,
this.onToggleExpanded,
this.expanded = false,
});
@override
@@ -175,40 +213,15 @@ class _MediaPickerState extends State<MediaPicker> {
@override
Widget build(BuildContext context) {
final source = _c.source;
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
final Widget content = switch (source) {
MediaKind.jellyfin => JellyfinPickerSource(
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
selected: _c.jellyfin,
onPick: _c.pickJellyfin,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
),
),
const SizedBox(height: kGap),
switch (source) {
MediaKind.jellyfin =>
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
@@ -241,14 +254,59 @@ class _MediaPickerState extends State<MediaPicker> {
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
},
};
final segments = Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
);
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
// The browser fills the height it is given; in an unbounded
// (scrolling) parent it gets a fixed one.
child: LayoutBuilder(builder: (context, box) {
final fill = source == MediaKind.jellyfin;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
segments,
const SizedBox(height: kGap),
if (!fill)
content
else if (box.hasBoundedHeight)
Expanded(child: content)
else
SizedBox(height: jellyfinPickerHeight, child: content),
],
);
}),
),
);
}
}
/// Height of the Jellyfin browser when the picker sits in a scroll view.
const jellyfinPickerHeight = 640.0;
class _FieldRow extends StatelessWidget {
final Widget field;
final Widget button;
@@ -283,70 +341,3 @@ class _FieldRow extends StatelessWidget {
);
}
}
/// Where the Jellyfin library grid will go: ghost posters that fill the
/// width, dimmed, with the "coming soon" note on top.
class _JellyfinPlaceholder extends StatelessWidget {
final VoidCallback? onOpenIntegrations;
const _JellyfinPlaceholder({this.onOpenIntegrations});
static const _posterWidth = 140.0;
static const _rows = 2;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
return LayoutBuilder(builder: (context, box) {
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
return Stack(
key: const ValueKey('media-jellyfin'),
alignment: Alignment.center,
children: [
Opacity(
opacity: 0.35,
child: Column(
spacing: 12,
children: [
for (var r = 0; r < _rows; r++)
Row(
spacing: 12,
children: [
for (var c = 0; c < cols; c++)
Expanded(
child: AspectRatio(
aspectRatio: 2 / 3,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
Panel(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
const SizedBox(height: 8),
Text('Link your Jellyfin in Integrations — coming soon',
textAlign: TextAlign.center, style: t.titleSmall),
if (onOpenIntegrations != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
],
],
),
),
],
);
});
}
}
@@ -0,0 +1,114 @@
/// What the media picker selected. Null selection = nothing (hosting
/// without media is allowed).
sealed class PickedMedia {
const PickedMedia();
/// One line for summaries ("Playing: …").
String get label;
}
/// A direct http(s) link everyone streams from.
final class LinkMedia extends PickedMedia {
final String url;
const LinkMedia(this.url);
@override
String get label {
final uri = Uri.tryParse(url);
final segs = uri?.pathSegments.where((s) => s.isNotEmpty).toList() ?? const [];
if (segs.isEmpty) return uri?.host.isNotEmpty == true ? uri!.host : url;
try {
return Uri.decodeComponent(segs.last);
} catch (_) {
return segs.last;
}
}
@override
bool operator ==(Object other) => other is LinkMedia && other.url == url;
@override
int get hashCode => url.hashCode;
@override
String toString() => 'LinkMedia($url)';
}
/// A file on this computer; friends stream it from the host.
final class FileMedia extends PickedMedia {
final String path;
const FileMedia(this.path);
@override
String get label =>
path.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => path);
@override
bool operator ==(Object other) => other is FileMedia && other.path == path;
@override
int get hashCode => path.hashCode;
@override
String toString() => 'FileMedia($path)';
}
/// A movie or episode on a linked Jellyfin server. [serverId] keys the
/// account (`JellyfinAccounts.clientFor`); [baseUrl] is the address it was
/// picked through. No credentials in here: each side authorises its own.
final class JellyfinMedia extends PickedMedia {
final String serverId;
final String baseUrl;
final String itemId;
/// Null = the item's default source.
final String? mediaSourceId;
final String title;
/// "Series · S1E6" for episodes, the year for movies; may be empty.
final String subtitle;
final Duration? runtime;
const JellyfinMedia({
required this.serverId,
required this.baseUrl,
required this.itemId,
this.mediaSourceId,
required this.title,
this.subtitle = '',
this.runtime,
});
@override
String get label => subtitle.isEmpty ? title : '$title — $subtitle';
Map<String, Object?> toJson() => {
'serverId': serverId,
'baseUrl': baseUrl,
'itemId': itemId,
'mediaSourceId': ?mediaSourceId,
'title': title,
'subtitle': subtitle,
if (runtime != null) 'runtimeMs': runtime!.inMilliseconds,
};
static JellyfinMedia? fromJson(Object? j) {
if (j is! Map) return null;
final serverId = j['serverId'], baseUrl = j['baseUrl'], itemId = j['itemId'];
if (serverId is! String || baseUrl is! String || itemId is! String) return null;
final ms = j['runtimeMs'];
return JellyfinMedia(
serverId: serverId,
baseUrl: baseUrl,
itemId: itemId,
mediaSourceId: j['mediaSourceId'] is String ? j['mediaSourceId'] as String : null,
title: j['title'] is String ? j['title'] as String : '',
subtitle: j['subtitle'] is String ? j['subtitle'] as String : '',
runtime: ms is int ? Duration(milliseconds: ms) : null,
);
}
@override
bool operator ==(Object other) =>
other is JellyfinMedia &&
other.serverId == serverId &&
other.itemId == itemId &&
other.mediaSourceId == mediaSourceId;
@override
int get hashCode => Object.hash(serverId, itemId, mediaSourceId);
@override
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
}
+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);
});
}
+38 -5
View File
@@ -330,18 +330,51 @@ Recent server tiles in a grid that adds columns as the window widens. While
the Join tab is visible each tile polls the server's `GET /health` every
10 s (`health_probe.dart`) for online, name, viewer count and a "needs
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
media picker (`media_picker.dart`: Jellyfin placeholder, Link, File; a
reusable widget driven by a `MediaPickerController`) fills the main area;
port, password and Host sit in a 340 px column on the right, below the
picker under 860 px. Text fields have fixed or capped widths (720 px max) so
media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget
driven by a `MediaPickerController` whose `selection` is a sealed
`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see
`picked_media.dart`) fills the main area; port, password and Host sit in a
340 px column on the right, below the picker under 860 px. With the
Jellyfin source the browser fills the height and can be enlarged, which
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
they stay compact on ultrawide screens. Submit state (busy, cancel,
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
decoding, updates, video shaders, shortcuts), Integrations
(Jellyfin "coming soon") and About open in the same body. Settings persist
(linked Jellyfin servers) and About open in the same body. Settings persist
in two Hive boxes: `connect_form` (last form and recents; saved even when a
join fails) and `settings`. Link media names are derived from the URL path
and never contain a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
`JellyfinAccount` per ServerId (address list, user, token) in the
`jellyfin` Hive box with a random per-install DeviceId; Client = app name,
Version = app version, Device = hostname. Only tokens obtained by
`sign_in.dart` (Quick Connect or password) are stored. Tokens are checked
on start (`/Users/Me`, each known address in turn): online, offline, or
expired (kept, shown as Reconnect; never dropped silently). A 401 while
browsing also marks it expired. `clientFor(serverId)` gives the shared
signed-in client; `cached()` memoises browse calls for 3 min.
- `sign_in.dart` + `sign_in_panel.dart`: address -> `/System/Info/Public`
-> Quick Connect (code, poll, cancel) or password; reconnect must hit the
same ServerId. `accounts_panel.dart` is the Integrations card.
- `browser.dart`: back stack of `BrowseRoute`s (Home rows, library grid,
series, detail) kept alive under `Visibility(maintainState)` so back keeps
scroll and loaded pages; search (300 ms debounce) overlays them. Cards
(`cards.dart`) fill the width with whole columns (`fitColumns`); rows and
grids are lazy builders; library grids page 100 at a time with skeletons
up to the total count. Images (`images.dart`) are `Image.network` at
width steps (160..2560) with the image tag in the URL, so the image cache
key changes only when the image does. An inspector pane follows
hover/focus from 1400 px browser width.
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
remembers the last pick in the `jellyfin` box. Interim until the protocol
carries Jellyfin items: `interimJellyfinLink` in `host_tab.dart`
(`INTERIM(jellyfin-protocol)`) hosts it as a link to the host's
authorised static stream URL; `ConnectFlow.submit(linkOverride:)` keeps
that token-bearing URL out of the saved form.
Window chrome is frameless (`window_manager`): every screen brings its own
drag area and min/max/close buttons (Home's top bar; the session uses
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.