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

@@ -91,8 +91,11 @@ class ConnectFlow extends ChangeNotifier {
}
/// [edit] fills the card's fields into a copy of the last form, so the
/// other card's saved values survive.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit) async {
/// other card's saved values survive. [linkOverride] (host only) is played
/// as the link but never stored in the form: the interim Jellyfin stream
/// URL carries a token.
Future<void> submit(ConnectMode mode, void Function(ConnectForm f) edit,
{String? linkOverride}) async {
if (busy != null) return;
final myAttempt = ++_attempt;
final form = _session.lastForm.copy();
@@ -112,7 +115,13 @@ class ConnectFlow extends ChangeNotifier {
final ConnectParams params;
try {
params = await buildParams(mode, form);
params = await buildParams(
mode,
linkOverride == null || mode != ConnectMode.host
? form
: (form.copy()
..mediaPath = ''
..linkUrl = linkOverride));
} catch (e) {
if (myAttempt != _attempt) return;
busy = null;
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
@@ -34,12 +35,15 @@ class HomeShell extends StatefulWidget {
final bool autoUpdateCheck;
final bool windowControls;
final HealthGetter healthGet;
/// Linked Jellyfin servers; defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const HomeShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = false,
this.healthGet = defaultHealthGet,
this.jellyfin,
});
@override
@@ -50,7 +54,8 @@ class _HomeShellState extends State<HomeShell> {
HomeView _view = HomeView.join;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final _jellyfin = widget.jellyfin ?? JellyfinAccounts.instance;
late final _host = HostFields(SessionState.instance.lastForm, jellyfin: _jellyfin);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
@@ -60,6 +65,8 @@ class _HomeShellState extends State<HomeShell> {
void initState() {
super.initState();
Profile.instance.ensureLoaded();
// Reads linked servers and checks their tokens in the background.
_jellyfin.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKickedDialog;
// Back from a session that ended in a kick: say so.
@@ -134,7 +141,7 @@ class _HomeShellState extends State<HomeShell> {
onOpenIntegrations: () => _select(HomeView.integrations),
),
HomeView.settings => SettingsPage(updates: widget.updates),
HomeView.integrations => const IntegrationsPage(),
HomeView.integrations => IntegrationsPage(jellyfin: _jellyfin),
HomeView.about => AboutPage(updates: widget.updates),
};
return CallbackShortcuts(
+193 -35
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../jellyfin/accounts.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
@@ -9,21 +10,60 @@ import 'ui.dart';
/// Host form state, owned by the Home shell so it survives tab switches.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
HostFields(ConnectForm f, {JellyfinAccounts? jellyfin})
: jellyfin = jellyfin ?? JellyfinAccounts.instance,
port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret),
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl) {
// The last Jellyfin pick lives in the Jellyfin box (the connect form
// only knows links and files); restore it once that box is read.
final noMedia = f.mediaPath.isEmpty && f.linkUrl.isEmpty;
this.jellyfin.ensureLoaded().then((_) {
final pick = this.jellyfin.lastPick;
if (_disposed || pick == null || media.jellyfin != null) return;
if (this.jellyfin.account(pick.serverId) == null) return;
if (noMedia) {
media.pickJellyfin(pick);
} else {
final source = media.source;
media.pickJellyfin(pick);
media.source = source;
}
});
}
final JellyfinAccounts jellyfin;
final TextEditingController port;
final TextEditingController secret;
final MediaPickerController media;
/// Jellyfin browser enlarged: the server column shrinks to a bar.
final browserExpanded = ValueNotifier(false);
bool _disposed = false;
void dispose() {
_disposed = true;
port.dispose();
secret.dispose();
media.dispose();
browserExpanded.dispose();
}
}
/// INTERIM(jellyfin-protocol): until the protocol carries a Jellyfin item,
/// the host plays it as a link: this install's authorised static stream URL
/// (token in the query, so viewers stream with the host's sign-in and the
/// URL must not be persisted). Throws [FormatException] with a user-facing
/// message when the server is not linked any more.
String interimJellyfinLink(JellyfinAccounts accounts, JellyfinMedia media) {
final client = accounts.clientFor(media.serverId);
if (client == null) {
throw const FormatException('that Jellyfin server is not linked any more — pick again');
}
if (accounts.statusOf(media.serverId) == JellyfinAccountStatus.expired) {
throw const FormatException('Jellyfin sign-in expired — reconnect it in Integrations');
}
return client.streamUrl(media.itemId, mediaSourceId: media.mediaSourceId).toString();
}
/// Below this width the server column moves under the media picker.
const hostStackBelow = 860.0;
const _columnWidth = 340.0;
@@ -36,47 +76,102 @@ class HostTab extends StatelessWidget {
const HostTab(
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
void _host() => flow.submit(ConnectMode.host, (f) {
final media = fields.media.selection;
f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media.mediaPath
..linkUrl = media.linkUrl;
});
void _host() {
if (flow.busy != null) return;
final media = fields.media.selection;
String? link;
if (media is JellyfinMedia) {
try {
link = interimJellyfinLink(fields.jellyfin, media);
} on FormatException catch (e) {
flow.showError(ConnectMode.host, CardError('Check the form', e.message));
return;
}
fields.jellyfin.rememberPick(media);
}
flow.submit(
ConnectMode.host,
(f) => f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media is FileMedia ? media.path : ''
..linkUrl = media is LinkMedia ? media.url : '',
linkOverride: link,
);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: flow,
listenable: Listenable.merge([flow, fields.media, fields.browserExpanded]),
builder: (context, _) {
final busy = flow.busy == ConnectMode.host;
final picker = Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
),
],
);
final jellyfin = fields.media.source == MediaKind.jellyfin;
final expanded = jellyfin && fields.browserExpanded.value;
Widget picker({required bool fill}) {
final p = MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
jellyfin: fields.jellyfin,
expanded: expanded,
onToggleExpanded: () => fields.browserExpanded.value = !fields.browserExpanded.value,
);
return Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
fill ? Expanded(child: p) : p,
],
);
}
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
return LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < hostStackBelow;
// Jellyfin browser: fills the height (lazy grids need a bounded
// viewport); the form sits beside it or, enlarged, in a bar below.
if (expanded) {
return Padding(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(height: 12),
_HostBar(flow: flow, fields: fields, onHost: _host),
],
),
);
}
if (jellyfin && !stacked) {
return Padding(
padding: kPagePadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: picker(fill: true)),
const SizedBox(width: 24),
SizedBox(
width: _columnWidth,
child: SingleChildScrollView(child: column),
),
],
),
);
}
return SingleChildScrollView(
padding: kPagePadding,
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
picker,
picker(fill: false),
const SizedBox(height: 24),
Align(
alignment: Alignment.topLeft,
@@ -90,7 +185,7 @@ class HostTab extends StatelessWidget {
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: picker),
Expanded(child: picker(fill: false)),
const SizedBox(width: 24),
SizedBox(width: _columnWidth, child: column),
],
@@ -168,7 +263,7 @@ class _ServerColumn extends StatelessWidget {
listenable: fields.media,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_mediaSummary(fields.media.selection),
child: Text(mediaSummary(fields.media.selection),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
@@ -184,11 +279,74 @@ class _ServerColumn extends StatelessWidget {
);
}
static String _mediaSummary(MediaSelection s) {
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
if (raw.isEmpty) return 'No media selected (optional).';
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
return 'Playing: $name';
}
String mediaSummary(PickedMedia? s) =>
s == null ? 'No media selected (optional).' : 'Playing: ${s.label}';
/// Enlarged-browser mode: what is picked, port, password and Host in one
/// row under the browser.
class _HostBar extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _HostBar({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('host-bar'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(mediaSummary(fields.media.selection),
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodyMedium),
),
const SizedBox(width: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 100,
child: TextField(
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 200,
child: TextField(
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'none = open'),
onSubmitted: (_) => onHost(),
),
),
],
),
),
const SizedBox(width: 12),
ConnectButton(flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost),
],
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
],
),
);
}
}
@@ -1,44 +1,22 @@
import 'package:flutter/material.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/accounts_panel.dart';
import 'ui.dart';
class IntegrationsPage extends StatelessWidget {
const IntegrationsPage({super.key});
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
const IntegrationsPage({super.key, this.jellyfin});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return PageScroll(
maxWidth: 680,
maxWidth: 820,
children: [
const PageTitle('Integrations',
subtitle: 'Connect media libraries to pick what to watch.'),
Panel(
enabled: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.video_library_outlined, size: 32),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Jellyfin', style: t.titleMedium),
const SizedBox(height: 4),
const Text(
'Coming soon: link your Jellyfin account(s), browse and '
'put up episodes, watched/continue-watching sync.',
style: TextStyle(color: Colors.grey),
),
],
),
),
const SizedBox(width: kGap),
const OutlinedButton(onPressed: null, child: Text('Link account')),
],
),
),
JellyfinAccountsPanel(accounts: jellyfin ?? JellyfinAccounts.instance),
],
);
}
@@ -2,23 +2,32 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../jellyfin/accounts.dart';
import '../../jellyfin/picker_source.dart';
import '../../widgets/media_input.dart';
import 'picked_media.dart';
import 'ui.dart';
export 'picked_media.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
enum MediaKind { jellyfin, link, file }
/// What to play: at most one of the two is non-empty; both empty = none.
typedef MediaSelection = ({String mediaPath, String linkUrl});
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
/// later the session's swap panel) so it survives rebuilds.
class MediaPickerController extends ChangeNotifier {
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
MediaPickerController(
{String mediaPath = '', String linkUrl = '', JellyfinMedia? jellyfin, MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
_jellyfin = jellyfin,
_source = source ??
(linkUrl.isNotEmpty
? MediaKind.link
: mediaPath.isEmpty && jellyfin != null
? MediaKind.jellyfin
: MediaKind.file) {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
@@ -27,6 +36,19 @@ class MediaPickerController extends ChangeNotifier {
final TextEditingController file;
MediaKind _source;
String? _notice;
JellyfinMedia? _jellyfin;
/// The Jellyfin item picked in the browser (kept while another source
/// is shown).
JellyfinMedia? get jellyfin => _jellyfin;
/// Picks a Jellyfin item and switches to the Jellyfin source.
void pickJellyfin(JellyfinMedia? media) {
_jellyfin = media;
_source = MediaKind.jellyfin;
_notice = null;
notifyListeners();
}
MediaKind get source => _source;
set source(MediaKind v) {
@@ -43,11 +65,19 @@ class MediaPickerController extends ChangeNotifier {
notifyListeners();
}
MediaSelection get selection => switch (_source) {
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
};
/// What the visible source has selected; null = nothing.
PickedMedia? get selection {
switch (_source) {
case MediaKind.link:
final url = link.text.trim();
return url.isEmpty ? null : LinkMedia(url);
case MediaKind.file:
final path = file.text.trim();
return path.isEmpty ? null : FileMedia(path);
case MediaKind.jellyfin:
return _jellyfin;
}
}
/// Drops pasted text where it belongs: http(s) into Link, else File.
void pasteText(String text) {
@@ -73,12 +103,17 @@ class MediaPickerController extends ChangeNotifier {
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<MediaSelection>? onChanged;
final ValueChanged<PickedMedia?>? onChanged;
/// Enter in a field.
final VoidCallback? onSubmit;
final VoidCallback? onOpenIntegrations;
final bool enabled;
final bool autofocus;
/// Defaults to [JellyfinAccounts.instance].
final JellyfinAccounts? jellyfin;
/// Jellyfin source only: the browser's "bigger" toggle.
final VoidCallback? onToggleExpanded;
final bool expanded;
const MediaPicker({
super.key,
required this.controller,
@@ -87,6 +122,9 @@ class MediaPicker extends StatefulWidget {
this.onOpenIntegrations,
this.enabled = true,
this.autofocus = false,
this.jellyfin,
this.onToggleExpanded,
this.expanded = false,
});
@override
@@ -175,80 +213,100 @@ class _MediaPickerState extends State<MediaPicker> {
@override
Widget build(BuildContext context) {
final source = _c.source;
final Widget content = switch (source) {
MediaKind.jellyfin => JellyfinPickerSource(
accounts: widget.jellyfin ?? JellyfinAccounts.instance,
selected: _c.jellyfin,
onPick: _c.pickJellyfin,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
controller: _c.link,
autofocus: widget.autofocus,
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
keyboardType: TextInputType.url,
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _pasteLink,
icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'),
),
help: 'A direct http(s) link to a video file; everyone streams it from there.',
notice: _c.notice,
),
MediaKind.file => _FieldRow(
key: const ValueKey('media-file'),
field: TextField(
controller: _c.file,
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse'),
),
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
};
final segments = Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
);
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
),
const SizedBox(height: kGap),
switch (source) {
MediaKind.jellyfin =>
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
controller: _c.link,
autofocus: widget.autofocus,
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
keyboardType: TextInputType.url,
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _pasteLink,
icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'),
),
help: 'A direct http(s) link to a video file; everyone streams it from there.',
notice: _c.notice,
),
MediaKind.file => _FieldRow(
key: const ValueKey('media-file'),
field: TextField(
controller: _c.file,
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse'),
),
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
},
],
),
// The browser fills the height it is given; in an unbounded
// (scrolling) parent it gets a fixed one.
child: LayoutBuilder(builder: (context, box) {
final fill = source == MediaKind.jellyfin;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: fill && box.hasBoundedHeight ? MainAxisSize.max : MainAxisSize.min,
children: [
segments,
const SizedBox(height: kGap),
if (!fill)
content
else if (box.hasBoundedHeight)
Expanded(child: content)
else
SizedBox(height: jellyfinPickerHeight, child: content),
],
);
}),
),
);
}
}
/// Height of the Jellyfin browser when the picker sits in a scroll view.
const jellyfinPickerHeight = 640.0;
class _FieldRow extends StatelessWidget {
final Widget field;
final Widget button;
@@ -283,70 +341,3 @@ class _FieldRow extends StatelessWidget {
);
}
}
/// Where the Jellyfin library grid will go: ghost posters that fill the
/// width, dimmed, with the "coming soon" note on top.
class _JellyfinPlaceholder extends StatelessWidget {
final VoidCallback? onOpenIntegrations;
const _JellyfinPlaceholder({this.onOpenIntegrations});
static const _posterWidth = 140.0;
static const _rows = 2;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
return LayoutBuilder(builder: (context, box) {
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
return Stack(
key: const ValueKey('media-jellyfin'),
alignment: Alignment.center,
children: [
Opacity(
opacity: 0.35,
child: Column(
spacing: 12,
children: [
for (var r = 0; r < _rows; r++)
Row(
spacing: 12,
children: [
for (var c = 0; c < cols; c++)
Expanded(
child: AspectRatio(
aspectRatio: 2 / 3,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
Panel(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
const SizedBox(height: 8),
Text('Link your Jellyfin in Integrations — coming soon',
textAlign: TextAlign.center, style: t.titleSmall),
if (onOpenIntegrations != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
],
],
),
),
],
);
});
}
}
@@ -0,0 +1,114 @@
/// What the media picker selected. Null selection = nothing (hosting
/// without media is allowed).
sealed class PickedMedia {
const PickedMedia();
/// One line for summaries ("Playing: …").
String get label;
}
/// A direct http(s) link everyone streams from.
final class LinkMedia extends PickedMedia {
final String url;
const LinkMedia(this.url);
@override
String get label {
final uri = Uri.tryParse(url);
final segs = uri?.pathSegments.where((s) => s.isNotEmpty).toList() ?? const [];
if (segs.isEmpty) return uri?.host.isNotEmpty == true ? uri!.host : url;
try {
return Uri.decodeComponent(segs.last);
} catch (_) {
return segs.last;
}
}
@override
bool operator ==(Object other) => other is LinkMedia && other.url == url;
@override
int get hashCode => url.hashCode;
@override
String toString() => 'LinkMedia($url)';
}
/// A file on this computer; friends stream it from the host.
final class FileMedia extends PickedMedia {
final String path;
const FileMedia(this.path);
@override
String get label =>
path.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => path);
@override
bool operator ==(Object other) => other is FileMedia && other.path == path;
@override
int get hashCode => path.hashCode;
@override
String toString() => 'FileMedia($path)';
}
/// A movie or episode on a linked Jellyfin server. [serverId] keys the
/// account (`JellyfinAccounts.clientFor`); [baseUrl] is the address it was
/// picked through. No credentials in here: each side authorises its own.
final class JellyfinMedia extends PickedMedia {
final String serverId;
final String baseUrl;
final String itemId;
/// Null = the item's default source.
final String? mediaSourceId;
final String title;
/// "Series · S1E6" for episodes, the year for movies; may be empty.
final String subtitle;
final Duration? runtime;
const JellyfinMedia({
required this.serverId,
required this.baseUrl,
required this.itemId,
this.mediaSourceId,
required this.title,
this.subtitle = '',
this.runtime,
});
@override
String get label => subtitle.isEmpty ? title : '$title — $subtitle';
Map<String, Object?> toJson() => {
'serverId': serverId,
'baseUrl': baseUrl,
'itemId': itemId,
'mediaSourceId': ?mediaSourceId,
'title': title,
'subtitle': subtitle,
if (runtime != null) 'runtimeMs': runtime!.inMilliseconds,
};
static JellyfinMedia? fromJson(Object? j) {
if (j is! Map) return null;
final serverId = j['serverId'], baseUrl = j['baseUrl'], itemId = j['itemId'];
if (serverId is! String || baseUrl is! String || itemId is! String) return null;
final ms = j['runtimeMs'];
return JellyfinMedia(
serverId: serverId,
baseUrl: baseUrl,
itemId: itemId,
mediaSourceId: j['mediaSourceId'] is String ? j['mediaSourceId'] as String : null,
title: j['title'] is String ? j['title'] as String : '',
subtitle: j['subtitle'] is String ? j['subtitle'] as String : '',
runtime: ms is int ? Duration(milliseconds: ms) : null,
);
}
@override
bool operator ==(Object other) =>
other is JellyfinMedia &&
other.serverId == serverId &&
other.itemId == itemId &&
other.mediaSourceId == mediaSourceId;
@override
int get hashCode => Object.hash(serverId, itemId, mediaSourceId);
@override
String toString() => 'JellyfinMedia($serverId/$itemId "$title")';
}