Jellyfin in the app: account linking and library browser
Integrations links several Jellyfin servers (Quick Connect or password, app-obtained tokens only, status/reconnect/sign out). The Host tab's Jellyfin source is a full browser: continue watching, next up, latest per library, library grids with sort/filter/infinite scroll, series pages with smart resume/next-up, details, grouped search, inspector pane on wide screens, keyboard navigation. MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin pick is interim (host's stream URL) until per-viewer playback lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45f4c1bc84
commit
773a04369e
27 files changed
+5169
-228
No files matched your search
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user