Jellyfin in the app: account linking and library browser

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

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

No files matched your search

@@ -0,0 +1,276 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_page.dart' show SkeletonCard;
enum LibrarySort {
name('Name', JellyfinSortBy.sortName, false),
added('Date added', JellyfinSortBy.dateCreated, true),
premiere('Release date', JellyfinSortBy.premiereDate, true),
rating('Rating', JellyfinSortBy.communityRating, true),
random('Random', JellyfinSortBy.random, false);
final String label;
final String apiName;
/// Natural direction: newest / best first.
final bool descending;
const LibrarySort(this.label, this.apiName, this.descending);
}
/// Item types and recursion for a library: movie and show libraries list
/// their titles flat; anything else is browsed folder by folder.
({List<String> types, bool recursive}) libraryShape(JellyfinItem folder) =>
switch (folder.collectionType) {
'movies' => (types: const [JellyfinItemType.movie], recursive: true),
'tvshows' => (types: const [JellyfinItemType.series], recursive: true),
'boxsets' => (types: const [JellyfinItemType.boxSet], recursive: true),
_ => (types: const <String>[], recursive: false),
};
const libraryPageSize = 100;
/// One library (or folder) as a grid that fills the width, with sort,
/// unplayed / favourite filters and infinite scroll. The scrollbar spans
/// the full count from the start: not-yet-loaded cells are skeletons and
/// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder});
@override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
bool _unplayed = false;
bool _favorites = false;
final _items = <JellyfinItem>[];
int? _total;
bool _loading = false;
Object? _error;
int _gen = 0;
final _scroll = ScrollController();
BrowseScope? _scope;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final scope = BrowseScope.of(context);
final first = _scope == null;
final changedServer = _scope != null && _scope!.client != scope.client;
_scope = scope;
if (first || changedServer) _reload();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
JellyfinItemQuery get _query {
final shape = libraryShape(widget.folder);
return JellyfinItemQuery(
parentId: widget.folder.id,
includeItemTypes: shape.types,
recursive: shape.recursive,
sortBy: [
// Folders first when browsing a folder tree.
if (!shape.recursive && _sort == LibrarySort.name) 'IsFolder',
_sort.apiName,
if (_sort != LibrarySort.name) JellyfinSortBy.sortName,
],
descending: _descending,
filters: [
if (_unplayed) JellyfinFilter.isUnplayed,
if (_favorites) JellyfinFilter.isFavorite,
],
);
}
void _reload() {
_gen++;
_items.clear();
_total = null;
_error = null;
_loading = false;
if (_scroll.hasClients) _scroll.jumpTo(0);
_loadMore();
}
Future<void> _loadMore() async {
final scope = _scope;
if (scope == null || _loading) return;
if (_total != null && _items.length >= _total!) return;
final gen = _gen;
setState(() {
_loading = true;
_error = null;
});
try {
final page = await scope.client.items(_query.page(_items.length, libraryPageSize));
if (gen != _gen || !mounted) return;
setState(() {
_items.addAll(page.items);
// An empty page before the advertised end: the library shrank.
_total = page.items.isEmpty ? _items.length : page.totalRecordCount;
_loading = false;
});
} catch (e) {
if (gen != _gen || !mounted) return;
if (e is JellyfinException && e.isUnauthorized) {
scope.accounts.markExpired(scope.account.serverId);
}
setState(() {
_loading = false;
_error = e;
});
}
}
void _setSort(LibrarySort s) {
setState(() {
_sort = s;
_descending = s.descending;
});
_reload();
}
CardShape get _shape =>
widget.folder.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final total = _total;
final toolbar = Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(widget.folder.name, style: t.titleLarge),
const SizedBox(width: 4),
Text(total == null ? '' : '$total ${total == 1 ? 'item' : 'items'}',
key: const ValueKey('library-count'),
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(width: 12),
_SortMenu(sort: _sort, onSelected: _setSort),
if (_sort != LibrarySort.random)
IconButton(
tooltip: _descending ? 'Descending' : 'Ascending',
visualDensity: VisualDensity.compact,
onPressed: () {
setState(() => _descending = !_descending);
_reload();
},
icon: Icon(_descending ? Icons.arrow_downward : Icons.arrow_upward, size: 18),
)
else
IconButton(
tooltip: 'Shuffle again',
visualDensity: VisualDensity.compact,
onPressed: _reload,
icon: const Icon(Icons.shuffle, size: 18),
),
FilterChip(
key: const ValueKey('filter-unplayed'),
label: const Text('Unwatched'),
selected: _unplayed,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _unplayed = v);
_reload();
},
),
FilterChip(
key: const ValueKey('filter-favorites'),
label: const Text('Favorites'),
selected: _favorites,
visualDensity: VisualDensity.compact,
onSelected: (v) {
setState(() => _favorites = v);
_reload();
},
),
],
);
final count = total ?? (_loading ? libraryPageSize ~/ 4 : 0);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(padding: const EdgeInsets.only(bottom: 12), child: toolbar),
if (_error != null && _items.isEmpty)
LoadError(error: _error!, onRetry: _loadMore)
else if (total == 0)
const Padding(
padding: EdgeInsets.all(32),
child: Center(child: Text('Nothing here', style: TextStyle(color: Colors.grey))),
)
else
Expanded(
child: Scrollbar(
controller: _scroll,
child: CustomScrollView(
controller: _scroll,
slivers: [
SliverGrid(
gridDelegate: CardGridDelegate(_shape),
delegate: SliverChildBuilderDelegate(
(context, i) {
if (i >= _items.length - 40 && _error == null) {
// Near the loaded edge: fetch the next page.
scheduleMicrotask(_loadMore);
}
if (i >= _items.length) return SkeletonCard(shape: _shape);
return MediaCard(item: _items[i], shape: _shape);
},
childCount: count,
),
),
if (_error != null && _items.isNotEmpty)
SliverToBoxAdapter(child: LoadError(error: _error!, onRetry: _loadMore)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
),
),
],
);
}
}
class _SortMenu extends StatelessWidget {
final LibrarySort sort;
final ValueChanged<LibrarySort> onSelected;
const _SortMenu({required this.sort, required this.onSelected});
@override
Widget build(BuildContext context) {
return MenuAnchor(
menuChildren: [
for (final s in LibrarySort.values)
MenuItemButton(
key: ValueKey('sort-${s.name}'),
leadingIcon: Icon(s == sort ? Icons.check : null, size: 18),
onPressed: () => onSelected(s),
child: Text(s.label),
),
],
builder: (context, menu, _) => OutlinedButton.icon(
key: const ValueKey('library-sort'),
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(Icons.sort, size: 18),
label: Text(sort.label),
),
);
}
}