Jellyfin in the app: account linking and library browser
Integrations links several Jellyfin servers (Quick Connect or password, app-obtained tokens only, status/reconnect/sign out). The Host tab's Jellyfin source is a full browser: continue watching, next up, latest per library, library grids with sort/filter/infinite scroll, series pages with smart resume/next-up, details, grouped search, inspector pane on wide screens, keyboard navigation. MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin pick is interim (host's stream URL) until per-viewer playback lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45f4c1bc84
commit
773a04369e
27 files changed
+5169
-228
No files matched your search
@@ -33,9 +33,9 @@ telling you to update.
|
||||
|
||||
## Host from the app
|
||||
|
||||
Home, Host tab. Pick what to watch (optional): Link (paste an http(s) URL;
|
||||
Ctrl+V outside a field pastes it in) or File (path or Browse). Jellyfin is
|
||||
coming. In the column beside it set a port and an optional password (blank =
|
||||
Home, Host tab. Pick what to watch (optional): Jellyfin (browse your linked
|
||||
servers, see below), Link (paste an http(s) URL; Ctrl+V outside a field
|
||||
pastes it in) or File (path or Browse). In the column beside it set a port and an optional password (blank =
|
||||
anyone can join), then Host. Your name and avatar come from your profile
|
||||
(the chip at the top left). The app starts a
|
||||
server inside itself and joins it; you are the host and nobody can kick you.
|
||||
@@ -84,7 +84,7 @@ x to forget it. Ctrl+1 / Ctrl+2 switch between Join and Host.
|
||||
Profile, hardware decoding (auto, off or custom; the env var
|
||||
`SYNC_RELAY_HWDEC` overrides it; applies on the next join or Reload video),
|
||||
updates, video shaders (see below), and the keyboard shortcuts (read-only).
|
||||
Integrations (Jellyfin) shows "coming soon". About shows the version.
|
||||
Integrations links Jellyfin servers (below). About shows the version.
|
||||
|
||||
Keys in a session: Space = ready / unready, Tab = side panel (people, log,
|
||||
chat, change video), Left/Right = seek 10 s, F = fullscreen, Esc = close
|
||||
@@ -102,9 +102,31 @@ Only the host needs the port reachable.
|
||||
|
||||
## Jellyfin
|
||||
|
||||
Pasting a Jellyfin direct-play link works today like any link. Proper
|
||||
Jellyfin support (per-person login, watched status, library browsing) is
|
||||
planned: see [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
Integrations > Jellyfin > Add server: type the address, then sign in with
|
||||
Quick Connect (the app shows a code; approve it in Jellyfin under Profile >
|
||||
Quick Connect) or your username and password. Several servers can be
|
||||
linked; each shows its status, and a star picks the default. Sign out
|
||||
revokes the app's sign-in on the server. If a sign-in expires the server
|
||||
shows "Reconnect" instead of disappearing.
|
||||
|
||||
The Host tab's Jellyfin source is a library browser: Continue watching (with
|
||||
time left), Next up and the latest per library on the start page; library
|
||||
grids with sort (name, date added, release date, rating, random) and
|
||||
Unwatched / Favorites filters; series pages with seasons, episode progress
|
||||
and a "Next up" button; details with the file's tracks; search. Clicking a
|
||||
movie or episode picks it; the button in the top right enlarges the browser
|
||||
and moves port, password and Host into a bar below it. On wide windows a
|
||||
side pane shows details of whatever is under the mouse.
|
||||
|
||||
Keys in the browser: arrows move, Enter picks or opens, I = details,
|
||||
Esc / Backspace / Alt+Left = back (Esc first clears a search), / or Ctrl+F =
|
||||
search.
|
||||
|
||||
For now a hosted Jellyfin pick is shared as a stream link carrying the
|
||||
host's sign-in, so everyone watches through the host's account. Per-person
|
||||
playback, watched status and progress sync are planned: see
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md). Pasting a Jellyfin direct-play link
|
||||
still works like any link.
|
||||
|
||||
## Video quality and shaders
|
||||
|
||||
|
||||
@@ -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
|
||||
/// other card's saved values survive.
|
||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
|
||||
/// other card's saved values survive. [linkOverride] (host only) is played
|
||||
/// as the link but never stored in the form: the interim Jellyfin stream
|
||||
/// URL carries a token.
|
||||
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
|
||||
{String? linkOverride}) async {
|
||||
if (busy != null) return;
|
||||
final myAttempt = ++_attempt;
|
||||
final form = _session.lastForm.copy();
|
||||
@@ -112,7 +115,13 @@ class ConnectFlow extends ChangeNotifier {
|
||||
|
||||
final ConnectParams params;
|
||||
try {
|
||||
params = await buildParams(mode, form);
|
||||
params = await buildParams(
|
||||
mode,
|
||||
linkOverride == null || mode != ConnectMode.host
|
||||
? form
|
||||
: (form.copy()
|
||||
..mediaPath = ''
|
||||
..linkUrl = linkOverride));
|
||||
} catch (e) {
|
||||
if (myAttempt != _attempt) return;
|
||||
busy = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import '../../state/settings.dart';
|
||||
import '../../update/update_controller.dart';
|
||||
@@ -34,12 +35,15 @@ class HomeShell extends StatefulWidget {
|
||||
final bool autoUpdateCheck;
|
||||
final bool windowControls;
|
||||
final HealthGetter healthGet;
|
||||
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
const HomeShell({
|
||||
super.key,
|
||||
this.updates,
|
||||
this.autoUpdateCheck = true,
|
||||
this.windowControls = false,
|
||||
this.healthGet = defaultHealthGet,
|
||||
this.jellyfin,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -50,7 +54,8 @@ class _HomeShellState extends State<HomeShell> {
|
||||
HomeView _view = HomeView.join;
|
||||
final _flow = ConnectFlow();
|
||||
late final _join = JoinFields(SessionState.instance.lastForm);
|
||||
late final _host = HostFields(SessionState.instance.lastForm);
|
||||
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
|
||||
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopCrashWatch;
|
||||
bool _dialogOpen = false;
|
||||
@@ -60,6 +65,8 @@ class _HomeShellState extends State<HomeShell> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
Profile.instance.ensureLoaded();
|
||||
// Reads linked servers and checks their tokens in the background.
|
||||
_jellyfin.ensureLoaded();
|
||||
if (widget.autoUpdateCheck) _autoCheck();
|
||||
_flow.onKicked = _showKickedDialog;
|
||||
// Back from a session that ended in a kick: say so.
|
||||
@@ -134,7 +141,7 @@ class _HomeShellState extends State<HomeShell> {
|
||||
onOpenIntegrations: () => _select(HomeView.integrations),
|
||||
),
|
||||
HomeView.settings => SettingsPage(updates: widget.updates),
|
||||
HomeView.integrations => const IntegrationsPage(),
|
||||
HomeView.integrations => IntegrationsPage(jellyfin: _jellyfin),
|
||||
HomeView.about => AboutPage(updates: widget.updates),
|
||||
};
|
||||
return CallbackShortcuts(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../state/session_state.dart';
|
||||
import 'connect_flow.dart';
|
||||
import 'connect_status.dart';
|
||||
@@ -9,21 +10,60 @@ import 'ui.dart';
|
||||
|
||||
/// Host form state, owned by the Home shell so it survives tab switches.
|
||||
class HostFields {
|
||||
HostFields(ConnectForm f)
|
||||
: port = TextEditingController(text: f.hostPort),
|
||||
HostFields(ConnectForm f, {JellyfinAccounts? jellyfin})
|
||||
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
|
||||
port = TextEditingController(text: f.hostPort),
|
||||
secret = TextEditingController(text: f.hostSecret),
|
||||
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
|
||||
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
|
||||
// The last Jellyfin pick lives in the Jellyfin box (the connect form
|
||||
// only knows links and files); restore it once that box is read.
|
||||
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
|
||||
this.jellyfin.ensureLoaded().then((_) {
|
||||
final pick = this.jellyfin.lastPick;
|
||||
if (_disposed || pick == null || media.jellyfin != null) return;
|
||||
if (this.jellyfin.account(pick.serverId) == null) return;
|
||||
if (noMedia) {
|
||||
media.pickJellyfin(pick);
|
||||
} else {
|
||||
final source = media.source;
|
||||
media.pickJellyfin(pick);
|
||||
media.source = source;
|
||||
}
|
||||
});
|
||||
}
|
||||
final JellyfinAccounts jellyfin;
|
||||
final TextEditingController port;
|
||||
final TextEditingController secret;
|
||||
final MediaPickerController media;
|
||||
/// Jellyfin browser enlarged: the server column shrinks to a bar.
|
||||
final browserExpanded = ValueNotifier(false);
|
||||
bool _disposed = false;
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
port.dispose();
|
||||
secret.dispose();
|
||||
media.dispose();
|
||||
browserExpanded.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item,
|
||||
/// the host plays it as a link: this install's authorised static stream URL
|
||||
/// (token in the query, so viewers stream with the host's sign-in and the
|
||||
/// URL must not be persisted). Throws [FormatException] with a user-facing
|
||||
/// message when the server is not linked any more.
|
||||
String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) {
|
||||
final client = accounts.clientFor(media.serverId);
|
||||
if (client == null) {
|
||||
throw const FormatException('that Jellyfin server is not linked any more — pick again');
|
||||
}
|
||||
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
|
||||
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
|
||||
}
|
||||
return client.streamUrl(media.itemId, mediaSourceId: media.mediaSourceId).toString();
|
||||
}
|
||||
|
||||
/// Below this width the server column moves under the media picker.
|
||||
const hostStackBelow = 860.0;
|
||||
const _columnWidth = 340.0;
|
||||
@@ -36,47 +76,102 @@ class HostTab extends StatelessWidget {
|
||||
const HostTab(
|
||||
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
|
||||
|
||||
void _host() => flow.submit(ConnectMode.host, (f) {
|
||||
final media = fields.media.selection;
|
||||
f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text
|
||||
..mediaPath = media.mediaPath
|
||||
..linkUrl = media.linkUrl;
|
||||
});
|
||||
void _host() {
|
||||
if (flow.busy != null) return;
|
||||
final media = fields.media.selection;
|
||||
String? link;
|
||||
if (media is JellyfinMedia) {
|
||||
try {
|
||||
link = interimJellyfinLink(fields.jellyfin, media);
|
||||
} on FormatException catch (e) {
|
||||
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
|
||||
return;
|
||||
}
|
||||
fields.jellyfin.rememberPick(media);
|
||||
}
|
||||
flow.submit(
|
||||
ConnectMode.host,
|
||||
(f) => f
|
||||
..hostPort = fields.port.text.trim()
|
||||
..hostSecret = fields.secret.text
|
||||
..mediaPath = media is FileMedia ? media.path : ''
|
||||
..linkUrl = media is LinkMedia ? media.url : '',
|
||||
linkOverride: link,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return ListenableBuilder(
|
||||
listenable: flow,
|
||||
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
|
||||
builder: (context, _) {
|
||||
final busy = flow.busy == ConnectMode.host;
|
||||
final picker = Column(
|
||||
key: const ValueKey('host-picker'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('What to watch', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
MediaPicker(
|
||||
controller: fields.media,
|
||||
enabled: !busy,
|
||||
autofocus: true,
|
||||
onSubmit: _host,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
),
|
||||
],
|
||||
);
|
||||
final jellyfin = fields.media.source == MediaKind.jellyfin;
|
||||
final expanded = jellyfin && fields.browserExpanded.value;
|
||||
Widget picker({required bool fill}) {
|
||||
final p = MediaPicker(
|
||||
controller: fields.media,
|
||||
enabled: !busy,
|
||||
autofocus: true,
|
||||
onSubmit: _host,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
jellyfin: fields.jellyfin,
|
||||
expanded: expanded,
|
||||
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
|
||||
);
|
||||
return Column(
|
||||
key: const ValueKey('host-picker'),
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('What to watch', style: t.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
fill ? Expanded(child: p) : p,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final stacked = box.maxWidth < hostStackBelow;
|
||||
// Jellyfin browser: fills the height (lazy grids need a bounded
|
||||
// viewport); the form sits beside it or, enlarged, in a bar below.
|
||||
if (expanded) {
|
||||
return Padding(
|
||||
padding: kPagePadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: picker(fill: true)),
|
||||
const SizedBox(height: 12),
|
||||
_HostBar(flow: flow, fields: fields, onHost: _host),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (jellyfin && !stacked) {
|
||||
return Padding(
|
||||
padding: kPagePadding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: picker(fill: true)),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(
|
||||
width: _columnWidth,
|
||||
child: SingleChildScrollView(child: column),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: kPagePadding,
|
||||
child: stacked
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
picker,
|
||||
picker(fill: false),
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
@@ -90,7 +185,7 @@ class HostTab extends StatelessWidget {
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: picker),
|
||||
Expanded(child: picker(fill: false)),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(width: _columnWidth, child: column),
|
||||
],
|
||||
@@ -168,7 +263,7 @@ class _ServerColumn extends StatelessWidget {
|
||||
listenable: fields.media,
|
||||
builder: (context, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(_mediaSummary(fields.media.selection),
|
||||
child: Text(mediaSummary(fields.media.selection),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
@@ -184,11 +279,74 @@ class _ServerColumn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _mediaSummary(MediaSelection s) {
|
||||
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
|
||||
if (raw.isEmpty) return 'No media selected (optional).';
|
||||
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
|
||||
return 'Playing: $name';
|
||||
}
|
||||
|
||||
String mediaSummary(PickedMedia? s) =>
|
||||
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
|
||||
|
||||
/// Enlarged-browser mode: what is picked, port, password and Host in one
|
||||
/// row under the browser.
|
||||
class _HostBar extends StatelessWidget {
|
||||
final ConnectFlow flow;
|
||||
final HostFields fields;
|
||||
final VoidCallback onHost;
|
||||
const _HostBar({required this.flow, required this.fields, required this.onHost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Panel(
|
||||
key: const ValueKey('host-bar'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(mediaSummary(fields.media.selection),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
AbsorbPointer(
|
||||
absorbing: flow.busy == ConnectMode.host,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: TextField(
|
||||
controller: fields.port,
|
||||
decoration: fieldDecoration('Port'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => flow.clearError(ConnectMode.host),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: TextField(
|
||||
controller: fields.secret,
|
||||
obscureText: true,
|
||||
decoration: fieldDecoration('Password', hint: 'none = open'),
|
||||
onSubmitted: (_) => onHost(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
|
||||
],
|
||||
),
|
||||
ConnectFeedback(flow: flow, mode: ConnectMode.host),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/accounts_panel.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
class IntegrationsPage extends StatelessWidget {
|
||||
const IntegrationsPage({super.key});
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
const IntegrationsPage({super.key, this.jellyfin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return PageScroll(
|
||||
maxWidth: 680,
|
||||
maxWidth: 820,
|
||||
children: [
|
||||
const PageTitle('Integrations',
|
||||
subtitle: 'Connect media libraries to pick what to watch.'),
|
||||
Panel(
|
||||
enabled: false,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.video_library_outlined, size: 32),
|
||||
const SizedBox(width: kGap),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Jellyfin', style: t.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Coming soon: link your Jellyfin account(s), browse and '
|
||||
'put up episodes, watched/continue-watching sync.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kGap),
|
||||
const OutlinedButton(onPressed: null, child: Text('Link account')),
|
||||
],
|
||||
),
|
||||
),
|
||||
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,32 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../jellyfin/accounts.dart';
|
||||
import '../../jellyfin/picker_source.dart';
|
||||
import '../../widgets/media_input.dart';
|
||||
import 'picked_media.dart';
|
||||
import 'ui.dart';
|
||||
|
||||
export 'picked_media.dart';
|
||||
|
||||
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
|
||||
|
||||
enum MediaKind { jellyfin, link, file }
|
||||
|
||||
/// What to play: at most one of the two is non-empty; both empty = none.
|
||||
typedef MediaSelection = ({String mediaPath, String linkUrl});
|
||||
|
||||
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
|
||||
/// later the session's swap panel) so it survives rebuilds.
|
||||
class MediaPickerController extends ChangeNotifier {
|
||||
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
|
||||
MediaPickerController(
|
||||
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
|
||||
: link = TextEditingController(text: linkUrl),
|
||||
file = TextEditingController(text: mediaPath),
|
||||
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
|
||||
_jellyfin = jellyfin,
|
||||
_source = source ??
|
||||
(linkUrl.isNotEmpty
|
||||
? MediaKind.link
|
||||
: mediaPath.isEmpty && jellyfin != null
|
||||
? MediaKind.jellyfin
|
||||
: MediaKind.file) {
|
||||
link.addListener(notifyListeners);
|
||||
file.addListener(notifyListeners);
|
||||
}
|
||||
@@ -27,6 +36,19 @@ class MediaPickerController extends ChangeNotifier {
|
||||
final TextEditingController file;
|
||||
MediaKind _source;
|
||||
String? _notice;
|
||||
JellyfinMedia? _jellyfin;
|
||||
|
||||
/// The Jellyfin item picked in the browser (kept while another source
|
||||
/// is shown).
|
||||
JellyfinMedia? get jellyfin => _jellyfin;
|
||||
|
||||
/// Picks a Jellyfin item and switches to the Jellyfin source.
|
||||
void pickJellyfin(JellyfinMedia? media) {
|
||||
_jellyfin = media;
|
||||
_source = MediaKind.jellyfin;
|
||||
_notice = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
MediaKind get source => _source;
|
||||
set source(MediaKind v) {
|
||||
@@ -43,11 +65,19 @@ class MediaPickerController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
MediaSelection get selection => switch (_source) {
|
||||
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
|
||||
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
|
||||
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
|
||||
};
|
||||
/// What the visible source has selected; null = nothing.
|
||||
PickedMedia? get selection {
|
||||
switch (_source) {
|
||||
case MediaKind.link:
|
||||
final url = link.text.trim();
|
||||
return url.isEmpty ? null : LinkMedia(url);
|
||||
case MediaKind.file:
|
||||
final path = file.text.trim();
|
||||
return path.isEmpty ? null : FileMedia(path);
|
||||
case MediaKind.jellyfin:
|
||||
return _jellyfin;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops pasted text where it belongs: http(s) into Link, else File.
|
||||
void pasteText(String text) {
|
||||
@@ -73,12 +103,17 @@ class MediaPickerController extends ChangeNotifier {
|
||||
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
|
||||
class MediaPicker extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final ValueChanged<MediaSelection>? onChanged;
|
||||
final ValueChanged<PickedMedia?>? onChanged;
|
||||
/// Enter in a field.
|
||||
final VoidCallback? onSubmit;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
/// Defaults to [JellyfinAccounts.instance].
|
||||
final JellyfinAccounts? jellyfin;
|
||||
/// Jellyfin source only: the browser's "bigger" toggle.
|
||||
final VoidCallback? onToggleExpanded;
|
||||
final bool expanded;
|
||||
const MediaPicker({
|
||||
super.key,
|
||||
required this.controller,
|
||||
@@ -87,6 +122,9 @@ class MediaPicker extends StatefulWidget {
|
||||
this.onOpenIntegrations,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
this.jellyfin,
|
||||
this.onToggleExpanded,
|
||||
this.expanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -175,80 +213,100 @@ class _MediaPickerState extends State<MediaPicker> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = _c.source;
|
||||
final Widget content = switch (source) {
|
||||
MediaKind.jellyfin => JellyfinPickerSource(
|
||||
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
|
||||
selected: _c.jellyfin,
|
||||
onPick: _c.pickJellyfin,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
onToggleExpanded: widget.onToggleExpanded,
|
||||
expanded: widget.expanded,
|
||||
),
|
||||
MediaKind.link => _FieldRow(
|
||||
key: const ValueKey('media-link'),
|
||||
field: TextField(
|
||||
controller: _c.link,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _pasteLink,
|
||||
icon: const Icon(Icons.content_paste, size: 18),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
help: 'A direct http(s) link to a video file; everyone streams it from there.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
MediaKind.file => _FieldRow(
|
||||
key: const ValueKey('media-file'),
|
||||
field: TextField(
|
||||
controller: _c.file,
|
||||
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse'),
|
||||
),
|
||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
};
|
||||
final segments = Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: MediaKind.jellyfin,
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('Jellyfin')),
|
||||
ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
|
||||
ButtonSegment(
|
||||
value: MediaKind.file,
|
||||
icon: Icon(Icons.insert_drive_file_outlined),
|
||||
label: Text('File')),
|
||||
],
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
);
|
||||
return Focus(
|
||||
autofocus: widget.autofocus,
|
||||
onKeyEvent: _onKey,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !widget.enabled,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: MediaKind.jellyfin,
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
label: Text('Jellyfin')),
|
||||
ButtonSegment(
|
||||
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
|
||||
ButtonSegment(
|
||||
value: MediaKind.file,
|
||||
icon: Icon(Icons.insert_drive_file_outlined),
|
||||
label: Text('File')),
|
||||
],
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kGap),
|
||||
switch (source) {
|
||||
MediaKind.jellyfin =>
|
||||
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
|
||||
MediaKind.link => _FieldRow(
|
||||
key: const ValueKey('media-link'),
|
||||
field: TextField(
|
||||
controller: _c.link,
|
||||
autofocus: widget.autofocus,
|
||||
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _pasteLink,
|
||||
icon: const Icon(Icons.content_paste, size: 18),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
help: 'A direct http(s) link to a video file; everyone streams it from there.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
MediaKind.file => _FieldRow(
|
||||
key: const ValueKey('media-file'),
|
||||
field: TextField(
|
||||
controller: _c.file,
|
||||
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
|
||||
onSubmitted: (_) => widget.onSubmit?.call(),
|
||||
),
|
||||
button: OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse'),
|
||||
),
|
||||
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
|
||||
notice: _c.notice,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
// The browser fills the height it is given; in an unbounded
|
||||
// (scrolling) parent it gets a fixed one.
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final fill = source == MediaKind.jellyfin;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
segments,
|
||||
const SizedBox(height: kGap),
|
||||
if (!fill)
|
||||
content
|
||||
else if (box.hasBoundedHeight)
|
||||
Expanded(child: content)
|
||||
else
|
||||
SizedBox(height: jellyfinPickerHeight, child: content),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the Jellyfin browser when the picker sits in a scroll view.
|
||||
const jellyfinPickerHeight = 640.0;
|
||||
|
||||
class _FieldRow extends StatelessWidget {
|
||||
final Widget field;
|
||||
final Widget button;
|
||||
@@ -283,70 +341,3 @@ class _FieldRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Jellyfin library grid will go: ghost posters that fill the
|
||||
/// width, dimmed, with the "coming soon" note on top.
|
||||
class _JellyfinPlaceholder extends StatelessWidget {
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
const _JellyfinPlaceholder({this.onOpenIntegrations});
|
||||
|
||||
static const _posterWidth = 140.0;
|
||||
static const _rows = 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
|
||||
return Stack(
|
||||
key: const ValueKey('media-jellyfin'),
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.35,
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (var r = 0; r < _rows; r++)
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (var c = 0; c < cols; c++)
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 2 / 3,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Panel(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text('Link your Jellyfin in Integrations — coming soon',
|
||||
textAlign: TextAlign.center, style: t.titleSmall),
|
||||
if (onOpenIntegrations != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// What the media picker selected. Null selection = nothing (hosting
|
||||
/// without media is allowed).
|
||||
sealed class PickedMedia {
|
||||
const PickedMedia();
|
||||
|
||||
/// One line for summaries ("Playing: …").
|
||||
String get label;
|
||||
}
|
||||
|
||||
/// A direct http(s) link everyone streams from.
|
||||
final class LinkMedia extends PickedMedia {
|
||||
final String url;
|
||||
const LinkMedia(this.url);
|
||||
|
||||
@override
|
||||
String get label {
|
||||
final uri = Uri.tryParse(url);
|
||||
final segs = uri?.pathSegments.where((s) => s.isNotEmpty).toList() ?? const [];
|
||||
if (segs.isEmpty) return uri?.host.isNotEmpty == true ? uri!.host : url;
|
||||
try {
|
||||
return Uri.decodeComponent(segs.last);
|
||||
} catch (_) {
|
||||
return segs.last;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is LinkMedia && other.url == url;
|
||||
@override
|
||||
int get hashCode => url.hashCode;
|
||||
@override
|
||||
String toString() => 'LinkMedia($url)';
|
||||
}
|
||||
|
||||
/// A file on this computer; friends stream it from the host.
|
||||
final class FileMedia extends PickedMedia {
|
||||
final String path;
|
||||
const FileMedia(this.path);
|
||||
|
||||
@override
|
||||
String get label =>
|
||||
path.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => path);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is FileMedia && other.path == path;
|
||||
@override
|
||||
int get hashCode => path.hashCode;
|
||||
@override
|
||||
String toString() => 'FileMedia($path)';
|
||||
}
|
||||
|
||||
/// A movie or episode on a linked Jellyfin server. [serverId] keys the
|
||||
/// account (`JellyfinAccounts.clientFor`); [baseUrl] is the address it was
|
||||
/// picked through. No credentials in here: each side authorises its own.
|
||||
final class JellyfinMedia extends PickedMedia {
|
||||
final String serverId;
|
||||
final String baseUrl;
|
||||
final String itemId;
|
||||
/// Null = the item's default source.
|
||||
final String? mediaSourceId;
|
||||
final String title;
|
||||
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
||||
final String subtitle;
|
||||
final Duration? runtime;
|
||||
const JellyfinMedia({
|
||||
required this.serverId,
|
||||
required this.baseUrl,
|
||||
required this.itemId,
|
||||
this.mediaSourceId,
|
||||
required this.title,
|
||||
this.subtitle = '',
|
||||
this.runtime,
|
||||
});
|
||||
|
||||
@override
|
||||
String get label => subtitle.isEmpty ? title : '$title — $subtitle';
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'serverId': serverId,
|
||||
'baseUrl': baseUrl,
|
||||
'itemId': itemId,
|
||||
'mediaSourceId': ?mediaSourceId,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
if (runtime != null) 'runtimeMs': runtime!.inMilliseconds,
|
||||
};
|
||||
|
||||
static JellyfinMedia? fromJson(Object? j) {
|
||||
if (j is! Map) return null;
|
||||
final serverId = j['serverId'], baseUrl = j['baseUrl'], itemId = j['itemId'];
|
||||
if (serverId is! String || baseUrl is! String || itemId is! String) return null;
|
||||
final ms = j['runtimeMs'];
|
||||
return JellyfinMedia(
|
||||
serverId: serverId,
|
||||
baseUrl: baseUrl,
|
||||
itemId: itemId,
|
||||
mediaSourceId: j['mediaSourceId'] is String ? j['mediaSourceId'] as String : null,
|
||||
title: j['title'] is String ? j['title'] as String : '',
|
||||
subtitle: j['subtitle'] is String ? j['subtitle'] as String : '',
|
||||
runtime: ms is int ? Duration(milliseconds: ms) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is JellyfinMedia &&
|
||||
other.serverId == serverId &&
|
||||
other.itemId == itemId &&
|
||||
other.mediaSourceId == mediaSourceId;
|
||||
@override
|
||||
int get hashCode => Object.hash(serverId, itemId, mediaSourceId);
|
||||
@override
|
||||
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
|
||||
}
|
||||
@@ -85,12 +85,12 @@ void main() {
|
||||
expect(c.source, MediaKind.file);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
expect(c.source, MediaKind.link);
|
||||
expect(c.selection, (mediaPath: '', linkUrl: 'https://example.com/a.mkv'));
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.selection, (mediaPath: '/home/me/b.mkv', linkUrl: ''));
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
c.source = MediaKind.jellyfin;
|
||||
expect(c.selection, (mediaPath: '', linkUrl: ''));
|
||||
expect(c.selection, isNull);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
@@ -238,10 +238,11 @@ void main() {
|
||||
expect(find.text('Paste'), findsOneWidget);
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link your Jellyfin in Integrations — coming soon'), findsOneWidget);
|
||||
// No linked server: sign-in right in the picker.
|
||||
expect(find.text('Add a Jellyfin server'), findsOneWidget);
|
||||
await tester.tap(find.text('Open Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link account'), findsOneWidget);
|
||||
expect(find.text('Add server'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/format.dart';
|
||||
import 'package:watch_together/src/jellyfin/sign_in.dart';
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart' show interimJellyfinLink, mediaSummary;
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
|
||||
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
|
||||
String jellyfinFixture(String name) =>
|
||||
File('../../packages/core/test/fixtures/jellyfin/$name.json').readAsStringSync();
|
||||
|
||||
const fixtureServerId = '122fce17c5b9413088af4be266bad6f4';
|
||||
const fixtureUserId = 'f00703a1d1ac44fe8a8a0ddf2ca05757';
|
||||
const arifuretaId = '550eff23b37ec61082c988678ccc214c';
|
||||
const season2Id = '331a23499d051bc288d281f2c72557a9';
|
||||
|
||||
typedef FakeHandler = JellyfinHttpResponse Function(Uri uri, String? body);
|
||||
|
||||
/// Fake Jellyfin: routes by "METHOD /path" to fixtures, filters episode and
|
||||
/// next-up lists by their query like the server does, records requests.
|
||||
/// [approved] flips the Quick Connect state.
|
||||
class FakeJellyfin implements JellyfinHttp {
|
||||
final requests = <({String method, Uri uri, Map<String, String> headers})>[];
|
||||
final routes = <String, FakeHandler>{};
|
||||
bool approved = false;
|
||||
bool offline = false;
|
||||
int meStatus = 200;
|
||||
|
||||
FakeJellyfin() {
|
||||
fixture('GET', '/System/Info/Public', 'pub');
|
||||
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
|
||||
fixture('POST', '/QuickConnect/Initiate', 'quickconnect_initiate');
|
||||
on('GET', '/QuickConnect/Connect', (_, _) => JellyfinHttpResponse(
|
||||
200, jsonEncode({'Authenticated': approved, 'Secret': 's', 'Code': '437392'})));
|
||||
fixture('POST', '/Users/AuthenticateWithQuickConnect', 'auth_result');
|
||||
on('POST', '/Users/AuthenticateByName', (_, body) {
|
||||
final pw = (jsonDecode(body!) as Map)['Pw'];
|
||||
return pw == 'right'
|
||||
? JellyfinHttpResponse(200, jellyfinFixture('auth_result'))
|
||||
: const JellyfinHttpResponse(401, '');
|
||||
});
|
||||
on('GET', '/Users/Me', (_, _) => JellyfinHttpResponse(
|
||||
meStatus, meStatus == 200 ? jellyfinFixture('me') : ''));
|
||||
on('POST', '/Sessions/Logout', (_, _) => const JellyfinHttpResponse(204, ''));
|
||||
fixture('GET', '/UserViews', 'views');
|
||||
on('GET', '/UserItems/Resume', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('resume')));
|
||||
on('GET', '/Shows/NextUp', (uri, _) {
|
||||
final series = uri.queryParameters['seriesId'];
|
||||
if (series == null) return JellyfinHttpResponse(200, jellyfinFixture('nextup'));
|
||||
// Arifureta's next up: S2E4.
|
||||
final eps = _items('episodes').where((e) => e['SeriesId'] == series).toList();
|
||||
return _page(eps.isEmpty ? const [] : [eps[3]]);
|
||||
});
|
||||
fixture('GET', '/Items/Latest', 'latest_shows');
|
||||
on('GET', '/Items', (uri, _) {
|
||||
final q = uri.queryParameters;
|
||||
if ((q['searchTerm'] ?? '').isNotEmpty) {
|
||||
return q['searchTerm']!.toLowerCase().startsWith('ari')
|
||||
? JellyfinHttpResponse(200, jellyfinFixture('search'))
|
||||
: _page(const []);
|
||||
}
|
||||
return JellyfinHttpResponse(
|
||||
200, jellyfinFixture(q['includeItemTypes'] == 'Movie' ? 'movies_page' : 'series_page'));
|
||||
});
|
||||
on('GET', '/Items/$arifuretaId', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('series_detail')));
|
||||
on('GET', '/Items/b18826be994070a79f9b52bc0f66f052',
|
||||
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('episode_detail')));
|
||||
on('GET', '/Shows/$arifuretaId/Seasons', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('seasons')));
|
||||
on('GET', '/Shows/$arifuretaId/Episodes', (uri, _) {
|
||||
final season = uri.queryParameters['seasonId'];
|
||||
return _page(_items('episodes').where((e) => season == null || e['SeasonId'] == season).toList());
|
||||
});
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _items(String fixture) =>
|
||||
((jsonDecode(jellyfinFixture(fixture)) as Map)['Items'] as List).cast<Map<String, dynamic>>();
|
||||
|
||||
static JellyfinHttpResponse _page(List<Object> items) =>
|
||||
JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length}));
|
||||
|
||||
void on(String method, String path, FakeHandler h) => routes['$method $path'] = h;
|
||||
void fixture(String method, String path, String name) =>
|
||||
on(method, path, (_, _) => JellyfinHttpResponse(200, jellyfinFixture(name)));
|
||||
|
||||
int count(String method, String path) =>
|
||||
requests.where((r) => r.method == method && r.uri.path == path).length;
|
||||
|
||||
@override
|
||||
Future<JellyfinHttpResponse> send(String method, Uri uri,
|
||||
{Map<String, String> headers = const {}, String? body}) async {
|
||||
requests.add((method: method, uri: uri, headers: headers));
|
||||
if (offline) throw JellyfinException('$method ${uri.path}: connection refused');
|
||||
final h = routes['$method ${uri.path}'];
|
||||
return h == null ? const JellyfinHttpResponse(404, '') : h(uri, body);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
JellyfinAuthResult fixtureAuth() =>
|
||||
JellyfinAuthResult.fromJson(jsonDecode(jellyfinFixture('auth_result')) as Map<String, dynamic>);
|
||||
|
||||
JellyfinServerInfo fixtureServer() =>
|
||||
JellyfinServerInfo.fromJson(jsonDecode(jellyfinFixture('pub')) as Map<String, dynamic>);
|
||||
|
||||
/// Accounts on a fake server with one linked account (no validation run).
|
||||
Future<(JellyfinAccounts, FakeJellyfin)> linkedAccounts({FakeJellyfin? http}) async {
|
||||
final fake = http ?? FakeJellyfin();
|
||||
final accounts = JellyfinAccounts(
|
||||
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test PC');
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
await accounts.link(
|
||||
baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
||||
return (accounts, fake);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('accounts', () {
|
||||
test('persist in their Hive box with a stable device id', () async {
|
||||
final dir = Directory.systemTemp.createTempSync('jf_accounts_');
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
Hive.init(dir.path);
|
||||
final fake = FakeJellyfin();
|
||||
|
||||
final a = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
|
||||
await a.ensureLoaded(validate: false);
|
||||
final deviceId = a.deviceId;
|
||||
expect(deviceId, hasLength(32));
|
||||
await a.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
||||
await a.rememberPick(const JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 'Ep'));
|
||||
await Hive.close();
|
||||
|
||||
final b = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
|
||||
await b.ensureLoaded(validate: false);
|
||||
expect(b.deviceId, deviceId);
|
||||
final acc = b.accounts.single;
|
||||
expect(acc.serverId, fixtureServerId);
|
||||
expect(acc.serverName, 'Example Server');
|
||||
expect(acc.userName, 'testuser');
|
||||
expect(acc.token, 'TEST_ACCESS_TOKEN');
|
||||
expect(b.defaultServerId, fixtureServerId);
|
||||
expect(b.lastPick?.itemId, 'abc');
|
||||
// The header names this app and install.
|
||||
expect(b.clientInfo.client, 'Watch Together');
|
||||
expect(b.clientInfo.deviceId, deviceId);
|
||||
await Hive.close();
|
||||
});
|
||||
|
||||
test('token check: online, expired stays linked, offline is not a sign-out', () async {
|
||||
final (accounts, fake) = await linkedAccounts();
|
||||
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.online);
|
||||
expect(fake.requests.last.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
|
||||
|
||||
fake.meStatus = 401;
|
||||
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.expired);
|
||||
expect(accounts.accounts, hasLength(1));
|
||||
|
||||
fake.offline = true;
|
||||
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.offline);
|
||||
expect(accounts.errorOf(fixtureServerId), contains('connection refused'));
|
||||
expect(accounts.accounts, hasLength(1));
|
||||
});
|
||||
|
||||
test('ensureLoaded validates in the background', () async {
|
||||
final fake = FakeJellyfin()..meStatus = 401;
|
||||
final store = MemoryJellyfinStore();
|
||||
final first = JellyfinAccounts(store: store, httpFactory: () => fake);
|
||||
await first.ensureLoaded(validate: false);
|
||||
await first.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
||||
final again = JellyfinAccounts(store: store, httpFactory: () => fake);
|
||||
await again.ensureLoaded();
|
||||
await pumpEventQueue();
|
||||
expect(again.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
||||
});
|
||||
|
||||
test('sign out revokes the token and unlinks; default moves on', () async {
|
||||
final (accounts, fake) = await linkedAccounts();
|
||||
final otherAuth = JellyfinAuthResult(
|
||||
accessToken: 'T2',
|
||||
user: const JellyfinUser(id: 'u2', name: 'other'),
|
||||
serverId: 'other-server');
|
||||
await accounts.link(
|
||||
baseUrl: 'https://two.invalid',
|
||||
server: const JellyfinServerInfo(id: 'other-server', name: 'Two', version: '10.11'),
|
||||
auth: otherAuth);
|
||||
expect(accounts.defaultServerId, fixtureServerId);
|
||||
await accounts.setDefault('other-server');
|
||||
expect(accounts.defaultServerId, 'other-server');
|
||||
|
||||
await accounts.signOut('other-server');
|
||||
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
||||
expect(fake.requests.last.headers['Authorization'], contains('Token="T2"'));
|
||||
expect(accounts.accounts.map((a) => a.serverId), [fixtureServerId]);
|
||||
expect(accounts.defaultServerId, fixtureServerId);
|
||||
});
|
||||
|
||||
test('signing in again replaces the account and revokes the old token', () async {
|
||||
final (accounts, fake) = await linkedAccounts();
|
||||
final fresh = JellyfinAuthResult(
|
||||
accessToken: 'NEW', user: fixtureAuth().user, serverId: fixtureServerId);
|
||||
await accounts.link(baseUrl: 'https://alt.invalid', server: fixtureServer(), auth: fresh);
|
||||
await pumpEventQueue();
|
||||
expect(accounts.accounts.single.token, 'NEW');
|
||||
expect(accounts.accounts.single.urls, ['https://alt.invalid', 'https://example.invalid']);
|
||||
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('sign-in flow', () {
|
||||
late FakeJellyfin fake;
|
||||
late JellyfinAccounts accounts;
|
||||
setUp(() async {
|
||||
fake = FakeJellyfin();
|
||||
accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
});
|
||||
|
||||
test('bad address and non-Jellyfin servers stay on the address step', () async {
|
||||
final flow = JellyfinSignIn(accounts, pollInterval: Duration.zero);
|
||||
await flow.probe(' ');
|
||||
expect(flow.step, JellyfinSignInStep.address);
|
||||
expect(flow.error, isNotNull);
|
||||
fake.offline = true;
|
||||
await flow.probe('jelly.example');
|
||||
expect(flow.step, JellyfinSignInStep.address);
|
||||
expect(flow.error, contains('No Jellyfin server'));
|
||||
});
|
||||
|
||||
test('Quick Connect: code, poll until approved, linked', () async {
|
||||
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
|
||||
await flow.probe('example.invalid');
|
||||
expect(flow.step, JellyfinSignInStep.method);
|
||||
expect(flow.server?.name, 'Example Server');
|
||||
expect(flow.quickConnectAvailable, isTrue);
|
||||
final done = flow.startQuickConnect();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(flow.code, '437392');
|
||||
expect(flow.step, JellyfinSignInStep.quickConnect);
|
||||
fake.approved = true;
|
||||
await done;
|
||||
expect(flow.step, JellyfinSignInStep.done);
|
||||
expect(accounts.accounts.single.userName, 'testuser');
|
||||
expect(accounts.accounts.single.urls, ['https://example.invalid']);
|
||||
});
|
||||
|
||||
test('Quick Connect cancel stops polling', () async {
|
||||
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
|
||||
await flow.probe('https://example.invalid');
|
||||
final done = flow.startQuickConnect();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
flow.cancel();
|
||||
await done;
|
||||
final polls = fake.count('GET', '/QuickConnect/Connect');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
|
||||
expect(flow.step, JellyfinSignInStep.method);
|
||||
expect(accounts.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('password: wrong then right', () async {
|
||||
final flow = JellyfinSignIn(accounts);
|
||||
await flow.probe('https://example.invalid');
|
||||
flow.usePassword();
|
||||
await flow.signIn('testuser', 'wrong');
|
||||
expect(flow.step, JellyfinSignInStep.password);
|
||||
expect(flow.error, 'Wrong username or password');
|
||||
await flow.signIn('testuser', 'right');
|
||||
expect(flow.step, JellyfinSignInStep.done);
|
||||
expect(accounts.accounts.single.token, 'TEST_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
test('reconnect refuses a different server', () async {
|
||||
final (linked, _) = await linkedAccounts();
|
||||
final other = JellyfinAccount(
|
||||
serverId: 'someone-else', serverName: 'X', urls: const ['https://example.invalid'],
|
||||
userId: 'u', userName: 'n', token: 't');
|
||||
final flow = JellyfinSignIn(linked, reconnect: other);
|
||||
await flow.probe(other.baseUrl);
|
||||
expect(flow.step, JellyfinSignInStep.address);
|
||||
expect(flow.error, contains('different Jellyfin server'));
|
||||
});
|
||||
});
|
||||
|
||||
group('picked media', () {
|
||||
test('controller selection is a PickedMedia per source', () {
|
||||
final c = MediaPickerController();
|
||||
expect(c.selection, isNull);
|
||||
c.pasteText('https://example.com/a.mkv');
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
c.source = MediaKind.jellyfin;
|
||||
expect(c.selection, isNull);
|
||||
const m = JellyfinMedia(
|
||||
serverId: 's', baseUrl: 'https://j.example', itemId: 'i', title: 'Ep', subtitle: 'Show · S1E6');
|
||||
c.pickJellyfin(m);
|
||||
expect(c.selection, m);
|
||||
c.source = MediaKind.file;
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
expect(c.jellyfin, m, reason: 'kept while another source shows');
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('a saved Jellyfin pick starts on the Jellyfin source', () {
|
||||
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
|
||||
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin);
|
||||
expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file);
|
||||
});
|
||||
|
||||
test('JellyfinMedia round-trips and labels', () {
|
||||
const m = JellyfinMedia(
|
||||
serverId: 's',
|
||||
baseUrl: 'https://j.example',
|
||||
itemId: 'i',
|
||||
mediaSourceId: 'src',
|
||||
title: 'Rice and Thieves and Heroes',
|
||||
subtitle: 'Black Summoner · S1E6',
|
||||
runtime: Duration(minutes: 23));
|
||||
final back = JellyfinMedia.fromJson(jsonDecode(jsonEncode(m.toJson())))!;
|
||||
expect(back, m);
|
||||
expect(back.runtime, const Duration(minutes: 23));
|
||||
expect(back.subtitle, 'Black Summoner · S1E6');
|
||||
expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6');
|
||||
expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv');
|
||||
expect(mediaSummary(null), 'No media selected (optional).');
|
||||
expect(JellyfinMedia.fromJson({'serverId': 1}), isNull);
|
||||
});
|
||||
|
||||
test('pickFor labels an episode "Series · S1E6"', () async {
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final ep = JellyfinItem.fromJson(jsonDecode(jellyfinFixture('episode_detail')) as Map<String, dynamic>);
|
||||
final m = pickFor(accounts.accounts.single, ep);
|
||||
expect(m.serverId, fixtureServerId);
|
||||
expect(m.itemId, 'b18826be994070a79f9b52bc0f66f052');
|
||||
expect(m.subtitle, 'Black Summoner · S1E6');
|
||||
expect(m.runtime, isNotNull);
|
||||
});
|
||||
|
||||
test('INTERIM: the host plays a Jellyfin pick as its authorised stream link', () async {
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
const m = JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 't');
|
||||
final url = Uri.parse(interimJellyfinLink(accounts, m));
|
||||
expect(url.path, '/Videos/abc/stream');
|
||||
expect(url.queryParameters['static'], 'true');
|
||||
expect(url.queryParameters['api_key'], 'TEST_ACCESS_TOKEN');
|
||||
expect(
|
||||
() => interimJellyfinLink(
|
||||
accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
|
||||
throwsFormatException);
|
||||
accounts.markExpired(fixtureServerId);
|
||||
expect(() => interimJellyfinLink(accounts, m), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('format', () {
|
||||
test('time left and subtitles', () {
|
||||
final resume = JellyfinItemPage.fromJson(jsonDecode(jellyfinFixture('resume')) as Map<String, dynamic>).items;
|
||||
final ep = resume[1];
|
||||
expect(timeLeft(ep), endsWith('min left'));
|
||||
expect(cardSubtitle(ep), startsWith('S1E10 · '));
|
||||
expect(progressOf(ep), greaterThan(0));
|
||||
expect(formatRuntime(const Duration(minutes: 102)), '1 h 42 min');
|
||||
expect(formatRuntime(const Duration(minutes: 24)), '24 min');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
|
||||
import 'package:watch_together/src/jellyfin/browser.dart';
|
||||
import 'package:watch_together/src/screens/home/connect_flow.dart';
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
|
||||
import 'jellyfin_accounts_test.dart'
|
||||
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
|
||||
|
||||
Future<void> setSize(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
Widget app(Widget child) => MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(body: Padding(padding: const EdgeInsets.all(16), child: child)),
|
||||
);
|
||||
|
||||
/// Lets fake requests, debounces and image errors settle without waiting
|
||||
/// on the progress spinners (pumpAndSettle would never return).
|
||||
Future<void> settle(WidgetTester tester, [int frames = 6]) async {
|
||||
for (var i = 0; i < frames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
/// MediaPicker on the Jellyfin source with one linked fake server.
|
||||
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker(
|
||||
WidgetTester tester, Size size) async {
|
||||
await setSize(tester, size);
|
||||
// Memory store + fake transport: no real IO, so no runAsync (futures
|
||||
// made there would resume outside the test's fake clock).
|
||||
final (accounts, fake) = await linkedAccounts();
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
|
||||
await settle(tester);
|
||||
return (c, accounts, fake);
|
||||
}
|
||||
|
||||
Future<void> openArifureta(WidgetTester tester) async {
|
||||
await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta');
|
||||
await settle(tester, 10);
|
||||
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('card-$arifuretaId')));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('home rows: continue watching with time left, next up, latest', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 2400));
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(find.text('Next up'), findsOneWidget);
|
||||
expect(find.text('Latest in Shows'), findsOneWidget);
|
||||
expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget);
|
||||
// Continue watching: series name on top, "S1E10 · N min left · title" below.
|
||||
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets);
|
||||
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
expect(find.text('Monogatari'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('series page: smart next button, season switching, pick an episode',
|
||||
(tester) async {
|
||||
final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
await openArifureta(tester);
|
||||
// Opens on the season holding next up (S2E4).
|
||||
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
|
||||
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
|
||||
expect(s2.selected, isTrue);
|
||||
expect(find.text('1. Irregular'), findsOneWidget);
|
||||
expect(find.textContaining('Next up: S2E4'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Season 3'));
|
||||
await settle(tester);
|
||||
expect(find.text('1. Irregular'), findsNothing);
|
||||
expect(find.text('1. Haulias Assemble'), findsWidgets);
|
||||
|
||||
// Picking an episode fills the controller.
|
||||
await tester.tap(find.text('2. Roar of Revolution').first);
|
||||
await tester.pump();
|
||||
final sel = c.selection;
|
||||
expect(sel, isA<JellyfinMedia>());
|
||||
sel as JellyfinMedia;
|
||||
expect(sel.serverId, fixtureServerId);
|
||||
expect(sel.title, 'Roar of Revolution');
|
||||
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2");
|
||||
expect(sel.baseUrl, 'https://example.invalid');
|
||||
|
||||
// The smart button picks next up.
|
||||
await tester.tap(find.byKey(const ValueKey('series-next')));
|
||||
await tester.pump();
|
||||
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 1000));
|
||||
final search = find.byKey(const ValueKey('jf-search'));
|
||||
|
||||
// Focus a card, then arrow right moves to the next card.
|
||||
final first = find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3'));
|
||||
Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first))
|
||||
.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!;
|
||||
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7');
|
||||
|
||||
// "/" jumps to search.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
|
||||
await tester.pump();
|
||||
expect(tester.widget<TextField>(search).focusNode!.hasFocus, isTrue);
|
||||
await tester.enterText(search, 'arifureta');
|
||||
await settle(tester, 10);
|
||||
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('search-results')), findsNothing);
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
|
||||
// Into the series and back with Esc.
|
||||
await openArifureta(tester);
|
||||
expect(find.byKey(const ValueKey('series-next')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('series-next')), findsNothing);
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('library grid with sort and filters', (tester) async {
|
||||
final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
await tester.tap(find.widgetWithText(ActionChip, 'Shows'));
|
||||
await settle(tester);
|
||||
expect(find.text('218 items'), findsOneWidget);
|
||||
expect(find.text('Alma-chan Wants to Be a Family!'), findsWidgets);
|
||||
await tester.tap(find.byKey(const ValueKey('filter-unplayed')));
|
||||
await settle(tester);
|
||||
final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
|
||||
expect(q['filters'], 'IsUnplayed');
|
||||
expect(q['includeItemTypes'], 'Series');
|
||||
expect(q['recursive'], 'true');
|
||||
await tester.tap(find.byKey(const ValueKey('library-sort')));
|
||||
await settle(tester, 2);
|
||||
await tester.tap(find.byKey(const ValueKey('sort-added')));
|
||||
await settle(tester);
|
||||
final q2 = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
|
||||
expect(q2['sortBy'], startsWith('DateCreated'));
|
||||
expect(q2['sortOrder'], 'Descending');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns',
|
||||
(tester) async {
|
||||
await pumpPicker(tester, const Size(820, 700));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')));
|
||||
|
||||
await pumpPicker(tester, const Size(3440, 1440));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
// Rows fill the width with whole cards.
|
||||
final wideCards = find.byWidgetPredicate((w) =>
|
||||
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
|
||||
expect(wideCards.evaluate().length, greaterThan(10));
|
||||
expect(narrowCard.width, greaterThan(200));
|
||||
|
||||
// Hover feeds the inspector.
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('jf-inspector')),
|
||||
matching: find.textContaining('Rice and Thieves and Heroes')),
|
||||
findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1200, 900));
|
||||
final fake = FakeJellyfin();
|
||||
final accounts = JellyfinAccounts(
|
||||
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test');
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
await tester.pumpWidget(app(SingleChildScrollView(
|
||||
child: JellyfinAccountsPanel(
|
||||
accounts: accounts, pollInterval: const Duration(milliseconds: 100)))));
|
||||
await tester.tap(find.byKey(const ValueKey('jf-add-server')));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byKey(const ValueKey('jf-address')), 'example.invalid');
|
||||
await tester.tap(find.byKey(const ValueKey('jf-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('Example Server'), findsOneWidget);
|
||||
expect(find.textContaining('Jellyfin 10.11.11'), findsOneWidget);
|
||||
|
||||
// Cancel: the code goes away and polling stops.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('437392'), findsOneWidget);
|
||||
expect(find.textContaining('Profile → Quick Connect'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('jf-qc-cancel')));
|
||||
await settle(tester, 4);
|
||||
final polls = fake.count('GET', '/QuickConnect/Connect');
|
||||
await settle(tester, 6);
|
||||
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
|
||||
expect(find.text('437392'), findsNothing);
|
||||
|
||||
// Again, and approve it.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('437392'), findsOneWidget);
|
||||
fake.approved = true;
|
||||
await settle(tester, 6);
|
||||
expect(accounts.accounts.single.userName, 'testuser');
|
||||
expect(find.byKey(const ValueKey('jf-account-$fixtureServerId')), findsOneWidget);
|
||||
expect(find.text('testuser on Example Server'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('jf-sign-in')), findsNothing);
|
||||
|
||||
// Expired token: Reconnect shows, the account stays.
|
||||
accounts.markExpired(fixtureServerId);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-reconnect-$fixtureServerId')), findsOneWidget);
|
||||
|
||||
// Sign out asks, revokes, unlinks.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-sign-out-$fixtureServerId')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('jf-sign-out-confirm')));
|
||||
await settle(tester, 4);
|
||||
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
||||
expect(accounts.isEmpty, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('no linked server: the picker offers sign-in inline', (tester) async {
|
||||
await setSize(tester, const Size(1200, 900));
|
||||
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(SingleChildScrollView(
|
||||
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
|
||||
expect(find.text('Open Integrations'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('expired account shows a reconnect banner in the browser', (tester) async {
|
||||
final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900));
|
||||
accounts.markExpired(fixtureServerId);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget);
|
||||
expect(find.text('Reconnect'), findsOneWidget);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Host tab: browser beside the server column, enlarged with a host bar, stacked when narrow',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1800, 1000));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
const pick = JellyfinMedia(
|
||||
serverId: fixtureServerId,
|
||||
baseUrl: 'https://example.invalid',
|
||||
itemId: 'b18826be994070a79f9b52bc0f66f052',
|
||||
title: 'Rice and Thieves and Heroes',
|
||||
subtitle: 'Black Summoner · S1E6');
|
||||
await accounts.rememberPick(pick);
|
||||
final fields = HostFields(ConnectForm(), jellyfin: accounts);
|
||||
final flow = ConnectFlow();
|
||||
addTearDown(() {
|
||||
fields.dispose();
|
||||
flow.dispose();
|
||||
});
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields))));
|
||||
await settle(tester);
|
||||
// The saved pick comes back on the Jellyfin source.
|
||||
expect(fields.media.selection, pick);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('host-column')), findsOneWidget);
|
||||
expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('jf-expand')));
|
||||
await settle(tester, 2);
|
||||
expect(find.byKey(const ValueKey('host-column')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('host-bar')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('jf-expand')));
|
||||
await settle(tester, 2);
|
||||
tester.view.physicalSize = const Size(800, 900);
|
||||
await settle(tester, 2);
|
||||
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
+38
-5
@@ -330,18 +330,51 @@ Recent server tiles in a grid that adds columns as the window widens. While
|
||||
the Join tab is visible each tile polls the server's `GET /health` every
|
||||
10 s (`health_probe.dart`) for online, name, viewer count and a "needs
|
||||
update" badge when `proto` differs from ours. Host (`host_tab.dart`): the
|
||||
media picker (`media_picker.dart`: Jellyfin placeholder, Link, File; a
|
||||
reusable widget driven by a `MediaPickerController`) fills the main area;
|
||||
port, password and Host sit in a 340 px column on the right, below the
|
||||
picker under 860 px. Text fields have fixed or capped widths (720 px max) so
|
||||
media picker (`media_picker.dart`: Jellyfin, Link, File; a reusable widget
|
||||
driven by a `MediaPickerController` whose `selection` is a sealed
|
||||
`PickedMedia`: `LinkMedia`, `FileMedia` or `JellyfinMedia`, see
|
||||
`picked_media.dart`) fills the main area; port, password and Host sit in a
|
||||
340 px column on the right, below the picker under 860 px. With the
|
||||
Jellyfin source the browser fills the height and can be enlarged, which
|
||||
turns the column into a one-row bar under it. Text fields have fixed or capped widths (720 px max) so
|
||||
they stay compact on ultrawide screens. Submit state (busy, cancel,
|
||||
per-tab errors, server crash) is `ConnectFlow`. Settings (profile, hardware
|
||||
decoding, updates, video shaders, shortcuts), Integrations
|
||||
(Jellyfin "coming soon") and About open in the same body. Settings persist
|
||||
(linked Jellyfin servers) and About open in the same body. Settings persist
|
||||
in two Hive boxes: `connect_form` (last form and recents; saved even when a
|
||||
join fails) and `settings`. Link media names are derived from the URL path
|
||||
and never contain a query string.
|
||||
|
||||
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||
- `accounts.dart`: `JellyfinAccounts` (singleton `instance`), one
|
||||
`JellyfinAccount` per ServerId (address list, user, token) in the
|
||||
`jellyfin` Hive box with a random per-install DeviceId; Client = app name,
|
||||
Version = app version, Device = hostname. Only tokens obtained by
|
||||
`sign_in.dart` (Quick Connect or password) are stored. Tokens are checked
|
||||
on start (`/Users/Me`, each known address in turn): online, offline, or
|
||||
expired (kept, shown as Reconnect; never dropped silently). A 401 while
|
||||
browsing also marks it expired. `clientFor(serverId)` gives the shared
|
||||
signed-in client; `cached()` memoises browse calls for 3 min.
|
||||
- `sign_in.dart` + `sign_in_panel.dart`: address -> `/System/Info/Public`
|
||||
-> Quick Connect (code, poll, cancel) or password; reconnect must hit the
|
||||
same ServerId. `accounts_panel.dart` is the Integrations card.
|
||||
- `browser.dart`: back stack of `BrowseRoute`s (Home rows, library grid,
|
||||
series, detail) kept alive under `Visibility(maintainState)` so back keeps
|
||||
scroll and loaded pages; search (300 ms debounce) overlays them. Cards
|
||||
(`cards.dart`) fill the width with whole columns (`fitColumns`); rows and
|
||||
grids are lazy builders; library grids page 100 at a time with skeletons
|
||||
up to the total count. Images (`images.dart`) are `Image.network` at
|
||||
width steps (160..2560) with the image tag in the URL, so the image cache
|
||||
key changes only when the image does. An inspector pane follows
|
||||
hover/focus from 1400 px browser width.
|
||||
- Picking an item gives a `JellyfinMedia` (serverId, baseUrl, itemId,
|
||||
mediaSourceId?, title, subtitle, runtime; no credentials). The Host tab
|
||||
remembers the last pick in the `jellyfin` box. Interim until the protocol
|
||||
carries Jellyfin items: `interimJellyfinLink` in `host_tab.dart`
|
||||
(`INTERIM(jellyfin-protocol)`) hosts it as a link to the host's
|
||||
authorised static stream URL; `ConnectFlow.submit(linkOverride:)` keeps
|
||||
that token-bearing URL out of the saved form.
|
||||
|
||||
Window chrome is frameless (`window_manager`): every screen brings its own
|
||||
drag area and min/max/close buttons (Home's top bar; the session uses
|
||||
`widgets/window_chrome_bar.dart`; both use `window_buttons.dart`); the session drag layer is `window_drag_layer.dart`.
|
||||
|
||||
Reference in new issue
Block a user