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