forked from Foxyz/sync_replay_project
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
@@ -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(
|
||||
|
||||
@@ -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")';
|
||||
}
|
||||
Reference in new issue
Block a user