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

+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
/// Server-side scale steps: nearby sizes share one URL (and one cache
/// entry) instead of fetching per-pixel variants on every resize.
const _widthSteps = [160, 240, 320, 480, 640, 960, 1280, 1920, 2560];
int imageWidthStep(double physicalWidth) {
for (final s in _widthSteps) {
if (s >= physicalWidth) return s;
}
return _widthSteps.last;
}
/// A Jellyfin image sized to its box. The URL carries the image tag and the
/// width step, so Flutter's image cache (keyed by URL) reuses it until the
/// image changes on the server; decode is capped at the same width. Shows
/// [placeholder] (or a tinted box with [icon]) while loading and on error,
/// and fades in when ready.
class JellyfinImage extends StatelessWidget {
final JellyfinClient client;
final JellyfinImageRef? image;
final BoxFit fit;
final IconData icon;
final Alignment alignment;
final Widget? placeholder;
const JellyfinImage({
super.key,
required this.client,
required this.image,
this.fit = BoxFit.cover,
this.icon = Icons.movie_outlined,
this.alignment = Alignment.center,
this.placeholder,
});
@override
Widget build(BuildContext context) {
final ref = image;
final fallback = placeholder ?? _Placeholder(icon: icon);
if (ref == null) return fallback;
return LayoutBuilder(builder: (context, box) {
final dpr = MediaQuery.maybeDevicePixelRatioOf(context) ?? 1;
final w = box.hasBoundedWidth ? box.maxWidth : 480.0;
final step = imageWidthStep(w * dpr);
final url = client.imageUrl(ref, maxWidth: step, quality: 85).toString();
return Image.network(
url,
key: ValueKey(url),
fit: fit,
alignment: alignment,
width: double.infinity,
height: double.infinity,
cacheWidth: step,
filterQuality: FilterQuality.medium,
frameBuilder: (context, child, frame, sync) {
if (sync) return child;
return Stack(fit: StackFit.passthrough, children: [
if (frame == null) fallback,
AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 180),
child: child,
),
]);
},
errorBuilder: (context, _, _) => fallback,
);
});
}
}
class _Placeholder extends StatelessWidget {
final IconData icon;
const _Placeholder({required this.icon});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ColoredBox(
color: scheme.surfaceContainerHigh,
child: Center(
child: Icon(icon, size: 28, color: scheme.onSurfaceVariant.withValues(alpha: 0.35)),
),
);
}
}