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), ), ); } }