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:
1 parent
45f4c1bc84
commit
773a04369e
27 files changed
+5169
-228
No files matched your search
@@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user