1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag
Library: source switch, selectable cards with a details panel, one merged Continue row, Latest grouped per series without re-releases, Link page with previews and recent links, Files embedded. Player: gear popover in both designs, progress line while controls hide, live hwdec. Settings rebuilt as cards. State marks the host member (proto stays 9, unreleased); hosting shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps the outgoing view's state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -1,11 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia;
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart' show HelloMedia, JellyfinException, JellyfinItem;
|
||||
|
||||
import '../files/ui/files_page.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
||||
import '../jellyfin/browse_scope.dart';
|
||||
import '../jellyfin/details_panel.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/home_page.dart' show libraryIcon, isVideoLibrary;
|
||||
import '../jellyfin/picker_source.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../screens/home/media_picker.dart';
|
||||
import '../screens/session/swap_panel.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'library_link.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
/// What putting up a Jellyfin pick sends: its ref (no URL, no token).
|
||||
/// Throws [FormatException] with a user-facing message when the server is
|
||||
@@ -39,18 +50,46 @@ Future<void> restoreLastPick(
|
||||
if (hadMedia) c.source = source;
|
||||
}
|
||||
|
||||
/// Library (round 1): the media picker as the "put it up" view. Offline
|
||||
/// it browses, with a hint that watching needs a server.
|
||||
class LibraryTab extends StatelessWidget {
|
||||
/// The file playing now, when it is the one put up from here last: the
|
||||
/// server's current media (state JSON) is a plain file (no link, no
|
||||
/// Jellyfin ref) named like [lastPath].
|
||||
String? nowPlayingFile(Map<String, Object?>? media, String lastPath) {
|
||||
if (media == null || media['url'] != null || media['jellyfin'] != null || lastPath.isEmpty) return null;
|
||||
final name = lastPath.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => lastPath);
|
||||
return name == media['name'] ? lastPath : null;
|
||||
}
|
||||
|
||||
/// Offline, nothing goes up; this says why.
|
||||
const offlinePutUpHint = 'Join or host to watch together';
|
||||
|
||||
/// Window width under which the details panel floats over the library.
|
||||
const detailsDockFrom = 1000.0;
|
||||
|
||||
/// The Library tab: a source switch (Jellyfin / Link / Files, fading
|
||||
/// through), the Jellyfin library chips beside it, and the source below.
|
||||
/// Jellyfin cards select on click and open their details in a panel at
|
||||
/// the right (docked from [detailsDockFrom], floating over the library
|
||||
/// below it); a double-click or the panel's button puts it up. Offline
|
||||
/// everything browses and the play buttons say [offlinePutUpHint].
|
||||
class LibraryTab extends StatefulWidget {
|
||||
final MediaPickerController controller;
|
||||
final JellyfinAccounts accounts;
|
||||
final bool online;
|
||||
/// This app hosts the server: its files stream from here directly.
|
||||
final bool isHost;
|
||||
final BrowseStart? jellyfinStart;
|
||||
/// Puts the pick up; returns an error text, or null when sent.
|
||||
final Future<String?> Function(PickedMedia media) onPutUp;
|
||||
final VoidCallback? onPutUpDone;
|
||||
final VoidCallback? onOpenIntegrations;
|
||||
/// Defaults to [RecentLinks.instance].
|
||||
final RecentLinks? recentLinks;
|
||||
final OEmbedGetter oembed;
|
||||
/// The Files source (tests inject one); defaults to [FilesPage].
|
||||
final Widget Function(BuildContext context, {required bool online, required bool relayed,
|
||||
required Future<String?> Function(String path) onPlay, String? nowPlayingPath})? filesBuilder;
|
||||
/// Clock for the Jellyfin Continue row (tests pin it).
|
||||
final DateTime Function() now;
|
||||
const LibraryTab({
|
||||
super.key,
|
||||
required this.controller,
|
||||
@@ -61,19 +100,323 @@ class LibraryTab extends StatelessWidget {
|
||||
this.onPutUpDone,
|
||||
this.jellyfinStart,
|
||||
this.onOpenIntegrations,
|
||||
this.recentLinks,
|
||||
this.oembed = defaultOEmbedGet,
|
||||
this.filesBuilder,
|
||||
this.now = DateTime.now,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LibraryTab> createState() => _LibraryTabState();
|
||||
}
|
||||
|
||||
class _LibraryTabState extends State<LibraryTab> {
|
||||
late final BrowseController _browse = BrowseController(widget.accounts, now: widget.now);
|
||||
/// What the closing panel keeps showing while it slides out.
|
||||
JellyfinSelection? _shown;
|
||||
|
||||
MediaPickerController get _c => widget.controller;
|
||||
String? get _hint => widget.online ? null : offlinePutUpHint;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_c.addListener(_changed);
|
||||
_browse.addListener(_changed);
|
||||
_shown = _c.selected;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LibraryTab old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.controller != widget.controller) {
|
||||
old.controller.removeListener(_changed);
|
||||
widget.controller.addListener(_changed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.removeListener(_changed);
|
||||
_browse.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_c.selected != null) _shown = _c.selected;
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- put up
|
||||
|
||||
Future<String?> _put(PickedMedia media) async {
|
||||
String? error;
|
||||
try {
|
||||
error = await widget.onPutUp(media);
|
||||
} catch (e) {
|
||||
error = '$e';
|
||||
}
|
||||
if (error == null) widget.onPutUpDone?.call();
|
||||
return error;
|
||||
}
|
||||
|
||||
/// A Jellyfin card or episode: a series goes up as its next episode.
|
||||
Future<String?> _putUpJellyfin(JellyfinSelection sel) async {
|
||||
if (!widget.online) return offlinePutUpHint;
|
||||
final account = widget.accounts.account(sel.serverId);
|
||||
final client = widget.accounts.clientFor(sel.serverId);
|
||||
if (account == null || client == null) return 'that Jellyfin server is not linked any more';
|
||||
JellyfinItem? item = sel.item;
|
||||
try {
|
||||
item ??= await client.item(sel.itemId);
|
||||
item = await playableFor(client, item);
|
||||
} on JellyfinException catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
if (item == null) return 'nothing to play in there';
|
||||
return _put(pickFor(account, item));
|
||||
}
|
||||
|
||||
/// Double-click on a card: put it up, errors as a snack bar.
|
||||
Future<void> _doublePutUp(JellyfinSelection sel) async {
|
||||
_c.select(sel);
|
||||
if (!widget.online) return;
|
||||
final error = await _putUpJellyfin(sel);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- paste
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
|
||||
if (e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// A focused text field handles its own paste.
|
||||
final inField =
|
||||
FocusManager.instance.primaryFocus?.context?.findAncestorStateOfType<EditableTextState>() != null;
|
||||
if (inField) return KeyEventResult.ignored;
|
||||
unawaited(_pasteLink());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// Ctrl+V outside a field: an http(s) link goes to the Link source.
|
||||
Future<void> _pasteLink() async {
|
||||
String? text;
|
||||
try {
|
||||
text = (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
|
||||
} catch (_) {}
|
||||
if (text == null || !(text.startsWith('http://') || text.startsWith('https://'))) return;
|
||||
_c.link.text = text;
|
||||
_c.source = MediaKind.link;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- build
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwapPanel(
|
||||
controller: controller,
|
||||
accounts: accounts,
|
||||
isHost: isHost,
|
||||
jellyfinStart: jellyfinStart,
|
||||
onSwap: onPutUp,
|
||||
onDone: onPutUpDone,
|
||||
onOpenIntegrations: onOpenIntegrations,
|
||||
disabledHint: online ? null : 'Join or host to watch together',
|
||||
final source = _c.source;
|
||||
final header = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
// Narrow: icons only, so the chips keep some room.
|
||||
final labels = box.maxWidth >= 620;
|
||||
ButtonSegment<MediaKind> seg(MediaKind k, IconData icon, String label) => ButtonSegment(
|
||||
value: k, icon: Icon(icon), label: labels ? Text(label) : null, tooltip: labels ? null : label);
|
||||
return Row(
|
||||
children: [
|
||||
SegmentedButton<MediaKind>(
|
||||
key: const ValueKey('media-source'),
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
seg(MediaKind.jellyfin, Icons.video_library_outlined, 'Jellyfin'),
|
||||
seg(MediaKind.link, Icons.link, 'Link'),
|
||||
seg(MediaKind.file, Icons.folder_outlined, 'Files'),
|
||||
],
|
||||
selected: {source},
|
||||
onSelectionChanged: (s) => _c.source = s.first,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: easeTime,
|
||||
child: source == MediaKind.jellyfin
|
||||
? _LibraryChips(key: const ValueKey('chips'), controller: _browse)
|
||||
: const SizedBox(height: 40),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
final Widget content = switch (source) {
|
||||
MediaKind.jellyfin => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: JellyfinPickerSource(
|
||||
accounts: widget.accounts,
|
||||
controller: _browse,
|
||||
selected: _c.selected,
|
||||
onSelect: _c.select,
|
||||
onPutUp: _doublePutUp,
|
||||
onOpenIntegrations: widget.onOpenIntegrations,
|
||||
start: widget.jellyfinStart,
|
||||
libraryStrip: false,
|
||||
),
|
||||
),
|
||||
MediaKind.link => LibraryLinkSource(
|
||||
controller: _c,
|
||||
offlineHint: _hint,
|
||||
recents: widget.recentLinks ?? RecentLinks.instance,
|
||||
oembed: widget.oembed,
|
||||
onPutUp: _put,
|
||||
),
|
||||
MediaKind.file => _files(context),
|
||||
};
|
||||
|
||||
final main = Column(
|
||||
children: [
|
||||
header,
|
||||
Expanded(child: fadeThrough(KeyedSubtree(key: ValueKey(source), child: content))),
|
||||
],
|
||||
);
|
||||
|
||||
final open = source == MediaKind.jellyfin && _c.selected != null;
|
||||
final shown = _shown;
|
||||
final details = shown == null
|
||||
? const SizedBox.shrink()
|
||||
: fadeThrough(KeyedSubtree(
|
||||
key: ValueKey('${shown.serverId}/${shown.itemId}'),
|
||||
child: JellyfinDetailsPanel(
|
||||
controller: _browse,
|
||||
selection: shown,
|
||||
offlineHint: _hint,
|
||||
onPutUp: _putUpJellyfin,
|
||||
onClose: () => _c.select(null),
|
||||
),
|
||||
));
|
||||
Widget slide(Widget child) => AnimatedSlide(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
offset: open ? Offset.zero : const Offset(1, 0),
|
||||
child: AnimatedOpacity(duration: easeTime, curve: ease, opacity: open ? 1 : 0, child: child),
|
||||
);
|
||||
|
||||
return Focus(
|
||||
key: const ValueKey('library-tab'),
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
if (box.maxWidth >= detailsDockFrom) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: main),
|
||||
ClipRect(
|
||||
child: AnimatedContainer(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
width: open ? detailsPanelWidth : 0,
|
||||
child: OverflowBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
minWidth: detailsPanelWidth,
|
||||
maxWidth: detailsPanelWidth,
|
||||
child: slide(details),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: main),
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: detailsPanelWidth,
|
||||
child: IgnorePointer(
|
||||
ignoring: !open,
|
||||
child: slide(Material(elevation: 16, child: details)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _files(BuildContext context) {
|
||||
final relayed = !widget.isHost;
|
||||
Future<String?> play(String path) => _put(FileMedia(path));
|
||||
return SignalBuilder(builder: (context) {
|
||||
final session = SessionState.instance;
|
||||
final now = widget.online
|
||||
? nowPlayingFile(session.coordinator.serverState.value?.media, session.lastForm.mediaPath)
|
||||
: null;
|
||||
final b = widget.filesBuilder;
|
||||
if (b != null) {
|
||||
return b(context, online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
}
|
||||
return FilesPage(online: widget.online, relayed: relayed, onPlay: play, nowPlayingPath: now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The current server's video libraries as chips in the Library header; a
|
||||
/// click opens one (the chip of the library open is selected).
|
||||
class _LibraryChips extends StatelessWidget {
|
||||
final BrowseController controller;
|
||||
const _LibraryChips({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([controller, controller.accounts]),
|
||||
builder: (context, _) {
|
||||
final accounts = controller.accounts;
|
||||
final account = accounts.loaded ? controller.account : null;
|
||||
final client = account == null ? null : accounts.clientFor(account.serverId);
|
||||
if (account == null || client == null) return const SizedBox(height: 40);
|
||||
final stack = controller.stack;
|
||||
final openId = stack.length > 1 && stack[1] is LibraryRoute ? (stack[1] as LibraryRoute).folder.id : null;
|
||||
return FutureBuilder<List<JellyfinItem>>(
|
||||
future: accounts.cached(account.serverId, 'views', client.userViews),
|
||||
builder: (context, snap) {
|
||||
final views = (snap.data ?? const <JellyfinItem>[]).where(isVideoLibrary).toList();
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: HScroll(
|
||||
builder: (c) => ListView.separated(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: views.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final v = views[i];
|
||||
return Center(
|
||||
child: ChoiceChip(
|
||||
key: ValueKey('library-${v.id}'),
|
||||
avatar: Icon(libraryIcon(v), size: 16),
|
||||
showCheckmark: false,
|
||||
label: Text(v.name),
|
||||
selected: v.id == openId,
|
||||
onSelected: (_) => v.id == openId
|
||||
? controller.popTo(0)
|
||||
: controller.openTop(LibraryRoute(v)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user