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,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'browse_scope.dart';
|
||||
import 'cards.dart';
|
||||
|
||||
/// Search results grouped by kind: shows and movies as posters, episodes
|
||||
/// as thumbnails, each a grid that fills the width.
|
||||
class JellyfinSearchResults extends StatelessWidget {
|
||||
final String term;
|
||||
const JellyfinSearchResults({super.key, required this.term});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = BrowseScope.of(context);
|
||||
return Loader<List<JellyfinItem>>(
|
||||
key: ValueKey('search-$term-${scope.account.serverId}'),
|
||||
load: () => scope.load('search:$term', (c) => c.search(term, limit: 80)),
|
||||
builder: (context, items) {
|
||||
if (items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Text('Nothing found for "$term"',
|
||||
key: const ValueKey('search-empty'),
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
),
|
||||
);
|
||||
}
|
||||
final groups = [
|
||||
('Shows', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.series).toList()),
|
||||
('Movies', CardShape.poster, items.where((i) => i.kind == JellyfinItemKind.movie).toList()),
|
||||
('Episodes', CardShape.thumb, items.where((i) => i.kind == JellyfinItemKind.episode).toList()),
|
||||
(
|
||||
'Other',
|
||||
CardShape.poster,
|
||||
items
|
||||
.where((i) => !const [
|
||||
JellyfinItemKind.series,
|
||||
JellyfinItemKind.movie,
|
||||
JellyfinItemKind.episode
|
||||
].contains(i.kind))
|
||||
.toList()
|
||||
),
|
||||
].where((g) => g.$3.isNotEmpty);
|
||||
return CustomScrollView(
|
||||
key: const ValueKey('search-results'),
|
||||
slivers: [
|
||||
for (final (title, shape, list) in groups) ...[
|
||||
SliverToBoxAdapter(child: SectionHeader(title, count: '${list.length}')),
|
||||
SliverGrid(
|
||||
gridDelegate: CardGridDelegate(shape),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => MediaCard(
|
||||
item: list[i],
|
||||
shape: shape,
|
||||
seriesFirst: shape == CardShape.thumb,
|
||||
),
|
||||
childCount: list.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user