From 773a04369ea28efb672fd813c53019e164ee8579 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 7 Oct 2026 02:05:46 +0300 Subject: [PATCH] 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 --- README.md | 36 +- apps/desktop/lib/src/jellyfin/accounts.dart | 500 ++++++++++++++++ .../lib/src/jellyfin/accounts_panel.dart | 207 +++++++ .../lib/src/jellyfin/browse_scope.dart | 200 +++++++ apps/desktop/lib/src/jellyfin/browser.dart | 546 ++++++++++++++++++ apps/desktop/lib/src/jellyfin/cards.dart | 424 ++++++++++++++ .../desktop/lib/src/jellyfin/detail_page.dart | 180 ++++++ apps/desktop/lib/src/jellyfin/format.dart | 90 +++ apps/desktop/lib/src/jellyfin/home_page.dart | 189 ++++++ apps/desktop/lib/src/jellyfin/images.dart | 87 +++ apps/desktop/lib/src/jellyfin/inspector.dart | 128 ++++ .../lib/src/jellyfin/library_page.dart | 276 +++++++++ .../lib/src/jellyfin/picker_source.dart | 101 ++++ .../lib/src/jellyfin/search_results.dart | 69 +++ .../desktop/lib/src/jellyfin/series_page.dart | 456 +++++++++++++++ apps/desktop/lib/src/jellyfin/sign_in.dart | 182 ++++++ .../lib/src/jellyfin/sign_in_panel.dart | 306 ++++++++++ .../lib/src/screens/home/connect_flow.dart | 15 +- .../lib/src/screens/home/home_screen.dart | 11 +- .../lib/src/screens/home/host_tab.dart | 228 ++++++-- .../src/screens/home/integrations_page.dart | 36 +- .../lib/src/screens/home/media_picker.dart | 275 +++++---- .../lib/src/screens/home/picked_media.dart | 114 ++++ apps/desktop/test/home_test.dart | 11 +- apps/desktop/test/jellyfin_accounts_test.dart | 374 ++++++++++++ apps/desktop/test/jellyfin_browser_test.dart | 313 ++++++++++ docs/ARCHITECTURE.md | 43 +- 27 files changed, 5169 insertions(+), 228 deletions(-) create mode 100644 apps/desktop/lib/src/jellyfin/accounts.dart create mode 100644 apps/desktop/lib/src/jellyfin/accounts_panel.dart create mode 100644 apps/desktop/lib/src/jellyfin/browse_scope.dart create mode 100644 apps/desktop/lib/src/jellyfin/browser.dart create mode 100644 apps/desktop/lib/src/jellyfin/cards.dart create mode 100644 apps/desktop/lib/src/jellyfin/detail_page.dart create mode 100644 apps/desktop/lib/src/jellyfin/format.dart create mode 100644 apps/desktop/lib/src/jellyfin/home_page.dart create mode 100644 apps/desktop/lib/src/jellyfin/images.dart create mode 100644 apps/desktop/lib/src/jellyfin/inspector.dart create mode 100644 apps/desktop/lib/src/jellyfin/library_page.dart create mode 100644 apps/desktop/lib/src/jellyfin/picker_source.dart create mode 100644 apps/desktop/lib/src/jellyfin/search_results.dart create mode 100644 apps/desktop/lib/src/jellyfin/series_page.dart create mode 100644 apps/desktop/lib/src/jellyfin/sign_in.dart create mode 100644 apps/desktop/lib/src/jellyfin/sign_in_panel.dart create mode 100644 apps/desktop/lib/src/screens/home/picked_media.dart create mode 100644 apps/desktop/test/jellyfin_accounts_test.dart create mode 100644 apps/desktop/test/jellyfin_browser_test.dart diff --git a/README.md b/README.md index 03ec7ac..5d5b6aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/desktop/lib/src/jellyfin/accounts.dart b/apps/desktop/lib/src/jellyfin/accounts.dart new file mode 100644 index 0000000..e26d305 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/accounts.dart @@ -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 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? 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 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().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 open(); + Object? get(String key); + Future put(String key, Object? value); +} + +class HiveJellyfinStore implements JellyfinStore { + Box? _box; + + @override + Future open() async => _box ??= await Hive.openBox(jellyfinBoxName); + + @override + Object? get(String key) => _box?.get(key); + + @override + Future 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 data; + MemoryJellyfinStore([Map? data]) : data = data ?? {}; + + @override + Future open() async {} + @override + Object? get(String key) => data[key]; + @override + Future 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 _accounts = const []; + final _status = {}; + final _errors = {}; + final _clients = {}; + final _memo = value})>{}; + String? _defaultServerId; + String _deviceId = ''; + JellyfinMedia? _lastPick; + Future? _load; + bool _loaded = false; + bool _disposed = false; + + List 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 ensureLoaded({bool validate = true}) => + _load ??= _loadNow(validate: validate); + + Future _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 _decodeAccounts(Object? raw) { + if (raw is! String) return const []; + final list = _tryJson(raw); + if (list is! List) return const []; + final out = []; + 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 _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 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 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 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 _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 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 setDefault(String serverId) async { + if (account(serverId) == null || _defaultServerId == serverId) return; + _defaultServerId = serverId; + _notify(); + await _save(); + } + + Future 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 cached(String serverId, String key, Future 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(); + } +} diff --git a/apps/desktop/lib/src/jellyfin/accounts_panel.dart b/apps/desktop/lib/src/jellyfin/accounts_panel.dart new file mode 100644 index 0000000..3523bd3 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/accounts_panel.dart @@ -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 createState() => _JellyfinAccountsPanelState(); +} + +class _JellyfinAccountsPanelState extends State { + bool _adding = false; + + @override + void initState() { + super.initState(); + widget.accounts.ensureLoaded(); + } + + Future _signOut(JellyfinAccount a) async { + final ok = await showDialog( + 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'), + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/browse_scope.dart b/apps/desktop/lib/src/jellyfin/browse_scope.dart new file mode 100644 index 0000000..319334e --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/browse_scope.dart @@ -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 onPick; + final ValueChanged onOpen; + /// Hover / focus: the inspector pane follows it. + final ValueChanged 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()!; + + /// Cached browse call on this server. + Future load(String key, Future 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 extends StatefulWidget { + final Future 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> createState() => _LoaderState(); +} + +class _LoaderState extends State> { + late Future _future = widget.load(); + + void _retry() => setState(() => _future = widget.load()); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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')), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/browser.dart b/apps/desktop/lib/src/jellyfin/browser.dart new file mode 100644 index 0000000..08e59b2 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/browser.dart @@ -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 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 createState() => _JellyfinBrowserState(); +} + +class _JellyfinBrowserState extends State { + String? _serverId; + List _stack = const [HomeRoute()]; + final _search = TextEditingController(); + final _searchFocus = FocusNode(debugLabel: 'jf-search'); + final _body = FocusScopeNode(debugLabel: 'jf-body'); + final _peek = ValueNotifier(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() != + 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 = []; + 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 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 showReconnectDialog( + BuildContext context, JellyfinAccounts accounts, JellyfinAccount account) { + return showDialog( + 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), + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/cards.dart b/apps/desktop/lib/src/jellyfin/cards.dart new file mode 100644 index 0000000..91d3059 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/cards.dart @@ -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 createState() => _MediaCardState(); +} + +class _MediaCardState extends State { + 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 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 createState() => _MediaRowState(); +} + +class _MediaRowState extends State { + 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, + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/detail_page.dart b/apps/desktop/lib/src/jellyfin/detail_page.dart new file mode 100644 index 0000000..f4231a7 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/detail_page.dart @@ -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( + 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().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)), + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/format.dart b/apps/desktop/lib/src/jellyfin/format.dart new file mode 100644 index 0000000..8ebb182 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/format.dart @@ -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 = []; + 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, + ); +} diff --git a/apps/desktop/lib/src/jellyfin/home_page.dart b/apps/desktop/lib/src/jellyfin/home_page.dart new file mode 100644 index 0000000..31feac0 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/home_page.dart @@ -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>( + 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 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> 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>( + 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), + ], + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/images.dart b/apps/desktop/lib/src/jellyfin/images.dart new file mode 100644 index 0000000..bfe72a3 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/images.dart @@ -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)), + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/inspector.dart b/apps/desktop/lib/src/jellyfin/inspector.dart new file mode 100644 index 0000000..04e361c --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/inspector.dart @@ -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 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( + 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), + ], + ], + ), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/library_page.dart b/apps/desktop/lib/src/jellyfin/library_page.dart new file mode 100644 index 0000000..8ac7aaa --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/library_page.dart @@ -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 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 [], 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 createState() => _JellyfinLibraryPageState(); +} + +class _JellyfinLibraryPageState extends State { + LibrarySort _sort = LibrarySort.name; + bool _descending = false; + bool _unplayed = false; + bool _favorites = false; + + final _items = []; + 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 _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 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), + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/picker_source.dart b/apps/desktop/lib/src/jellyfin/picker_source.dart new file mode 100644 index 0000000..8ce066b --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/picker_source.dart @@ -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 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 createState() => _JellyfinPickerSourceState(); +} + +class _JellyfinPickerSourceState extends State { + @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')), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/search_results.dart b/apps/desktop/lib/src/jellyfin/search_results.dart new file mode 100644 index 0000000..a294488 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/search_results.dart @@ -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>( + 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)), + ], + ], + ); + }, + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/series_page.dart b/apps/desktop/lib/src/jellyfin/series_page.dart new file mode 100644 index 0000000..44364bf --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/series_page.dart @@ -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 createState() => _JellyfinSeriesPageState(); +} + +typedef _SeriesData = ({JellyfinItem series, List seasons, JellyfinItem? next}); + +class _JellyfinSeriesPageState extends State { + 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([ + 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?>((v) => v, onError: (_) => null), + ]); + final next = results[2] as List?; + return ( + series: results[0] as JellyfinItem, + seasons: results[1] as List, + 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>? _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>( + 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 createState() => _EpisodeTileState(); +} + +class _EpisodeTileState extends State { + 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)), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/jellyfin/sign_in.dart b/apps/desktop/lib/src/jellyfin/sign_in.dart new file mode 100644 index 0000000..09f0879 --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/sign_in.dart @@ -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 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 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 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 _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(); + } +} diff --git a/apps/desktop/lib/src/jellyfin/sign_in_panel.dart b/apps/desktop/lib/src/jellyfin/sign_in_panel.dart new file mode 100644 index 0000000..58509ac --- /dev/null +++ b/apps/desktop/lib/src/jellyfin/sign_in_panel.dart @@ -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? 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 createState() => _JellyfinSignInPanelState(); +} + +class _JellyfinSignInPanelState extends State { + 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')), + ], + ); + } +} diff --git a/apps/desktop/lib/src/screens/home/connect_flow.dart b/apps/desktop/lib/src/screens/home/connect_flow.dart index 7a9de55..61f86c3 100644 --- a/apps/desktop/lib/src/screens/home/connect_flow.dart +++ b/apps/desktop/lib/src/screens/home/connect_flow.dart @@ -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 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 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; diff --git a/apps/desktop/lib/src/screens/home/home_screen.dart b/apps/desktop/lib/src/screens/home/home_screen.dart index 7488599..2691dc0 100644 --- a/apps/desktop/lib/src/screens/home/home_screen.dart +++ b/apps/desktop/lib/src/screens/home/home_screen.dart @@ -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 { 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 { 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 { 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( diff --git a/apps/desktop/lib/src/screens/home/host_tab.dart b/apps/desktop/lib/src/screens/home/host_tab.dart index ecfd05f..9ff6a20 100644 --- a/apps/desktop/lib/src/screens/home/host_tab.dart +++ b/apps/desktop/lib/src/screens/home/host_tab.dart @@ -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), + ], + ), + ); } } diff --git a/apps/desktop/lib/src/screens/home/integrations_page.dart b/apps/desktop/lib/src/screens/home/integrations_page.dart index 449ebda..03e0ae9 100644 --- a/apps/desktop/lib/src/screens/home/integrations_page.dart +++ b/apps/desktop/lib/src/screens/home/integrations_page.dart @@ -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), ], ); } diff --git a/apps/desktop/lib/src/screens/home/media_picker.dart b/apps/desktop/lib/src/screens/home/media_picker.dart index 1ed1829..57302ab 100644 --- a/apps/desktop/lib/src/screens/home/media_picker.dart +++ b/apps/desktop/lib/src/screens/home/media_picker.dart @@ -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? onChanged; + final ValueChanged? 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 { @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( + 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( - 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')), - ], - ], - ), - ), - ], - ); - }); - } -} diff --git a/apps/desktop/lib/src/screens/home/picked_media.dart b/apps/desktop/lib/src/screens/home/picked_media.dart new file mode 100644 index 0000000..a217e2b --- /dev/null +++ b/apps/desktop/lib/src/screens/home/picked_media.dart @@ -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 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")'; +} diff --git a/apps/desktop/test/home_test.dart b/apps/desktop/test/home_test.dart index 5b08f06..092e0e7 100644 --- a/apps/desktop/test/home_test.dart +++ b/apps/desktop/test/home_test.dart @@ -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); }); diff --git a/apps/desktop/test/jellyfin_accounts_test.dart b/apps/desktop/test/jellyfin_accounts_test.dart new file mode 100644 index 0000000..44b8f2a --- /dev/null +++ b/apps/desktop/test/jellyfin_accounts_test.dart @@ -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 headers})>[]; + final routes = {}; + 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> _items(String fixture) => + ((jsonDecode(jellyfinFixture(fixture)) as Map)['Items'] as List).cast>(); + + static JellyfinHttpResponse _page(List 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 send(String method, Uri uri, + {Map 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); + +JellyfinServerInfo fixtureServer() => + JellyfinServerInfo.fromJson(jsonDecode(jellyfinFixture('pub')) as Map); + +/// 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.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.delayed(const Duration(milliseconds: 20)); + flow.cancel(); + await done; + final polls = fake.count('GET', '/QuickConnect/Connect'); + await Future.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); + 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).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'); + }); + }); +} diff --git a/apps/desktop/test/jellyfin_browser_test.dart b/apps/desktop/test/jellyfin_browser_test.dart new file mode 100644 index 0000000..2845226 --- /dev/null +++ b/apps/desktop/test/jellyfin_browser_test.dart @@ -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 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 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 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(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()); + 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()!; + expect((ink.key as ValueKey).value, 'card-43c0f58d4f0c13e4df01d737b695fae7'); + + // "/" jumps to search. + await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/'); + await tester.pump(); + expect(tester.widget(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 && (w.key as ValueKey).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); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db131a8..daec5f8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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`.