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