Jellyfin in the app: account linking and library browser

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

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

No files matched your search

+500
View File
@@ -0,0 +1,500 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart' show appName;
import '../update/updater.dart' show appVersion;
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
//
// Only tokens this app obtained itself (Quick Connect or password through
// [JellyfinSignIn]) are ever stored or used: Jellyfin overwrites a token's
// device name and app version with whatever header comes with it, so
// borrowing another client's token would rename that client's device.
const jellyfinBoxName = 'jellyfin';
const _keyDeviceId = 'deviceId';
const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick';
/// One signed-in user on one server.
class JellyfinAccount {
final String serverId;
final String serverName;
final String serverVersion;
/// Addresses this server was reached at, preferred first.
final List<String> urls;
final String userId;
final String userName;
final String? userImageTag;
final String token;
const JellyfinAccount({
required this.serverId,
required this.serverName,
this.serverVersion = '',
required this.urls,
required this.userId,
required this.userName,
this.userImageTag,
required this.token,
});
String get baseUrl => urls.first;
JellyfinUser get user =>
JellyfinUser(id: userId, name: userName, serverId: serverId, primaryImageTag: userImageTag);
/// "Example Server" or the host when the server has no name.
String get displayName =>
serverName.isNotEmpty ? serverName : (Uri.tryParse(baseUrl)?.host ?? baseUrl);
JellyfinAccount copyWith({
String? serverName,
String? serverVersion,
List<String>? urls,
String? userName,
String? userImageTag,
}) =>
JellyfinAccount(
serverId: serverId,
serverName: serverName ?? this.serverName,
serverVersion: serverVersion ?? this.serverVersion,
urls: urls ?? this.urls,
userId: userId,
userName: userName ?? this.userName,
userImageTag: userImageTag ?? this.userImageTag,
token: token,
);
Map<String, Object?> toJson() => {
'serverId': serverId,
'serverName': serverName,
'serverVersion': serverVersion,
'urls': urls,
'userId': userId,
'userName': userName,
'userImageTag': ?userImageTag,
'token': token,
};
static JellyfinAccount? fromJson(Object? j) {
if (j is! Map) return null;
final sid = j['serverId'], uid = j['userId'], token = j['token'], urls = j['urls'];
if (sid is! String || uid is! String || token is! String || urls is! List) return null;
final list = urls.whereType<String>().toList();
if (sid.isEmpty || token.isEmpty || list.isEmpty) return null;
String s(Object? v) => v is String ? v : '';
return JellyfinAccount(
serverId: sid,
serverName: s(j['serverName']),
serverVersion: s(j['serverVersion']),
urls: list,
userId: uid,
userName: s(j['userName']),
userImageTag: j['userImageTag'] is String ? j['userImageTag'] as String : null,
token: token,
);
}
}
enum JellyfinAccountStatus {
/// Not checked yet this run.
unknown,
checking,
online,
/// Unreachable (network error); the token may still be fine.
offline,
/// The server rejected the token: sign in again ("Reconnect").
expired,
}
/// Key-value persistence behind [JellyfinAccounts]; tests use
/// [MemoryJellyfinStore].
abstract class JellyfinStore {
Future<void> open();
Object? get(String key);
Future<void> put(String key, Object? value);
}
class HiveJellyfinStore implements JellyfinStore {
Box? _box;
@override
Future<void> open() async => _box ??= await Hive.openBox(jellyfinBoxName);
@override
Object? get(String key) => _box?.get(key);
@override
Future<void> put(String key, Object? value) async {
final box = _box;
if (box == null) return;
value == null ? await box.delete(key) : await box.put(key, value);
}
}
class MemoryJellyfinStore implements JellyfinStore {
final Map<String, Object?> data;
MemoryJellyfinStore([Map<String, Object?>? data]) : data = data ?? {};
@override
Future<void> open() async {}
@override
Object? get(String key) => data[key];
@override
Future<void> put(String key, Object? value) async =>
value == null ? data.remove(key) : data[key] = value;
}
/// All linked Jellyfin servers, their live status and one authenticated
/// [JellyfinClient] each. [ensureLoaded] reads the box and then checks every
/// token in the background; a rejected token turns the account
/// [JellyfinAccountStatus.expired] (shown as "Reconnect"), never a silent
/// sign-out.
class JellyfinAccounts extends ChangeNotifier {
static final JellyfinAccounts instance = JellyfinAccounts();
JellyfinAccounts({
JellyfinStore? store,
this.httpFactory,
this.deviceName,
}) : _store = store ?? HiveJellyfinStore();
final JellyfinStore _store;
/// Transport for every client made here; null = real HTTP. Tests fake it.
final JellyfinHttp Function()? httpFactory;
/// Device name sent to Jellyfin; null = the hostname.
final String? deviceName;
List<JellyfinAccount> _accounts = const [];
final _status = <String, JellyfinAccountStatus>{};
final _errors = <String, String>{};
final _clients = <String, JellyfinClient>{};
final _memo = <String, ({DateTime at, Future<Object?> value})>{};
String? _defaultServerId;
String _deviceId = '';
JellyfinMedia? _lastPick;
Future<void>? _load;
bool _loaded = false;
bool _disposed = false;
List<JellyfinAccount> get accounts => _accounts;
bool get loaded => _loaded;
bool get isEmpty => _accounts.isEmpty;
JellyfinAccount? account(String serverId) =>
_accounts.where((a) => a.serverId == serverId).firstOrNull;
/// The chosen default, else the first linked server.
JellyfinAccount? get defaultAccount =>
(_defaultServerId == null ? null : account(_defaultServerId!)) ?? _accounts.firstOrNull;
String? get defaultServerId => defaultAccount?.serverId;
JellyfinAccountStatus statusOf(String serverId) =>
_status[serverId] ?? JellyfinAccountStatus.unknown;
/// Last failure text for an offline account.
String? errorOf(String serverId) => _errors[serverId];
/// The Host tab's last Jellyfin pick, restored on the next start.
JellyfinMedia? get lastPick => _lastPick;
/// Stable per install: Jellyfin keys devices and tokens by it.
String get deviceId => _deviceId;
JellyfinClientInfo get clientInfo => JellyfinClientInfo(
client: appName,
device: deviceName ?? _hostname(),
deviceId: _deviceId.isEmpty ? 'unknown' : _deviceId,
version: appVersion,
);
static String _hostname() {
try {
final h = Platform.localHostname;
return h.isEmpty ? 'Desktop' : h;
} catch (_) {
return 'Desktop';
}
}
Future<void> ensureLoaded({bool validate = true}) =>
_load ??= _loadNow(validate: validate);
Future<void> _loadNow({required bool validate}) async {
try {
await _store.open();
final id = _store.get(_keyDeviceId);
if (id is String && id.isNotEmpty) {
_deviceId = id;
} else {
_deviceId = _newDeviceId();
await _store.put(_keyDeviceId, _deviceId);
}
_accounts = _decodeAccounts(_store.get(_keyAccounts));
final def = _store.get(_keyDefault);
_defaultServerId = def is String ? def : null;
final pick = _store.get(_keyLastPick);
_lastPick = pick is String ? JellyfinMedia.fromJson(_tryJson(pick)) : null;
} catch (e, st) {
debugPrint('load jellyfin accounts failed: $e\n$st');
if (_deviceId.isEmpty) _deviceId = _newDeviceId();
}
_loaded = true;
_notify();
if (validate) unawaited(validateAll());
}
static String _newDeviceId() {
final r = Random.secure();
return [for (var i = 0; i < 16; i++) r.nextInt(256).toRadixString(16).padLeft(2, '0')].join();
}
static Object? _tryJson(String s) {
try {
return jsonDecode(s);
} catch (_) {
return null;
}
}
static List<JellyfinAccount> _decodeAccounts(Object? raw) {
if (raw is! String) return const [];
final list = _tryJson(raw);
if (list is! List) return const [];
final out = <JellyfinAccount>[];
for (final j in list) {
final a = JellyfinAccount.fromJson(j);
if (a != null && out.every((o) => o.serverId != a.serverId)) out.add(a);
}
return out;
}
Future<void> _save() async {
try {
await _store.put(_keyAccounts, jsonEncode([for (final a in _accounts) a.toJson()]));
await _store.put(_keyDefault, _defaultServerId);
} catch (e, st) {
debugPrint('save jellyfin accounts failed: $e\n$st');
}
}
void _notify() {
if (!_disposed) notifyListeners();
}
// ------------------------------------------------------------- clients
/// A client without a token, for probing an address and signing in.
/// The caller closes it.
JellyfinClient anonymousClient(String baseUrl) =>
JellyfinClient(baseUrl: baseUrl, clientInfo: clientInfo, http: httpFactory?.call());
/// The signed-in client of a linked server (null if not linked). Shared;
/// do not close it.
JellyfinClient? clientFor(String serverId) {
final a = account(serverId);
if (a == null) return null;
final c = _clients[serverId];
if (c != null && c.baseUrl == a.baseUrl && c.accessToken == a.token) return c;
c?.close();
return _clients[serverId] = JellyfinClient(
baseUrl: a.baseUrl,
clientInfo: clientInfo,
accessToken: a.token,
userId: a.userId,
http: httpFactory?.call(),
);
}
// ---------------------------------------------------------- validation
Future<void> validateAll() => Future.wait([for (final a in _accounts) validate(a.serverId)]);
/// Checks the saved token. Tries each known address in order and moves
/// the first that answers to the front.
Future<JellyfinAccountStatus> validate(String serverId) async {
final start = account(serverId);
if (start == null) return JellyfinAccountStatus.unknown;
_status[serverId] = JellyfinAccountStatus.checking;
_notify();
Object? lastError;
for (final url in start.urls) {
final client = JellyfinClient(
baseUrl: url,
clientInfo: clientInfo,
accessToken: start.token,
userId: start.userId,
http: httpFactory?.call(),
);
try {
final user = await client.validateToken();
if (user == null) return _setStatus(serverId, JellyfinAccountStatus.expired);
final a = account(serverId);
if (a == null) return JellyfinAccountStatus.unknown;
_replace(a.copyWith(
userName: user.name.isEmpty ? null : user.name,
userImageTag: user.primaryImageTag,
urls: [url, ...a.urls.where((u) => u != url)],
));
unawaited(_save());
_errors.remove(serverId);
return _setStatus(serverId, JellyfinAccountStatus.online);
} catch (e) {
lastError = e;
} finally {
client.close();
}
}
_errors[serverId] = _short(lastError);
return _setStatus(serverId, JellyfinAccountStatus.offline);
}
static String _short(Object? e) {
final s = '$e'.replaceFirst('JellyfinException: ', '');
return s.length > 160 ? '${s.substring(0, 160)}…' : s;
}
JellyfinAccountStatus _setStatus(String serverId, JellyfinAccountStatus s) {
_status[serverId] = s;
_notify();
return s;
}
/// A browse call got 401: the token is gone, ask to reconnect.
void markExpired(String serverId) {
if (statusOf(serverId) == JellyfinAccountStatus.expired) return;
_setStatus(serverId, JellyfinAccountStatus.expired);
}
void _replace(JellyfinAccount a) {
_accounts = [for (final o in _accounts) o.serverId == a.serverId ? a : o];
}
// ------------------------------------------------------- link / unlink
/// Stores a sign-in made by this app. Signing in again to a linked server
/// replaces its account (and best-effort revokes the old token).
Future<JellyfinAccount> link({
required String baseUrl,
required JellyfinServerInfo server,
required JellyfinAuthResult auth,
}) async {
await ensureLoaded(validate: false);
final serverId = auth.serverId.isNotEmpty ? auth.serverId : server.id;
final old = account(serverId);
final a = JellyfinAccount(
serverId: serverId,
serverName: server.name,
serverVersion: server.version,
urls: [baseUrl, ...?old?.urls.where((u) => u != baseUrl)],
userId: auth.user.id,
userName: auth.user.name,
userImageTag: auth.user.primaryImageTag,
token: auth.accessToken,
);
if (old != null && old.token != a.token) unawaited(_revoke(old));
_accounts = old == null ? [..._accounts, a] : [for (final o in _accounts) o.serverId == serverId ? a : o];
_defaultServerId ??= serverId;
_status[serverId] = JellyfinAccountStatus.online;
_errors.remove(serverId);
_clearMemo(serverId);
await _save();
_notify();
return a;
}
Future<void> _revoke(JellyfinAccount a) async {
final c = JellyfinClient(
baseUrl: a.baseUrl,
clientInfo: clientInfo,
accessToken: a.token,
userId: a.userId,
http: httpFactory?.call());
try {
await c.logout();
} catch (_) {
} finally {
c.close();
}
}
/// Revokes the token on the server (best effort: an offline server or an
/// already dead token still unlinks) and forgets the account.
Future<void> signOut(String serverId) async {
final a = account(serverId);
if (a == null) return;
final c = _clients.remove(serverId);
c?.close();
await _revoke(a).timeout(const Duration(seconds: 8), onTimeout: () {});
_accounts = [for (final o in _accounts) if (o.serverId != serverId) o];
_status.remove(serverId);
_errors.remove(serverId);
_clearMemo(serverId);
if (_defaultServerId == serverId) _defaultServerId = _accounts.firstOrNull?.serverId;
if (_lastPick?.serverId == serverId) await rememberPick(null);
await _save();
_notify();
}
Future<void> setDefault(String serverId) async {
if (account(serverId) == null || _defaultServerId == serverId) return;
_defaultServerId = serverId;
_notify();
await _save();
}
Future<void> rememberPick(JellyfinMedia? pick) async {
_lastPick = pick;
try {
await _store.put(_keyLastPick, pick == null ? null : jsonEncode(pick.toJson()));
} catch (e) {
debugPrint('save jellyfin pick failed: $e');
}
}
// --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch.
/// Failures are not kept; a 401 marks the account expired.
Future<T> cached<T>(String serverId, String key, Future<T> Function() load,
{Duration ttl = const Duration(minutes: 3)}) {
final k = '$serverId|$key';
final hit = _memo[k];
if (hit != null && DateTime.now().difference(hit.at) < ttl) {
return hit.value.then((v) => v as T);
}
final f = load();
_memo[k] = (at: DateTime.now(), value: f);
f.catchError((Object e) {
if (identical(_memo[k]?.value, f)) _memo.remove(k);
if (e is JellyfinException && e.isUnauthorized) markExpired(serverId);
throw e;
}).ignore();
return f;
}
void _clearMemo(String serverId) =>
_memo.removeWhere((k, _) => k.startsWith('$serverId|'));
/// Drops cached browse results (refresh button, after a pick).
void invalidate(String serverId) => _clearMemo(serverId);
@override
void dispose() {
_disposed = true;
for (final c in _clients.values) {
c.close();
}
_clients.clear();
super.dispose();
}
}
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browser.dart' show JellyfinAvatar, StatusDot, showReconnectDialog;
import 'sign_in_panel.dart';
/// Integrations > Jellyfin: linked servers (avatar, status, default,
/// reconnect, sign out) and the inline add-server flow.
class JellyfinAccountsPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final Duration pollInterval;
const JellyfinAccountsPanel({
super.key,
required this.accounts,
this.pollInterval = const Duration(seconds: 3),
});
@override
State<JellyfinAccountsPanel> createState() => _JellyfinAccountsPanelState();
}
class _JellyfinAccountsPanelState extends State<JellyfinAccountsPanel> {
bool _adding = false;
@override
void initState() {
super.initState();
widget.accounts.ensureLoaded();
}
Future<void> _signOut(JellyfinAccount a) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Sign out of ${a.displayName}?'),
content: const Text(
'This app\'s sign-in is revoked on the server and the server is unlinked. '
'You can add it again any time.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
FilledButton(
key: const ValueKey('jf-sign-out-confirm'),
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign out')),
],
),
);
if (ok == true) await widget.accounts.signOut(a.serverId);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: widget.accounts,
builder: (context, _) {
final list = widget.accounts.accounts;
return Panel(
key: const ValueKey('jf-accounts'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.video_library_outlined,
size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4),
const Text(
'Browse your libraries in the Host tab and put up movies and episodes. '
'Sign in with Quick Connect or your password; several servers are fine.',
style: TextStyle(color: Colors.grey),
),
],
),
),
const SizedBox(width: kGap),
if (!_adding)
FilledButton.icon(
key: const ValueKey('jf-add-server'),
onPressed: () => setState(() => _adding = true),
icon: const Icon(Icons.add, size: 18),
label: const Text('Add server'),
),
],
),
if (list.isNotEmpty) const SizedBox(height: kGap),
for (final a in list)
_AccountTile(
accounts: widget.accounts,
account: a,
isDefault: widget.accounts.defaultServerId == a.serverId,
showDefault: list.length > 1,
onSignOut: () => _signOut(a),
),
if (_adding) ...[
const SizedBox(height: kGap),
const Divider(height: 1),
const SizedBox(height: kGap),
JellyfinSignInPanel(
accounts: widget.accounts,
pollInterval: widget.pollInterval,
onCancel: () => setState(() => _adding = false),
onDone: (_) => setState(() => _adding = false),
),
],
],
),
);
},
);
}
}
class _AccountTile extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final bool isDefault;
final bool showDefault;
final VoidCallback onSignOut;
const _AccountTile({
required this.accounts,
required this.account,
required this.isDefault,
required this.showDefault,
required this.onSignOut,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final status = accounts.statusOf(account.serverId);
final statusText = switch (status) {
JellyfinAccountStatus.online => 'Connected',
JellyfinAccountStatus.offline => 'Offline',
JellyfinAccountStatus.expired => 'Sign-in expired',
JellyfinAccountStatus.checking => 'Checking…',
JellyfinAccountStatus.unknown => 'Not checked',
};
return Padding(
key: ValueKey('jf-account-${account.serverId}'),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
JellyfinAvatar(accounts: accounts, account: account),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${account.userName} on ${account.displayName}', style: t.titleSmall),
Row(children: [
StatusDot(status: status),
const SizedBox(width: 6),
Flexible(
child: Text(
[
statusText,
if (account.serverVersion.isNotEmpty) 'Jellyfin ${account.serverVersion}',
account.baseUrl,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey),
),
),
]),
],
),
),
if (showDefault)
IconButton(
key: ValueKey('jf-default-${account.serverId}'),
tooltip: isDefault ? 'Default server' : 'Make default',
onPressed: () => accounts.setDefault(account.serverId),
icon: Icon(isDefault ? Icons.star : Icons.star_border,
color: isDefault ? Theme.of(context).colorScheme.primary : null),
),
if (status == JellyfinAccountStatus.expired)
FilledButton.tonal(
key: ValueKey('jf-reconnect-${account.serverId}'),
onPressed: () => showReconnectDialog(context, accounts, account),
child: const Text('Reconnect'),
)
else if (status == JellyfinAccountStatus.offline)
TextButton(
onPressed: () => accounts.validate(account.serverId),
child: const Text('Retry'),
),
const SizedBox(width: 4),
TextButton(
key: ValueKey('jf-sign-out-${account.serverId}'),
onPressed: onSignOut,
child: const Text('Sign out'),
),
],
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
import 'format.dart';
/// One level of the library browser's back stack.
sealed class BrowseRoute {
const BrowseRoute();
String get label;
/// Identity for keeping a page's state while it is in the stack.
String get key;
}
final class HomeRoute extends BrowseRoute {
const HomeRoute();
@override
String get label => 'Home';
@override
String get key => 'home';
}
/// A library or folder grid.
final class LibraryRoute extends BrowseRoute {
final JellyfinItem folder;
const LibraryRoute(this.folder);
@override
String get label => folder.name;
@override
String get key => 'lib:${folder.id}';
}
final class SeriesRoute extends BrowseRoute {
final JellyfinItem series;
/// Season to open on (from a season card or an episode's "open series").
final String? seasonId;
const SeriesRoute(this.series, {this.seasonId});
@override
String get label => series.name;
@override
String get key => 'series:${series.id}';
}
/// Movie / episode / video detail.
final class DetailRoute extends BrowseRoute {
final JellyfinItem item;
const DetailRoute(this.item);
@override
String get label => item.kind == JellyfinItemKind.episode
? (item.episodeLabel ?? item.name)
: item.name;
@override
String get key => 'item:${item.id}';
}
/// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinClient client;
/// Item id of the current pick on this server, highlighted everywhere.
final String? selectedItemId;
final ValueChanged<JellyfinItem> onPick;
final ValueChanged<BrowseRoute> onOpen;
/// Hover / focus: the inspector pane follows it.
final ValueChanged<JellyfinItem> onPeek;
const BrowseScope({
super.key,
required this.accounts,
required this.account,
required this.client,
required this.selectedItemId,
required this.onPick,
required this.onOpen,
required this.onPeek,
required super.child,
});
static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
/// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client));
/// Click / Enter on a card: playable items are picked, containers open.
void activate(JellyfinItem item) {
if (isPlayable(item)) {
onPick(item);
return;
}
switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season:
final sid = item.seriesId;
if (sid != null) {
onOpen(SeriesRoute(
JellyfinItem(id: sid, name: item.seriesName ?? item.name, type: 'Series'),
seasonId: item.id));
}
default:
onOpen(LibraryRoute(item));
}
}
/// Opens the series of an episode (or the item itself if it has none).
void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId;
if (sid == null) return;
onOpen(SeriesRoute(
JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'),
seasonId: episode.seasonId));
}
@override
bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId ||
old.account != account ||
old.client != client;
}
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
/// a spinner meanwhile, and a compact error with Retry on failure.
class Loader<T> extends StatefulWidget {
final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder;
final Widget? loading;
/// When false the error collapses to nothing (optional sections).
final bool showError;
const Loader({
super.key,
required this.load,
required this.builder,
this.loading,
this.showError = true,
});
@override
State<Loader<T>> createState() => _LoaderState<T>();
}
class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load();
void _retry() => setState(() => _future = widget.load());
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: _future,
builder: (context, snap) {
if (snap.hasData) return widget.builder(context, snap.data as T);
if (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry);
}
if (snap.connectionState == ConnectionState.done) {
return widget.builder(context, snap.data as T);
}
return widget.loading ??
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
},
);
}
}
class LoadError extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
const LoadError({super.key, required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
final e = error;
final msg = e is JellyfinException
? (e.isUnauthorized ? 'Sign-in expired' : e.message)
: '$e';
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, size: 18, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
Flexible(
child: Text(msg.length > 160 ? '${msg.substring(0, 160)}…' : msg,
style: const TextStyle(color: Colors.grey)),
),
const SizedBox(width: 8),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
),
);
}
}
+546
View File
@@ -0,0 +1,546 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart';
import 'search_results.dart';
import 'series_page.dart';
import 'sign_in_panel.dart';
/// Browser width from which the inspector pane shows on the right.
const inspectorFrom = 1400.0;
const inspectorWidth = 340.0;
/// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back
/// keeps scroll position and loaded pages.
///
/// Keyboard: arrows move between cards, Enter picks / opens, I opens
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
/// Shown when given: toggles a bigger browser (the host hides its form).
final VoidCallback? onToggleExpanded;
final bool expanded;
final VoidCallback? onOpenIntegrations;
final Duration searchDebounce;
const JellyfinBrowser({
super.key,
required this.accounts,
required this.onPick,
this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations,
this.searchDebounce = const Duration(milliseconds: 300),
});
@override
State<JellyfinBrowser> createState() => _JellyfinBrowserState();
}
class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = '';
Timer? _debounce;
int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts;
@override
void initState() {
super.initState();
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
? widget.selected!.serverId
: _accounts.defaultServerId;
_accounts.addListener(_changed);
}
@override
void didUpdateWidget(JellyfinBrowser old) {
super.didUpdateWidget(old);
if (old.accounts != widget.accounts) {
old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed);
}
}
@override
void dispose() {
_accounts.removeListener(_changed);
_debounce?.cancel();
_search.dispose();
_searchFocus.dispose();
_body.dispose();
_peek.dispose();
super.dispose();
}
void _changed() {
if (mounted) setState(() {});
}
JellyfinAccount? get _account =>
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
void _switchServer(String serverId) {
if (serverId == _account?.serverId) return;
setState(() {
_serverId = serverId;
_stack = const [HomeRoute()];
_peek.value = null;
_clearSearch();
});
}
// ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_body.requestFocus();
}
bool _back() {
if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_body.requestFocus();
return true;
}
void _popTo(int index) {
setState(() {
_clearSearch();
_stack = _stack.sublist(0, index + 1);
});
_body.requestFocus();
}
void _onSearchChanged(String text) {
_debounce?.cancel();
_debounce = Timer(widget.searchDebounce, () {
if (mounted) setState(() => _term = text.trim());
});
}
void _clearSearch() {
_debounce?.cancel();
_search.clear();
_term = '';
}
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
final inField = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.escape) {
if (_search.text.isNotEmpty || _term.isNotEmpty) {
setState(_clearSearch);
_body.requestFocus();
return KeyEventResult.handled;
}
if (inField) {
_body.requestFocus();
return KeyEventResult.handled;
}
return _back() ? KeyEventResult.handled : KeyEventResult.ignored;
}
if (inField) return KeyEventResult.ignored;
if (e.character == '/' || (keys.isControlPressed && k == LogicalKeyboardKey.keyF)) {
_searchFocus.requestFocus();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.backspace ||
(keys.isAltPressed && k == LogicalKeyboardKey.arrowLeft) ||
k == LogicalKeyboardKey.browserBack) {
return _back() ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
}
// ------------------------------------------------------------------ build
Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
};
@override
Widget build(BuildContext context) {
final account = _account;
if (account == null) {
return const Center(child: Text('No Jellyfin server linked', style: TextStyle(color: Colors.grey)));
}
final client = _accounts.clientFor(account.serverId)!;
final status = _accounts.statusOf(account.serverId);
final sel = widget.selected;
final searching = _term.isNotEmpty;
final pages = Stack(
children: [
for (var i = 0; i < _stack.length; i++)
Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
visible: !searching && i == _stack.length - 1,
maintainState: true,
child: _page(_stack[i]),
),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
],
);
return BrowseScope(
accounts: _accounts,
account: account,
client: client,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick,
onOpen: _open,
onPeek: (item) => _peek.value = item,
child: Focus(
onKeyEvent: _onKey,
child: Column(
key: const ValueKey('jf-browser'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(context, account),
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(builder: (context, box) {
final inspector = box.maxWidth >= inspectorFrom;
final body = FocusScope(node: _body, child: pages);
if (!inspector) return body;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: body),
const SizedBox(width: 16),
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
],
);
}),
),
],
),
),
);
}
Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context);
final t = theme.textTheme;
final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty;
if (i > 0) {
crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.chevron_right, size: 18, color: Colors.grey),
));
}
final label = Text(
_stack[i].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: last ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant,
fontWeight: last ? FontWeight.w600 : null),
);
crumbs.add(Flexible(
child: last
? label
: InkWell(
key: ValueKey('crumb-$i'),
borderRadius: BorderRadius.circular(6),
onTap: () => _popTo(i),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: label),
),
));
}
if (_term.isNotEmpty) {
crumbs
..add(const Icon(Icons.chevron_right, size: 18, color: Colors.grey))
..add(Flexible(
child: Text('Search “$_term”',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
}
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
onSelect: _switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
),
),
),
),
IconButton(
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
if (widget.onToggleExpanded != null)
IconButton(
key: const ValueKey('jf-expand'),
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
),
],
);
}
}
/// Current server (avatar + name); a menu when several are linked.
class _ServerSwitcher extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount current;
final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations;
const _ServerSwitcher({
required this.accounts,
required this.current,
required this.onSelect,
this.onOpenIntegrations,
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final chip = Row(
mainAxisSize: MainAxisSize.min,
children: [
StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
),
],
);
if (accounts.accounts.length < 2 && onOpenIntegrations == null) {
return Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: chip);
}
return MenuAnchor(
menuChildren: [
for (final a in accounts.accounts)
MenuItemButton(
key: ValueKey('server-${a.serverId}'),
leadingIcon: StatusDot(status: accounts.statusOf(a.serverId)),
trailingIcon: a.serverId == current.serverId ? const Icon(Icons.check, size: 18) : null,
onPressed: () => onSelect(a.serverId),
child: Text('${a.displayName} · ${a.userName}'),
),
if (onOpenIntegrations != null)
MenuItemButton(
leadingIcon: const Icon(Icons.add, size: 18),
onPressed: onOpenIntegrations,
child: const Text('Manage servers…'),
),
],
builder: (context, menu, _) => TextButton(
key: const ValueKey('jf-server-switch'),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
child: Row(mainAxisSize: MainAxisSize.min, children: [
chip,
const Icon(Icons.arrow_drop_down, size: 20),
]),
),
);
}
}
class StatusDot extends StatelessWidget {
final JellyfinAccountStatus status;
const StatusDot({super.key, required this.status});
@override
Widget build(BuildContext context) {
final color = switch (status) {
JellyfinAccountStatus.online => Colors.green,
JellyfinAccountStatus.offline => Colors.orange,
JellyfinAccountStatus.expired => Theme.of(context).colorScheme.error,
_ => Colors.grey,
};
return Container(
width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle));
}
}
class _StatusBanner extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinAccountStatus status;
const _StatusBanner({required this.accounts, required this.account, required this.status});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final expired = status == JellyfinAccountStatus.expired;
return Container(
key: const ValueKey('jf-status-banner'),
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: (expired ? scheme.errorContainer : Colors.orange.shade900).withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(expired ? Icons.lock_clock : Icons.cloud_off, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(expired
? 'Sign-in to ${account.displayName} expired. Reconnect to keep browsing.'
: "Can't reach ${account.displayName}: ${accounts.errorOf(account.serverId) ?? 'offline'}"),
),
if (expired)
FilledButton.tonal(
onPressed: () => showReconnectDialog(context, accounts, account),
child: const Text('Reconnect'),
)
else
TextButton(
onPressed: () => accounts.validate(account.serverId),
child: const Text('Retry'),
),
],
),
);
}
}
/// Sign in again to an expired account (same server, new token).
Future<void> showReconnectDialog(
BuildContext context, JellyfinAccounts accounts, JellyfinAccount account) {
return showDialog<void>(
context: context,
builder: (context) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Padding(
padding: const EdgeInsets.all(20),
child: JellyfinSignInPanel(
accounts: accounts,
reconnect: account,
onDone: (_) => Navigator.of(context).pop(),
onCancel: () => Navigator.of(context).pop(),
),
),
),
),
);
}
/// Small helper for other screens: the user's avatar on a linked server.
class JellyfinAvatar extends StatelessWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final double radius;
const JellyfinAvatar({super.key, required this.accounts, required this.account, this.radius = 18});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final initials = account.userName.isEmpty ? '?' : account.userName.characters.first.toUpperCase();
final fallback = CircleAvatar(
radius: radius,
backgroundColor: scheme.primaryContainer,
child: Text(initials, style: TextStyle(color: scheme.onPrimaryContainer)),
);
final client = accounts.clientFor(account.serverId);
final url = client?.userImageUrl(account.user, maxWidth: (radius * 4).round());
if (url == null) return fallback;
return ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: Image.network(url.toString(),
fit: BoxFit.cover,
cacheWidth: (radius * 4).round(),
errorBuilder: (_, _, _) => fallback),
),
);
}
}
+424
View File
@@ -0,0 +1,424 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
enum CardShape {
/// 2:3 poster (movies, series, latest).
poster(2 / 3, 150),
/// 16:9 thumbnail (continue watching, next up, episodes).
thumb(16 / 9, 260);
/// Image width / height.
final double aspect;
/// Smallest card width; rows and grids add columns past it.
final double minWidth;
const CardShape(this.aspect, this.minWidth);
}
/// Title + subtitle lines under a card image.
const cardTextHeight = 46.0;
const cardGap = 12.0;
/// Card image for [item] in [shape]: thumbs prefer the item's own
/// screenshot / thumb / backdrop, posters the primary (series poster for
/// episodes).
JellyfinImageRef? cardImage(JellyfinItem item, CardShape shape) {
if (shape == CardShape.poster) return item.image(JellyfinImageType.primary);
final own = item.ownImage(JellyfinImageType.primary);
final primaryIsWide = (item.primaryImageAspectRatio ?? 0) > 1.2 ||
item.kind == JellyfinItemKind.episode ||
item.kind == JellyfinItemKind.video;
if (own != null && primaryIsWide) return own;
return item.image(JellyfinImageType.thumb) ??
item.image(JellyfinImageType.backdrop) ??
item.image(JellyfinImageType.primary);
}
IconData kindIcon(JellyfinItem item) => switch (item.kind) {
JellyfinItemKind.series || JellyfinItemKind.season => Icons.live_tv_outlined,
JellyfinItemKind.episode => Icons.tv_outlined,
JellyfinItemKind.folder || JellyfinItemKind.collectionFolder => Icons.folder_outlined,
JellyfinItemKind.boxSet => Icons.collections_bookmark_outlined,
_ => Icons.movie_outlined,
};
/// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and
/// focus feed the inspector, "i" or the hover button opens the details.
class MediaCard extends StatefulWidget {
final JellyfinItem item;
final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top,
/// "S2E3 · 12 min left" below.
final bool seriesFirst;
final bool autofocus;
const MediaCard({
super.key,
required this.item,
required this.shape,
this.seriesFirst = false,
this.autofocus = false,
});
@override
State<MediaCard> createState() => _MediaCardState();
}
class _MediaCardState extends State<MediaCard> {
bool _hover = false;
bool _focus = false;
JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
_details(scope);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final selected = scope.selectedItemId == item.id;
final played = item.userData.played;
final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount;
final ring = selected
? scheme.primary
: _focus
? scheme.onSurface
: Colors.transparent;
final image = AspectRatio(
aspectRatio: widget.shape.aspect,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ring, width: 2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: cardImage(item, widget.shape),
icon: kindIcon(item),
),
if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor: Colors.black54,
),
),
Positioned(
top: 6,
right: 6,
child: selected
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
: unplayed != null && unplayed > 0 && !isPlayable(item)
? _Badge(
color: scheme.primaryContainer,
child: Text('$unplayed',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(),
),
if (_hover && isPlayable(item))
Positioned(
top: 4,
left: 4,
child: IconButton.filledTonal(
key: ValueKey('card-info-${item.id}'),
tooltip: 'Details (I)',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: () => _details(scope),
icon: const Icon(Icons.info_outline),
),
),
],
),
),
),
);
final title = cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => scope.activate(item),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
],
),
),
),
);
}
}
class _Badge extends StatelessWidget {
final Color color;
final Widget child;
const _Badge({required this.color, required this.child});
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minWidth: 22, minHeight: 22),
padding: const EdgeInsets.symmetric(horizontal: 5),
alignment: Alignment.center,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(11)),
child: child,
);
}
/// Columns and card width that fill [width] exactly with cards at least
/// [minWidth] wide: no half-cut card at the edge, any window width.
({int cols, double itemWidth}) fitColumns(double width, double minWidth, {double gap = cardGap}) {
final cols = math.max(1, ((width + gap) / (minWidth + gap)).floor());
return (cols: cols, itemWidth: (width - gap * (cols - 1)) / cols);
}
/// Grid layout for cards: as many columns as fit, height = image + text.
class CardGridDelegate extends SliverGridDelegate {
final CardShape shape;
final double minWidth;
CardGridDelegate(this.shape, {double? minWidth}) : minWidth = minWidth ?? shape.minWidth;
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
final fit = fitColumns(constraints.crossAxisExtent, minWidth);
final h = fit.itemWidth / shape.aspect + cardTextHeight;
return SliverGridRegularTileLayout(
crossAxisCount: fit.cols,
mainAxisStride: h + 18,
crossAxisStride: fit.itemWidth + cardGap,
childMainAxisExtent: h,
childCrossAxisExtent: fit.itemWidth,
reverseCrossAxis: false,
);
}
@override
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
}
/// A titled horizontal row of cards sized to fill the width; arrows (on
/// hover) page through it. Lazy: only visible cards are built.
class MediaRow extends StatefulWidget {
final String title;
final List<JellyfinItem> items;
final CardShape shape;
final bool seriesFirst;
final Widget? trailing;
const MediaRow({
super.key,
required this.title,
required this.items,
required this.shape,
this.seriesFirst = false,
this.trailing,
});
@override
State<MediaRow> createState() => _MediaRowState();
}
class _MediaRowState extends State<MediaRow> {
final _scroll = ScrollController();
bool _hover = false;
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _page(int dir, double width) {
if (!_scroll.hasClients) return;
final p = _scroll.position;
final to = (p.pixels + dir * (width + cardGap)).clamp(0.0, p.maxScrollExtent);
_scroll.animateTo(to, duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(widget.title, style: t.titleMedium),
const SizedBox(width: 8),
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
const Spacer(),
?widget.trailing,
],
),
),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols;
return SizedBox(
height: h,
child: Stack(
children: [
ListView.separated(
controller: _scroll,
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: widget.items[i],
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
),
),
if (overflow && _hover) ...[
_PageArrow(left: true, height: h - cardTextHeight, onTap: () => _page(-1, box.maxWidth)),
_PageArrow(left: false, height: h - cardTextHeight, onTap: () => _page(1, box.maxWidth)),
],
],
),
);
}),
],
),
);
}
}
class _PageArrow extends StatelessWidget {
final bool left;
final double height;
final VoidCallback onTap;
const _PageArrow({required this.left, required this.height, required this.onTap});
@override
Widget build(BuildContext context) {
return Positioned(
left: left ? 0 : null,
right: left ? null : 0,
top: 0,
height: height,
child: Center(
child: ExcludeFocus(
child: IconButton.filledTonal(
tooltip: left ? 'Previous' : 'Next',
onPressed: onTap,
icon: Icon(left ? Icons.chevron_left : Icons.chevron_right),
),
),
),
);
}
}
/// Section heading used above grids and rows.
class SectionHeader extends StatelessWidget {
final String title;
final String? count;
final Widget? trailing;
const SectionHeader(this.title, {super.key, this.count, this.trailing});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
children: [
Text(title, style: t.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
const Spacer(),
?trailing,
],
),
);
}
}
@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Movie / episode detail: art, title, meta, overview, the pick button and
/// what is in the file (video, audio and subtitle tracks).
class JellyfinDetailPage extends StatelessWidget {
final JellyfinItem item;
const JellyfinDetailPage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<JellyfinItem>(
key: ValueKey('detail-${item.id}-${scope.account.serverId}'),
load: () => scope.load('item:${item.id}', (c) => c.item(item.id)),
builder: (context, full) => _Detail(item: full),
);
}
}
class _Detail extends StatelessWidget {
final JellyfinItem item;
const _Detail({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final episode = item.kind == JellyfinItemKind.episode;
final selected = scope.selectedItemId == item.id;
final meta = [
?item.episodeLabel,
if (item.productionYear != null) '${item.productionYear}',
if (item.runTime != null) formatRuntime(item.runTime!),
?timeLeft(item),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (item.userData.played) 'Watched',
].join(' · ');
final art = episode
? AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client,
image: item.ownImage(JellyfinImageType.primary) ??
item.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined),
),
)
: AspectRatio(
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client, image: item.image(JellyfinImageType.primary)),
),
);
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
TextButton.icon(
key: const ValueKey('detail-series'),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () => scope.openSeriesOf(item),
icon: const Icon(Icons.live_tv_outlined, size: 18),
label: Text(item.seriesName!, style: t.titleMedium),
),
SelectableText(item.name, style: t.headlineSmall),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (progressOf(item) > 0) ...[
const SizedBox(height: 8),
SizedBox(
width: 240,
child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)),
],
const SizedBox(height: 16),
FilledButton.icon(
key: const ValueKey('detail-pick'),
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow),
label: Text(selected ? 'Picked' : 'Pick to watch'),
),
if (item.overview != null) ...[
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: SelectableText(item.overview!, style: t.bodyMedium),
),
],
const SizedBox(height: 16),
_Tracks(item: item),
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: LayoutBuilder(builder: (context, box) {
final wide = box.maxWidth >= 760;
final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0;
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: episode ? 320 : 180, child: art)),
const SizedBox(height: 16),
info,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: artWidth, child: art),
const SizedBox(width: 24),
Expanded(child: info),
],
);
}),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
/// "1 h 42 min", "24 min", "45 s".
String formatRuntime(Duration d) {
final h = d.inHours, m = d.inMinutes % 60;
if (h > 0) return m == 0 ? '$h h' : '$h h $m min';
if (d.inMinutes > 0) return '${d.inMinutes} min';
return '${d.inSeconds} s';
}
/// Watched share of a resumable item, 0 when there is no resume point.
double progressOf(JellyfinItem item) {
final run = item.runTimeTicks;
final pos = item.userData.playbackPositionTicks;
if (run == null || run <= 0 || pos <= 0) return 0;
return (pos / run).clamp(0.0, 1.0);
}
/// "12 min left" for a resumable item, else null.
String? timeLeft(JellyfinItem item) {
final run = item.runTimeTicks;
if (run == null || !item.userData.isResumable) return null;
final left = ticksToDuration(run - item.userData.playbackPositionTicks);
if (left.inMinutes < 1) return 'almost done';
return '${formatRuntime(left)} left';
}
bool isPlayable(JellyfinItem item) => switch (item.kind) {
JellyfinItemKind.movie || JellyfinItemKind.episode || JellyfinItemKind.video => true,
_ => !item.isFolder && item.mediaType == 'Video',
};
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
switch (item.kind) {
case JellyfinItemKind.episode:
if (withSeries && item.seriesName != null) parts.add(item.seriesName!);
parts.add(item.episodeLabel ?? item.seasonName ?? 'Episode');
case JellyfinItemKind.series:
if (item.productionYear != null) parts.add('${item.productionYear}');
final eps = item.recursiveItemCount, seasons = item.childCount;
if (eps != null && eps > 0) {
parts.add('$eps eps');
} else if (seasons != null && seasons > 0) {
parts.add(seasons == 1 ? '1 season' : '$seasons seasons');
}
case JellyfinItemKind.season:
final n = item.childCount ?? item.recursiveItemCount;
if (n != null) parts.add('$n eps');
default:
if (item.productionYear != null) parts.add('${item.productionYear}');
}
final left = timeLeft(item);
if (left != null) {
parts.add(left);
} else if (isPlayable(item) && item.runTime != null) {
parts.add(formatRuntime(item.runTime!));
}
return parts.join(' · ');
}
/// Title on a card: episodes in rows that mix series show the series name
/// on top and the episode below.
String cardTitle(JellyfinItem item, {bool seriesFirst = false}) =>
seriesFirst && item.kind == JellyfinItemKind.episode && item.seriesName != null
? item.seriesName!
: item.name;
/// The selection for a playable [item] of [account].
JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
final String subtitle;
if (item.kind == JellyfinItemKind.episode) {
subtitle = [?item.seriesName, ?item.episodeLabel].join(' · ');
} else {
subtitle = item.productionYear?.toString() ?? '';
}
return JellyfinMedia(
serverId: account.serverId,
baseUrl: account.baseUrl,
itemId: item.id,
mediaSourceId: item.mediaSources.length > 1 ? item.mediaSources.first.id : null,
title: item.name,
subtitle: subtitle,
runtime: item.runTime,
);
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
/// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser.
bool isVideoLibrary(JellyfinItem view) => switch (view.collectionType) {
'movies' || 'tvshows' || 'homevideos' || 'boxsets' || 'mixed' || 'musicvideos' || null => true,
_ => false,
};
IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
'movies' => Icons.movie_outlined,
'tvshows' => Icons.live_tv_outlined,
'boxsets' => Icons.collections_bookmark_outlined,
'homevideos' => Icons.videocam_outlined,
_ => Icons.folder_outlined,
};
/// Start page: library shortcuts, then Continue watching, Next up and the
/// latest additions of each library. Each row loads on its own, so a slow
/// library does not hold up the rest.
class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()),
builder: (context, all) {
final views = all.where(isVideoLibrary).toList();
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
_LibraryStrip(views: views),
const SizedBox(height: 20),
_Section(
key: const ValueKey('row-resume'),
title: 'Continue watching',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
),
_Section(
key: const ValueKey('row-nextup'),
title: 'Next up',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
),
for (final v in views)
_Section(
key: ValueKey('row-latest-${v.id}'),
title: 'Latest in ${v.name}',
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
trailing: TextButton(
onPressed: () => scope.onOpen(LibraryRoute(v)),
child: const Text('Show all'),
),
),
],
);
},
);
}
}
class _LibraryStrip extends StatelessWidget {
final List<JellyfinItem> views;
const _LibraryStrip({required this.views});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (views.isEmpty) {
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final v in views)
ActionChip(
key: ValueKey('library-${v.id}'),
avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)),
),
],
);
}
}
class _Section extends StatelessWidget {
final String title;
final CardShape shape;
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load;
final Widget? trailing;
const _Section({
super.key,
required this.title,
required this.shape,
required this.load,
this.seriesFirst = false,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Loader<List<JellyfinItem>>(
load: load,
showError: false,
loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
items: items,
shape: shape,
seriesFirst: seriesFirst,
trailing: trailing,
),
),
);
}
}
class _RowSkeleton extends StatelessWidget {
final String title;
final CardShape shape;
const _RowSkeleton({required this.title, required this.shape});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SectionHeader(title),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, shape.minWidth);
return Row(
children: [
for (var i = 0; i < fit.cols; i++) ...[
if (i > 0) const SizedBox(width: cardGap),
Expanded(child: SkeletonCard(shape: shape)),
],
],
);
}),
],
),
);
}
}
class SkeletonCard extends StatelessWidget {
final CardShape shape;
const SkeletonCard({super.key, required this.shape});
@override
Widget build(BuildContext context) {
final c = Theme.of(context).colorScheme.surfaceContainerHigh.withValues(alpha: 0.6);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: shape.aspect,
child: DecoratedBox(
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(8))),
),
const SizedBox(height: cardTextHeight),
],
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
/// Server-side scale steps: nearby sizes share one URL (and one cache
/// entry) instead of fetching per-pixel variants on every resize.
const _widthSteps = [160, 240, 320, 480, 640, 960, 1280, 1920, 2560];
int imageWidthStep(double physicalWidth) {
for (final s in _widthSteps) {
if (s >= physicalWidth) return s;
}
return _widthSteps.last;
}
/// A Jellyfin image sized to its box. The URL carries the image tag and the
/// width step, so Flutter's image cache (keyed by URL) reuses it until the
/// image changes on the server; decode is capped at the same width. Shows
/// [placeholder] (or a tinted box with [icon]) while loading and on error,
/// and fades in when ready.
class JellyfinImage extends StatelessWidget {
final JellyfinClient client;
final JellyfinImageRef? image;
final BoxFit fit;
final IconData icon;
final Alignment alignment;
final Widget? placeholder;
const JellyfinImage({
super.key,
required this.client,
required this.image,
this.fit = BoxFit.cover,
this.icon = Icons.movie_outlined,
this.alignment = Alignment.center,
this.placeholder,
});
@override
Widget build(BuildContext context) {
final ref = image;
final fallback = placeholder ?? _Placeholder(icon: icon);
if (ref == null) return fallback;
return LayoutBuilder(builder: (context, box) {
final dpr = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1;
final w = box.hasBoundedWidth ? box.maxWidth : 480.0;
final step = imageWidthStep(w * dpr);
final url = client.imageUrl(ref, maxWidth: step, quality: 85).toString();
return Image.network(
url,
key: ValueKey(url),
fit: fit,
alignment: alignment,
width: double.infinity,
height: double.infinity,
cacheWidth: step,
filterQuality: FilterQuality.medium,
frameBuilder: (context, child, frame, sync) {
if (sync) return child;
return Stack(fit: StackFit.passthrough, children: [
if (frame == null) fallback,
AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 180),
child: child,
),
]);
},
errorBuilder: (context, _, _) => fallback,
);
});
}
}
class _Placeholder extends StatelessWidget {
final IconData icon;
const _Placeholder({required this.icon});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ColoredBox(
color: scheme.surfaceContainerHigh,
child: Center(
child: Icon(icon, size: 28, color: scheme.onSurfaceVariant.withValues(alpha: 0.35)),
),
);
}
}
@@ -0,0 +1,128 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Side pane on wide windows: details of whatever card is hovered or
/// focused, so browsing needs no extra clicks. Pick / Open act on it.
class JellyfinInspector extends StatelessWidget {
final ValueListenable<JellyfinItem?> item;
const JellyfinInspector({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
key: const ValueKey('jf-inspector'),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: ValueListenableBuilder<JellyfinItem?>(
valueListenable: item,
builder: (context, it, _) => it == null
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('Hover or focus a title to see its details here.',
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
),
)
: _Body(item: it),
),
);
}
}
class _Body extends StatelessWidget {
final JellyfinItem item;
const _Body({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final t = Theme.of(context).textTheme;
final playable = isPlayable(item);
final selected = scope.selectedItemId == item.id;
final episode = item.kind == JellyfinItemKind.episode;
final art = episode
? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop)
: item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb);
final meta = [
if (item.productionYear != null) '${item.productionYear}',
if (playable && item.runTime != null) formatRuntime(item.runTime!),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (!playable && (item.userData.unplayedItemCount ?? 0) > 0)
'${item.userData.unplayedItemCount} unwatched',
if (item.userData.played) 'Watched',
].join(' · ');
return ListView(
padding: EdgeInsets.zero,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: JellyfinImage(client: scope.client, image: art),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)),
Text(
episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name,
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (timeLeft(item) != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressOf(item), minHeight: 3),
const SizedBox(height: 4),
Text(timeLeft(item)!, style: t.bodySmall),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
if (playable)
FilledButton.icon(
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18),
label: Text(selected ? 'Picked' : 'Pick'),
)
else
FilledButton.tonalIcon(
onPressed: () => scope.activate(item),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Open'),
),
if (playable)
OutlinedButton(
onPressed: () => scope.onOpen(DetailRoute(item)),
child: const Text('Details'),
),
if (episode && item.seriesId != null)
OutlinedButton(
onPressed: () => scope.openSeriesOf(item),
child: const Text('Series'),
),
]),
if (item.overview != null) ...[
const SizedBox(height: 12),
Text(item.overview!, style: t.bodyMedium),
],
],
),
),
],
);
}
}
@@ -0,0 +1,276 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_page.dart' show SkeletonCard;
enum LibrarySort {
name('Name', JellyfinSortBy.sortName, false),
added('Date added', JellyfinSortBy.dateCreated, true),
premiere('Release date', JellyfinSortBy.premiereDate, true),
rating('Rating', JellyfinSortBy.communityRating, true),
random('Random', JellyfinSortBy.random, false);
final String label;
final String apiName;
/// Natural direction: newest / best first.
final bool descending;
const LibrarySort(this.label, this.apiName, this.descending);
}
/// Item types and recursion for a library: movie and show libraries list
/// their titles flat; anything else is browsed folder by folder.
({List<String> types, bool recursive}) libraryShape(JellyfinItem folder) =>
switch (folder.collectionType) {
'movies' => (types: const [JellyfinItemType.movie], recursive: true),
'tvshows' => (types: const [JellyfinItemType.series], recursive: true),
'boxsets' => (types: const [JellyfinItemType.boxSet], recursive: true),
_ => (types: const <String>[], recursive: false),
};
const libraryPageSize = 100;
/// One library (or folder) as a grid that fills the width, with sort,
/// unplayed / favourite filters and infinite scroll. The scrollbar spans
/// the full count from the start: not-yet-loaded cells are skeletons and
/// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder});
@override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
bool _unplayed = false;
bool _favorites = false;
final _items = <JellyfinItem>[];
int? _total;
bool _loading = false;
Object? _error;
int _gen = 0;
final _scroll = ScrollController();
BrowseScope? _scope;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final scope = BrowseScope.of(context);
final first = _scope == null;
final changedServer = _scope != null && _scope!.client != scope.client;
_scope = scope;
if (first || changedServer) _reload();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
JellyfinItemQuery get _query {
final shape = libraryShape(widget.folder);
return JellyfinItemQuery(
parentId: widget.folder.id,
includeItemTypes: shape.types,
recursive: shape.recursive,
sortBy: [
// Folders first when browsing a folder tree.
if (!shape.recursive && _sort == LibrarySort.name) 'IsFolder',
_sort.apiName,
if (_sort != LibrarySort.name) JellyfinSortBy.sortName,
],
descending: _descending,
filters: [
if (_unplayed) JellyfinFilter.isUnplayed,
if (_favorites) JellyfinFilter.isFavorite,
],
);
}
void _reload() {
_gen++;
_items.clear();
_total = null;
_error = null;
_loading = false;
if (_scroll.hasClients) _scroll.jumpTo(0);
_loadMore();
}
Future<void> _loadMore() async {
final scope = _scope;
if (scope == null || _loading) return;
if (_total != null && _items.length >= _total!) return;
final gen = _gen;
setState(() {
_loading = true;
_error = null;
});
try {
final page = await scope.client.items(_query.page(_items.length, libraryPageSize));
if (gen != _gen || !mounted) return;
setState(() {
_items.addAll(page.items);
// An empty page before the advertised end: the library shrank.
_total = page.items.isEmpty ? _items.length : page.totalRecordCount;
_loading = false;
});
} catch (e) {
if (gen != _gen || !mounted) return;
if (e is JellyfinException && e.isUnauthorized) {
scope.accounts.markExpired(scope.account.serverId);
}
setState(() {
_loading = false;
_error = e;
});
}
}
void _setSort(LibrarySort s) {
setState(() {
_sort = s;
_descending = s.descending;
});
_reload();
}
CardShape get _shape =>
widget.folder.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final total = _total;
final toolbar = Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(widget.folder.name, style: t.titleLarge),
const SizedBox(width: 4),
Text(total == null ? '' : '$total ${total == 1 ? 'item' : 'items'}',
key: const ValueKey('library-count'),
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(width: 12),
_SortMenu(sort: _sort, onSelected: _setSort),
if (_sort != LibrarySort.random)
IconButton(
tooltip: _descending ? 'Descending' : 'Ascending',
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _descending = !_descending);
_reload();
},
icon: Icon(_descending ? Icons.arrow_downward : Icons.arrow_upward, size: 18),
)
else
IconButton(
tooltip: 'Shuffle again',
visualDensity: VisualDensity.compact,
onPressed: _reload,
icon: const Icon(Icons.shuffle, size: 18),
),
FilterChip(
key: const ValueKey('filter-unplayed'),
label: const Text('Unwatched'),
selected: _unplayed,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _unplayed = v);
_reload();
},
),
FilterChip(
key: const ValueKey('filter-favorites'),
label: const Text('Favorites'),
selected: _favorites,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _favorites = v);
_reload();
},
),
],
);
final count = total ?? (_loading ? libraryPageSize ~/ 4 : 0);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(padding: const EdgeInsets.only(bottom: 12), child: toolbar),
if (_error != null && _items.isEmpty)
LoadError(error: _error!, onRetry: _loadMore)
else if (total == 0)
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: Text('Nothing here', style: TextStyle(color: Colors.grey))),
)
else
Expanded(
child: Scrollbar(
controller: _scroll,
child: CustomScrollView(
controller: _scroll,
slivers: [
SliverGrid(
gridDelegate: CardGridDelegate(_shape),
delegate: SliverChildBuilderDelegate(
(context, i) {
if (i >= _items.length - 40 && _error == null) {
// Near the loaded edge: fetch the next page.
scheduleMicrotask(_loadMore);
}
if (i >= _items.length) return SkeletonCard(shape: _shape);
return MediaCard(item: _items[i], shape: _shape);
},
childCount: count,
),
),
if (_error != null && _items.isNotEmpty)
SliverToBoxAdapter(child: LoadError(error: _error!, onRetry: _loadMore)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
),
],
);
}
}
class _SortMenu extends StatelessWidget {
final LibrarySort sort;
final ValueChanged<LibrarySort> onSelected;
const _SortMenu({required this.sort, required this.onSelected});
@override
Widget build(BuildContext context) {
return MenuAnchor(
menuChildren: [
for (final s in LibrarySort.values)
MenuItemButton(
key: ValueKey('sort-${s.name}'),
leadingIcon: Icon(s == sort ? Icons.check : null, size: 18),
onPressed: () => onSelected(s),
child: Text(s.label),
),
],
builder: (context, menu, _) => OutlinedButton.icon(
key: const ValueKey('library-sort'),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(Icons.sort, size: 18),
label: Text(sort.label),
),
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browser.dart';
import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server
/// is linked, else an inline add-server panel.
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
const JellyfinPickerSource({
super.key,
required this.accounts,
required this.onPick,
this.selected,
this.onOpenIntegrations,
this.onToggleExpanded,
this.expanded = false,
});
@override
State<JellyfinPickerSource> createState() => _JellyfinPickerSourceState();
}
class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
@override
void initState() {
super.initState();
widget.accounts.ensureLoaded();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.accounts,
builder: (context, _) {
final a = widget.accounts;
if (!a.loaded) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
if (a.isEmpty) return _LinkFirst(accounts: a, onOpenIntegrations: widget.onOpenIntegrations);
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
accounts: a,
selected: widget.selected,
onPick: widget.onPick,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
);
},
);
}
}
class _LinkFirst extends StatelessWidget {
final JellyfinAccounts accounts;
final VoidCallback? onOpenIntegrations;
const _LinkFirst({required this.accounts, this.onOpenIntegrations});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
key: const ValueKey('media-jellyfin'),
child: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Link a Jellyfin server to browse it here. '
'You can manage linked servers in Integrations.',
style: TextStyle(color: Colors.grey)),
const SizedBox(height: kGap),
JellyfinSignInPanel(accounts: accounts),
if (onOpenIntegrations != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
),
],
],
),
),
),
),
);
}
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
/// Search results grouped by kind: shows and movies as posters, episodes
/// as thumbnails, each a grid that fills the width.
class JellyfinSearchResults extends StatelessWidget {
final String term;
const JellyfinSearchResults({super.key, required this.term});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('search-$term-${scope.account.serverId}'),
load: () => scope.load('search:$term', (c) => c.search(term, limit: 80)),
builder: (context, items) {
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text('Nothing found for "$term"',
key: const ValueKey('search-empty'),
style: const TextStyle(color: Colors.grey)),
),
);
}
final groups = [
('Shows', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.series).toList()),
('Movies', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.movie).toList()),
('Episodes', CardShape.thumb, items.where((i) => i.kind == JellyfinItemKind.episode).toList()),
(
'Other',
CardShape.poster,
items
.where((i) => !const [
JellyfinItemKind.series,
JellyfinItemKind.movie,
JellyfinItemKind.episode
].contains(i.kind))
.toList()
),
].where((g) => g.$3.isNotEmpty);
return CustomScrollView(
key: const ValueKey('search-results'),
slivers: [
for (final (title, shape, list) in groups) ...[
SliverToBoxAdapter(child: SectionHeader(title, count: '${list.length}')),
SliverGrid(
gridDelegate: CardGridDelegate(shape),
delegate: SliverChildBuilderDelegate(
(context, i) => MediaCard(
item: list[i],
shape: shape,
seriesFirst: shape == CardShape.thumb,
),
childCount: list.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
);
},
);
}
}
@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Series page: backdrop + logo header with overview and the smart "next
/// unwatched" button, a season selector and the season's episodes as dense
/// rows (several columns on wide windows).
class JellyfinSeriesPage extends StatefulWidget {
final JellyfinItem series;
final String? seasonId;
const JellyfinSeriesPage({super.key, required this.series, this.seasonId});
@override
State<JellyfinSeriesPage> createState() => _JellyfinSeriesPageState();
}
typedef _SeriesData = ({JellyfinItem series, List<JellyfinItem> seasons, JellyfinItem? next});
class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
String? _seasonId;
@override
void initState() {
super.initState();
_seasonId = widget.seasonId;
}
Future<_SeriesData> _load(BrowseScope scope) async {
final id = widget.series.id;
final results = await Future.wait<Object?>([
scope.load('item:$id', (c) => c.item(id)),
scope.load('seasons:$id', (c) => c.seasons(id)),
scope
.load('next:$id', (c) => c.nextUp(seriesId: id, limit: 1, enableResumable: true))
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = results[2] as List<JellyfinItem>?;
return (
series: results[0] as JellyfinItem,
seasons: results[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
/// Season to show first: the one holding the next episode, else the first
/// with something unwatched, else the first.
String? _defaultSeason(_SeriesData d) {
if (d.seasons.isEmpty) return null;
final nextSeason = d.next?.seasonId;
if (nextSeason != null && d.seasons.any((s) => s.id == nextSeason)) return nextSeason;
return (d.seasons.where((s) => !s.userData.played).firstOrNull ?? d.seasons.first).id;
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
? _seasonId
: _defaultSeason(d);
return CustomScrollView(
key: PageStorageKey('series-${widget.series.id}'),
slivers: [
SliverToBoxAdapter(child: _SeriesHeader(data: d)),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final s in d.seasons)
ChoiceChip(
key: ValueKey('season-${s.id}'),
label: Text(s.name),
avatar: s.userData.played
? const Icon(Icons.check, size: 16)
: null,
showCheckmark: false,
selected: s.id == seasonId,
onSelected: (_) => setState(() => _seasonId = s.id),
tooltip: s.userData.unplayedItemCount != null
? '${s.userData.unplayedItemCount} unwatched'
: null,
),
],
),
),
),
if (seasonId != null)
_EpisodeList(
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
}
}
class _SeriesHeader extends StatefulWidget {
final _SeriesData data;
const _SeriesHeader({required this.data});
@override
State<_SeriesHeader> createState() => _SeriesHeaderState();
}
class _SeriesHeaderState extends State<_SeriesHeader> {
bool _more = false;
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final s = widget.data.series;
final next = widget.data.next;
final logo = s.image(JellyfinImageType.logo);
final meta = [
if (s.productionYear != null) '${s.productionYear}',
if (s.officialRating != null) s.officialRating!,
if (s.communityRating != null) '★ ${s.communityRating!.toStringAsFixed(1)}',
if (widget.data.seasons.isNotEmpty)
'${widget.data.seasons.length} ${widget.data.seasons.length == 1 ? 'season' : 'seasons'}',
if ((s.userData.unplayedItemCount ?? 0) > 0) '${s.userData.unplayedItemCount} unwatched',
].join(' · ');
final String nextLabel;
if (next == null) {
nextLabel = 'All watched';
} else if (next.userData.isResumable) {
nextLabel = 'Resume ${next.episodeLabel ?? next.name}';
} else {
nextLabel = 'Next up: ${next.episodeLabel ?? ''} ${next.name}'.trim();
}
final selectedNext = next != null && scope.selectedItemId == next.id;
return LayoutBuilder(builder: (context, box) {
final h = (box.maxWidth * 0.28).clamp(220.0, 380.0);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: h,
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: s.image(JellyfinImageType.backdrop),
alignment: Alignment.topCenter,
icon: Icons.live_tv_outlined,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
theme.colorScheme.surface.withValues(alpha: 0.96),
theme.colorScheme.surface.withValues(alpha: 0.75),
theme.colorScheme.surface.withValues(alpha: 0.1),
],
stops: const [0, 0.45, 1],
),
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (logo != null)
SizedBox(
height: 72,
width: 320,
child: JellyfinImage(
client: scope.client,
image: logo,
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
placeholder: Align(
alignment: Alignment.centerLeft,
child: Text(s.name, style: t.headlineMedium)),
),
)
else
Text(s.name, style: t.headlineMedium),
const SizedBox(height: 8),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (s.overview != null) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _more = !_more),
child: Text(s.overview!,
maxLines: _more ? null : 3,
overflow: _more ? null : TextOverflow.ellipsis,
style: t.bodyMedium),
),
],
const SizedBox(height: 14),
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onPick(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
]),
],
),
),
),
),
),
],
),
),
);
});
}
}
/// Episodes of one season as a lazy grid of rows: one column, more past
/// ~760 px each.
class _EpisodeList extends StatefulWidget {
final String seriesId;
final String seasonId;
const _EpisodeList({super.key, required this.seriesId, required this.seasonId});
@override
State<_EpisodeList> createState() => _EpisodeListState();
}
class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future;
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (_future == null) _load(scope);
return FutureBuilder<List<JellyfinItem>>(
future: _future,
builder: (context, snap) {
if (snap.hasError) {
return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
}
final eps = snap.data;
if (eps == null) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
);
}
if (eps.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
),
);
}
return SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 760,
mainAxisExtent: episodeTileHeight,
crossAxisSpacing: 12,
mainAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate(
(context, i) => EpisodeTile(episode: eps[i]),
childCount: eps.length,
),
);
},
);
}
}
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click picks it; I opens details.
class EpisodeTile extends StatefulWidget {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@override
State<EpisodeTile> createState() => _EpisodeTileState();
}
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false;
bool _hover = false;
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = theme.textTheme;
final e = widget.episode;
final selected = scope.selectedItemId == e.id;
final played = e.userData.played;
final progress = progressOf(e);
final meta = [
?e.episodeLabel,
?timeLeft(e),
if (timeLeft(e) == null && e.runTime != null) formatRuntime(e.runTime!),
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, k) {
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
scope.onOpen(DetailRoute(e));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => scope.onPick(e),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
if (_hover)
IconButton(
tooltip: 'Details (I)',
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
iconSize: 18,
onPressed: () => scope.onOpen(DetailRoute(e)),
icon: const Icon(Icons.info_outline),
),
]),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
],
),
),
],
),
),
),
),
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
enum JellyfinSignInStep {
/// Typing the server address.
address,
probing,
/// Server found: choose Quick Connect or password.
method,
/// Code shown, polling until the user approves it in Jellyfin.
quickConnect,
password,
signingIn,
/// Linked; [JellyfinSignIn.linked] is the account.
done,
}
/// Add-server / reconnect flow: address -> /System/Info/Public -> Quick
/// Connect (code + poll, cancellable) or username/password -> linked in
/// [JellyfinAccounts]. UI-free so tests drive it with a fake transport.
class JellyfinSignIn extends ChangeNotifier {
JellyfinSignIn(this.accounts, {this.pollInterval = const Duration(seconds: 3), this.reconnect});
final JellyfinAccounts accounts;
final Duration pollInterval;
/// Set when re-signing an expired account: the address is prefilled.
final JellyfinAccount? reconnect;
JellyfinSignInStep _step = JellyfinSignInStep.address;
String? _error;
JellyfinServerInfo? _server;
bool _quickConnectAvailable = false;
String? _code;
JellyfinAccount? _linked;
JellyfinClient? _client;
int _gen = 0;
bool _disposed = false;
JellyfinSignInStep get step => _step;
String? get error => _error;
JellyfinServerInfo? get server => _server;
String? get baseUrl => _client?.baseUrl;
bool get quickConnectAvailable => _quickConnectAvailable;
/// The Quick Connect code to show.
String? get code => _code;
JellyfinAccount? get linked => _linked;
bool get busy =>
_step == JellyfinSignInStep.probing || _step == JellyfinSignInStep.signingIn;
void _set(JellyfinSignInStep s, {String? error}) {
_step = s;
_error = error;
if (!_disposed) notifyListeners();
}
/// Checks [input] is a Jellyfin server and whether Quick Connect is on.
Future<void> probe(String input) async {
final url = normalizeServerUrl(input);
if (url == null) {
_set(JellyfinSignInStep.address, error: 'Enter the server address, e.g. jellyfin.example.com');
return;
}
final gen = ++_gen;
_client?.close();
final client = _client = accounts.anonymousClient(url);
_set(JellyfinSignInStep.probing);
try {
final info = await client.publicInfo();
if (gen != _gen) return;
if (info.id.isEmpty) throw const JellyfinException('not a Jellyfin server');
if (reconnect != null && info.id != reconnect!.serverId) {
_set(JellyfinSignInStep.address,
error: 'That address is a different Jellyfin server (${info.name}).');
return;
}
var qc = false;
try {
qc = await client.quickConnectEnabled();
} catch (_) {}
if (gen != _gen) return;
_server = info;
_quickConnectAvailable = qc;
_set(JellyfinSignInStep.method);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.address, error: 'No Jellyfin server at $url (${_short(e)})');
}
}
/// Starts Quick Connect: shows the code, polls until approved, links.
Future<void> startQuickConnect() async {
final client = _client;
if (client == null || _server == null) return;
final gen = ++_gen;
_code = null;
_set(JellyfinSignInStep.quickConnect);
try {
final session = await client.quickConnectInitiate();
if (gen != _gen) return;
_code = session.code;
_set(JellyfinSignInStep.quickConnect);
final auth = await client.waitForQuickConnect(session,
interval: pollInterval, isCancelled: () => gen != _gen || _disposed);
if (auth == null || gen != _gen) return;
await _finish(auth);
} on TimeoutException {
if (gen == _gen) _set(JellyfinSignInStep.method, error: 'The code expired. Try again.');
} catch (e) {
if (gen != _gen) return;
final msg = e is JellyfinException && e.isUnauthorized
? 'Quick Connect is turned off on this server. Use your password.'
: e is JellyfinException && e.statusCode == 404
? 'The code expired. Try again.'
: 'Quick Connect failed: ${_short(e)}';
_set(JellyfinSignInStep.method, error: msg);
}
}
/// Stops waiting for the Quick Connect code.
void cancel() {
_gen++;
_code = null;
_set(_server == null ? JellyfinSignInStep.address : JellyfinSignInStep.method);
}
void usePassword() => _set(JellyfinSignInStep.password);
/// Back to the address field.
void changeAddress() {
_gen++;
_server = null;
_code = null;
_set(JellyfinSignInStep.address);
}
Future<void> signIn(String username, String password) async {
final client = _client;
if (client == null) return;
if (username.trim().isEmpty) {
_set(JellyfinSignInStep.password, error: 'Enter your Jellyfin username');
return;
}
final gen = ++_gen;
_set(JellyfinSignInStep.signingIn);
try {
final auth = await client.authenticateByName(username.trim(), password);
if (gen != _gen) return;
await _finish(auth);
} catch (e) {
if (gen != _gen) return;
_set(JellyfinSignInStep.password,
error: e is JellyfinException && e.isUnauthorized
? 'Wrong username or password'
: 'Sign-in failed: ${_short(e)}');
}
}
Future<void> _finish(JellyfinAuthResult auth) async {
final server = _server!;
if (auth.accessToken.isEmpty) throw const JellyfinException('no token in the answer');
_linked = await accounts.link(baseUrl: _client!.baseUrl, server: server, auth: auth);
_code = null;
_set(JellyfinSignInStep.done);
}
static String _short(Object e) {
final s = '$e'.replaceFirst(RegExp(r'^JellyfinException( \d+)?: '), '');
return s.length > 140 ? '${s.substring(0, 140)}…' : s;
}
@override
void dispose() {
_disposed = true;
_gen++;
_client?.close();
super.dispose();
}
}
@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'sign_in.dart';
/// Add a Jellyfin server (or reconnect an expired one): address, then Quick
/// Connect (big code, approve it in Jellyfin) or username and password.
class JellyfinSignInPanel extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinAccount? reconnect;
final ValueChanged<JellyfinAccount>? onDone;
final VoidCallback? onCancel;
final Duration pollInterval;
const JellyfinSignInPanel({
super.key,
required this.accounts,
this.reconnect,
this.onDone,
this.onCancel,
this.pollInterval = const Duration(seconds: 3),
});
@override
State<JellyfinSignInPanel> createState() => _JellyfinSignInPanelState();
}
class _JellyfinSignInPanelState extends State<JellyfinSignInPanel> {
late final JellyfinSignIn _flow =
JellyfinSignIn(widget.accounts, pollInterval: widget.pollInterval, reconnect: widget.reconnect);
late final _address = TextEditingController(text: widget.reconnect?.baseUrl ?? '');
late final _user = TextEditingController(text: widget.reconnect?.userName ?? '');
final _password = TextEditingController();
bool _reported = false;
@override
void initState() {
super.initState();
_flow.addListener(_changed);
if (widget.reconnect != null) _flow.probe(widget.reconnect!.baseUrl);
}
@override
void dispose() {
_flow.removeListener(_changed);
_flow.dispose();
_address.dispose();
_user.dispose();
_password.dispose();
super.dispose();
}
void _changed() {
if (!mounted) return;
setState(() {});
final linked = _flow.linked;
if (_flow.step == JellyfinSignInStep.done && linked != null && !_reported) {
_reported = true;
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onDone?.call(linked));
}
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final f = _flow;
final body = switch (f.step) {
JellyfinSignInStep.address || JellyfinSignInStep.probing => _addressStep(),
JellyfinSignInStep.method => _methodStep(),
JellyfinSignInStep.quickConnect => _quickConnectStep(),
JellyfinSignInStep.password || JellyfinSignInStep.signingIn => _passwordStep(),
JellyfinSignInStep.done => Row(children: [
const Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text('Linked as ${f.linked?.userName} on ${f.linked?.displayName}',
key: const ValueKey('jf-linked'))),
]),
};
return Column(
key: const ValueKey('jf-sign-in'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.reconnect == null ? 'Add a Jellyfin server' : 'Reconnect ${widget.reconnect!.displayName}',
style: t.titleMedium),
const SizedBox(height: 12),
if (f.server != null && f.step != JellyfinSignInStep.address && f.step != JellyfinSignInStep.done)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _ServerLine(
name: f.server!.name,
version: f.server!.version,
url: f.baseUrl ?? '',
onChange: widget.reconnect == null ? f.changeAddress : null,
),
),
body,
if (f.error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(f.error!,
key: const ValueKey('jf-sign-in-error'),
style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
);
}
Widget _cancelButton() => widget.onCancel == null
? const SizedBox.shrink()
: TextButton(onPressed: widget.onCancel, child: const Text('Cancel'));
Widget _addressStep() {
final probing = _flow.step == JellyfinSignInStep.probing;
return Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('jf-address'),
controller: _address,
autofocus: true,
enabled: !probing,
decoration: fieldDecoration('Server address', hint: 'jellyfin.example.com'),
keyboardType: TextInputType.url,
onSubmitted: (v) => _flow.probe(v),
),
),
const SizedBox(width: 12),
FilledButton(
key: const ValueKey('jf-connect'),
onPressed: probing ? null : () => _flow.probe(_address.text),
child: probing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Connect'),
),
_cancelButton(),
],
);
}
Widget _methodStep() {
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (_flow.quickConnectAvailable)
FilledButton.icon(
key: const ValueKey('jf-quick-connect'),
onPressed: _flow.startQuickConnect,
icon: const Icon(Icons.qr_code_2, size: 18),
label: const Text('Quick Connect'),
),
if (_flow.quickConnectAvailable)
OutlinedButton.icon(
key: const ValueKey('jf-use-password'),
onPressed: _flow.usePassword,
icon: const Icon(Icons.password, size: 18),
label: const Text('Username & password'),
)
else
FilledButton.icon(
key: const ValueKey('jf-use-password'),
onPressed: _flow.usePassword,
icon: const Icon(Icons.password, size: 18),
label: const Text('Username & password'),
),
_cancelButton(),
],
);
}
Widget _quickConnectStep() {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final code = _flow.code;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (code == null)
const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: SelectableText(
code,
key: const ValueKey('jf-qc-code'),
style: t.displaySmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
letterSpacing: 8,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Copy code',
onPressed: () => Clipboard.setData(ClipboardData(text: code)),
icon: const Icon(Icons.copy, size: 18),
),
],
),
const SizedBox(height: 12),
const Text('Approve it in Jellyfin: Profile → Quick Connect, then enter this code.'),
const SizedBox(height: 12),
Row(
children: [
const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 10),
const Text('Waiting for approval…', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 16),
TextButton(
key: const ValueKey('jf-qc-cancel'),
onPressed: _flow.cancel,
child: const Text('Cancel'),
),
],
),
],
);
}
Widget _passwordStep() {
final busy = _flow.step == JellyfinSignInStep.signingIn;
void submit() => _flow.signIn(_user.text, _password.text);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('jf-username'),
controller: _user,
autofocus: true,
enabled: !busy,
decoration: fieldDecoration('Username'),
onSubmitted: (_) => submit(),
),
const SizedBox(height: 10),
TextField(
key: const ValueKey('jf-password'),
controller: _password,
enabled: !busy,
obscureText: true,
decoration: fieldDecoration('Password'),
onSubmitted: (_) => submit(),
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
key: const ValueKey('jf-sign-in-submit'),
onPressed: busy ? null : submit,
child: busy
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Sign in'),
),
const SizedBox(width: 8),
if (_flow.quickConnectAvailable)
TextButton(onPressed: _flow.cancel, child: const Text('Back')),
_cancelButton(),
],
),
],
);
}
}
class _ServerLine extends StatelessWidget {
final String name;
final String version;
final String url;
final VoidCallback? onChange;
const _ServerLine({required this.name, required this.version, required this.url, this.onChange});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
children: [
Icon(Icons.dns_outlined, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name.isEmpty ? 'Jellyfin server' : name,
key: const ValueKey('jf-server-name'), style: t.titleSmall),
Text('Jellyfin $version · $url', style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
if (onChange != null) TextButton(onPressed: onChange, child: const Text('Change')),
],
);
}
}