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:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:58:12 +03:00
1 parent 99cda7ed04
commit d092ea371a
54 files changed
+4963 -1825

No files matched your search

@@ -24,6 +24,7 @@ const _keyAccounts = 'accounts';
const _keyDefault = 'defaultServer';
const _keyLastPick = 'lastPick';
const _keyBrowsePrefix = 'browse:';
const _keyDismissedPrefix = 'dismissed:';
/// One signed-in user on one server.
class JellyfinAccount {
@@ -484,6 +485,38 @@ class JellyfinAccounts extends ChangeNotifier {
}
}
/// Continue-row entries removed by hand on [serverId] (key -> when), so a
/// next-up series (which Jellyfin cannot drop) stays out until it is
/// played again. Keys are `continueKey`s.
Map<String, DateTime> continueDismissed(String serverId) {
try {
final v = _store.get('$_keyDismissedPrefix$serverId');
final j = v is String ? _tryJson(v) : null;
if (j is! Map) return const {};
return {
for (final e in j.entries)
if (e.key is String && e.value is int)
e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int, isUtc: true),
};
} catch (_) {
return const {};
}
}
/// Keeps [key] out of the Continue row on [serverId] until it is played
/// after [at] (default now). The newest 200 are kept.
Future<void> dismissContinue(String serverId, String key, {DateTime? at}) async {
final all = {...continueDismissed(serverId), key: (at ?? DateTime.now()).toUtc()};
final newest = all.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
try {
await _store.put('$_keyDismissedPrefix$serverId', jsonEncode({
for (final e in newest.take(200)) e.key: e.value.millisecondsSinceEpoch,
}));
} catch (e) {
debugPrint('save dismissed continue failed: $e');
}
}
// --------------------------------------------------------------- cache
/// Short-lived memo for browse calls, so going back does not refetch.
+183 -42
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
@@ -20,14 +23,17 @@ final class HomeRoute extends BrowseRoute {
String get key => 'home';
}
/// A library or folder grid.
/// A library or folder grid, optionally opened with a sort ("Show all"
/// next to Latest opens it newest first).
final class LibraryRoute extends BrowseRoute {
final JellyfinItem folder;
const LibraryRoute(this.folder);
/// A `LibrarySort` name; null = by name.
final String? sort;
const LibraryRoute(this.folder, {this.sort});
@override
String get label => folder.name;
@override
String get key => 'lib:${folder.id}';
String get key => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
}
final class SeriesRoute extends BrowseRoute {
@@ -41,18 +47,6 @@ final class SeriesRoute extends BrowseRoute {
String get key => 'series:${series.id}';
}
/// Movie / episode / video detail.
final class DetailRoute extends BrowseRoute {
final JellyfinItem item;
const DetailRoute(this.item);
@override
String get label => item.kind == JellyfinItemKind.episode
? (item.episodeLabel ?? item.name)
: item.name;
@override
String get key => 'item:${item.id}';
}
/// A route stack as JSON for [JellyfinAccounts.saveBrowseLocation]: only
/// ids, names and the fields the pages start from (each page loads the
/// full item itself).
@@ -60,18 +54,17 @@ List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
for (final r in stack)
switch (r) {
HomeRoute() => {'r': 'home'},
LibraryRoute(:final folder) => {'r': 'lib', ..._itemJson(folder)},
LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort},
SeriesRoute(:final series, :final seasonId) => {
'r': 'series',
..._itemJson(series),
'seasonId': ?seasonId,
},
DetailRoute(:final item) => {'r': 'item', ..._itemJson(item)},
}
];
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when
/// [raw] is not a stack.
/// [raw] is not a stack. Entries it no longer knows are skipped.
List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (raw is! List) return null;
final out = <BrowseRoute>[const HomeRoute()];
@@ -79,11 +72,10 @@ List<BrowseRoute>? decodeBrowseStack(Object? raw) {
if (e is! Map) return null;
final item = _itemFrom(e);
final route = switch (e['r']) {
'home' => null,
'lib' when item != null => LibraryRoute(item),
'lib' when item != null =>
LibraryRoute(item, sort: e['sort'] is String ? e['sort'] as String : null),
'series' when item != null =>
SeriesRoute(item, seasonId: e['seasonId'] is String ? e['seasonId'] as String : null),
'item' when item != null => DetailRoute(item),
_ => null,
};
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
@@ -141,46 +133,181 @@ class BrowseStart {
}
}
/// The card selected in the library (details panel): an item on a server.
/// [item] is what the card had, when known (the panel loads it in full).
class JellyfinSelection {
final String serverId;
final String itemId;
final JellyfinItem? item;
const JellyfinSelection(this.serverId, this.itemId, [this.item]);
@override
bool operator ==(Object other) =>
other is JellyfinSelection && other.serverId == serverId && other.itemId == itemId;
@override
int get hashCode => Object.hash(serverId, itemId);
}
/// Browser location shared by the browser, the library chips in the
/// Library header and the details panel: the server shown, its route
/// stack (saved per server on every move), a data version that bumps after
/// watched-state changes (rows reload), and the keys in the Continue row.
class BrowseController extends ChangeNotifier {
final JellyfinAccounts accounts;
/// Clock for the Continue row's age cut-off (tests pin it).
final DateTime Function() now;
BrowseController(this.accounts, {this.now = DateTime.now});
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
bool _started = false;
int _version = 0;
Set<String> _continueKeys = const {};
List<BrowseRoute> get stack => _stack;
int get version => _version;
/// [continueKey]s of the cards in the Continue row now.
Set<String> get continueKeys => _continueKeys;
JellyfinAccount? get account =>
(_serverId == null ? null : accounts.account(_serverId!)) ?? accounts.defaultAccount;
String? get serverId => account?.serverId;
/// First use (accounts loaded): [start], else the server of [serverHint]
/// at its remembered location, else the default server's.
void start({BrowseStart? start, String? serverHint}) {
if (_started) return;
_started = true;
if (start != null && accounts.account(start.serverId) != null) {
_serverId = start.serverId;
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
_remember();
} else {
_serverId = serverHint != null && accounts.account(serverHint) != null
? serverHint
: accounts.defaultServerId;
_stack = _restored(_serverId);
}
}
List<BrowseRoute> _restored(String? serverId) =>
(serverId == null ? null : decodeBrowseStack(accounts.browseLocation(serverId))) ??
const [HomeRoute()];
void _remember() {
final id = serverId;
if (id != null) unawaited(accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
}
void switchServer(String serverId) {
if (serverId == this.serverId) return;
_serverId = serverId;
_stack = _restored(serverId);
_continueKeys = const {};
notifyListeners();
}
void open(BrowseRoute route) {
if (_stack.last.key == route.key) return;
_stack = [..._stack, route];
_remember();
notifyListeners();
}
/// Home, then [route] (library chips: a library is a top-level place).
void openTop(BrowseRoute route) {
_stack = [const HomeRoute(), route];
_remember();
notifyListeners();
}
bool back() {
if (_stack.length <= 1) return false;
_stack = _stack.sublist(0, _stack.length - 1);
_remember();
notifyListeners();
return true;
}
void popTo(int index) {
_stack = _stack.sublist(0, index + 1);
_remember();
notifyListeners();
}
/// Watched state changed on the server: drop its cached lists and let
/// the rows reload.
void dataChanged() {
final id = serverId;
if (id != null) accounts.invalidate(id);
_version++;
notifyListeners();
}
set continueKeys(Set<String> keys) {
if (setEquals(keys, _continueKeys)) return;
_continueKeys = keys;
// Set while building a row: tell listeners after the frame.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed) notifyListeners();
});
}
bool _disposed = false;
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
/// What every browser page needs, provided by [JellyfinBrowser].
class BrowseScope extends InheritedWidget {
final JellyfinAccounts accounts;
final JellyfinAccount account;
final JellyfinClient client;
/// Item id of the current pick on this server, highlighted everywhere.
final BrowseController controller;
/// Item id of the selected card on this server, highlighted everywhere.
final String? selectedItemId;
final ValueChanged<JellyfinItem> onPick;
final ValueChanged<BrowseRoute> onOpen;
/// Hover / focus: the inspector pane follows it.
final ValueChanged<JellyfinItem> onPeek;
/// Click: select (details panel).
final ValueChanged<JellyfinItem> onSelect;
/// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> onPutUp;
/// Home shows the library chips itself (the Library tab has them in its
/// header instead).
final bool libraryStrip;
const BrowseScope({
super.key,
required this.accounts,
required this.account,
required this.client,
required this.controller,
required this.selectedItemId,
required this.onPick,
required this.onOpen,
required this.onPeek,
required this.onSelect,
required this.onPutUp,
this.libraryStrip = true,
required super.child,
});
static BrowseScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<BrowseScope>()!;
void onOpen(BrowseRoute route) => controller.open(route);
/// Cached browse call on this server.
Future<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client));
/// Click / Enter on a card: playable items are picked, containers open.
/// Click / Enter on a card: series, movies and episodes are selected,
/// seasons open their series, libraries and folders open.
void activate(JellyfinItem item) {
if (isPlayable(item)) {
onPick(item);
if (isSelectable(item)) {
onSelect(item);
return;
}
switch (item.kind) {
case JellyfinItemKind.series:
onOpen(SeriesRoute(item));
case JellyfinItemKind.season:
final sid = item.seriesId;
if (sid != null) {
@@ -193,7 +320,7 @@ class BrowseScope extends InheritedWidget {
}
}
/// Opens the series of an episode (or the item itself if it has none).
/// Opens the series of an episode.
void openSeriesOf(JellyfinItem episode) {
final sid = episode.seriesId;
if (sid == null) return;
@@ -206,23 +333,28 @@ class BrowseScope extends InheritedWidget {
bool updateShouldNotify(BrowseScope old) =>
old.selectedItemId != selectedItemId ||
old.account != account ||
old.client != client;
old.client != client ||
old.libraryStrip != libraryStrip;
}
/// Runs [load] once (again on [retry]) and shows [builder] with the result,
/// a spinner meanwhile, and a compact error with Retry on failure.
/// Runs [load] once (again on [retry] and when [version] changes) and
/// shows [builder] with the result, a spinner meanwhile, and a compact
/// error with Retry on failure. A reload for a new [version] keeps the old
/// result on screen until the new one arrives.
class Loader<T> extends StatefulWidget {
final Future<T> Function() load;
final Widget Function(BuildContext context, T data) builder;
final Widget? loading;
/// When false the error collapses to nothing (optional sections).
final bool showError;
final Object? version;
const Loader({
super.key,
required this.load,
required this.builder,
this.loading,
this.showError = true,
this.version,
});
@override
@@ -231,22 +363,31 @@ class Loader<T> extends StatefulWidget {
class _LoaderState<T> extends State<Loader<T>> {
late Future<T> _future = widget.load();
(T,)? _last;
void _retry() => setState(() => _future = widget.load());
@override
void didUpdateWidget(Loader<T> old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = widget.load();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: _future,
builder: (context, snap) {
if (snap.hasData) return widget.builder(context, snap.data as T);
if (snap.hasData || (snap.connectionState == ConnectionState.done && !snap.hasError)) {
_last = (snap.data as T,);
return widget.builder(context, snap.data as T);
}
if (snap.hasError) {
if (!widget.showError) return const SizedBox.shrink();
return LoadError(error: snap.error!, onRetry: _retry);
}
if (snap.connectionState == ConnectionState.done) {
return widget.builder(context, snap.data as T);
}
final last = _last;
if (last != null) return widget.builder(context, last.$1);
return widget.loading ??
const Padding(
padding: EdgeInsets.all(32),
+124 -172
View File
@@ -2,52 +2,51 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'detail_page.dart';
import 'format.dart';
import 'home_page.dart';
import 'inspector.dart';
import 'library_page.dart';
import 'search_results.dart';
import 'series_page.dart';
import 'sign_in_panel.dart';
/// Browser width from which the inspector pane shows on the right.
const inspectorFrom = 1400.0;
const inspectorWidth = 340.0;
/// Library browser for the linked Jellyfin servers: server switcher,
/// breadcrumbs, search, and a back stack of pages (Home rows, library
/// grids, series, details). Pages below the top stay alive, so going back
/// keeps scroll position and loaded pages.
/// grids, series). Pages below the top stay alive, so going back keeps
/// scroll position and loaded pages. Where it is lives in a
/// [BrowseController] (shared with the Library header's library chips and
/// the details panel; one of its own when none is given).
///
/// Keyboard: arrows move between cards, Enter picks / opens, I opens
/// details, Esc / Backspace / Alt+Left go back, / or Ctrl+F searches.
/// A click on a card selects it ([onSelect]; libraries and folders open),
/// a double-click puts it up ([onPutUp]).
///
/// Keyboard: arrows move between cards, Enter selects / opens, Esc /
/// Backspace / Alt+Left go back, / or Ctrl+F searches.
class JellyfinBrowser extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
/// Shown when given: toggles a bigger browser (the host hides its form).
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseController? controller;
/// Highlighted card.
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final Duration searchDebounce;
/// Opens here instead of the remembered location (the series of the
/// episode playing, in the session's swap panel).
/// episode playing, after "Change video").
final BrowseStart? start;
/// Library chips on Home (off when the Library header shows them).
final bool libraryStrip;
const JellyfinBrowser({
this.start,
super.key,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onToggleExpanded,
this.expanded = false,
this.onOpenIntegrations,
this.libraryStrip = true,
this.searchDebounce = const Duration(milliseconds: 300),
});
@@ -56,45 +55,23 @@ class JellyfinBrowser extends StatefulWidget {
}
class _JellyfinBrowserState extends State<JellyfinBrowser> {
String? _serverId;
List<BrowseRoute> _stack = const [HomeRoute()];
BrowseController? _own;
final _search = TextEditingController();
final _searchFocus = FocusNode(debugLabel: 'jf-search');
final _body = FocusScopeNode(debugLabel: 'jf-body');
final _peek = ValueNotifier<JellyfinItem?>(null);
String _term = '';
Timer? _debounce;
int _refresh = 0;
JellyfinAccounts get _accounts => widget.accounts;
BrowseController get _c => widget.controller ?? (_own ??= BrowseController(widget.accounts));
@override
void initState() {
super.initState();
final start = widget.start;
if (start != null && _accounts.account(start.serverId) != null) {
_serverId = start.serverId;
_stack = start.stack.isEmpty ? const [HomeRoute()] : start.stack;
_remember();
} else {
_serverId = widget.selected != null && _accounts.account(widget.selected!.serverId) != null
? widget.selected!.serverId
: _accounts.defaultServerId;
_stack = _restored(_serverId);
}
_c.start(start: widget.start, serverHint: widget.selected?.serverId);
_accounts.addListener(_changed);
}
/// The remembered location on [serverId], else Home.
List<BrowseRoute> _restored(String? serverId) =>
(serverId == null ? null : decodeBrowseStack(_accounts.browseLocation(serverId))) ??
const [HomeRoute()];
/// Every navigation is saved per server (Hive), so the next browser
/// (the Library tab, next start) opens where this one was.
void _remember() {
final id = _account?.serverId;
if (id != null) unawaited(_accounts.saveBrowseLocation(id, encodeBrowseStack(_stack)));
_c.addListener(_moved);
}
@override
@@ -104,16 +81,22 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
old.accounts.removeListener(_changed);
widget.accounts.addListener(_changed);
}
final was = old.controller ?? _own;
if (was != _c) {
was?.removeListener(_moved);
_c.addListener(_moved);
}
}
@override
void dispose() {
_accounts.removeListener(_changed);
_c.removeListener(_moved);
_own?.dispose();
_debounce?.cancel();
_search.dispose();
_searchFocus.dispose();
_body.dispose();
_peek.dispose();
super.dispose();
}
@@ -121,44 +104,33 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (mounted) setState(() {});
}
JellyfinAccount? get _account =>
(_serverId == null ? null : _accounts.account(_serverId!)) ?? _accounts.defaultAccount;
void _switchServer(String serverId) {
if (serverId == _account?.serverId) return;
/// Any move (here, from the library chips or a server switch) ends a
/// search.
int _depth = 0;
String? _server;
void _moved() {
if (!mounted) return;
final moved = _c.stack.length != _depth || _c.serverId != _server;
_depth = _c.stack.length;
_server = _c.serverId;
setState(() {
_serverId = serverId;
_stack = _restored(serverId);
_peek.value = null;
_clearSearch();
if (moved) _clearSearch();
});
}
JellyfinAccount? get _account => _c.account;
// ------------------------------------------------------------- navigation
void _open(BrowseRoute route) {
setState(() {
_clearSearch();
if (_stack.last.key != route.key) _stack = [..._stack, route];
});
_remember();
_body.requestFocus();
}
bool _back() {
if (_stack.length <= 1) return false;
setState(() => _stack = _stack.sublist(0, _stack.length - 1));
_remember();
_body.requestFocus();
return true;
final ok = _c.back();
if (ok) _body.requestFocus();
return ok;
}
void _popTo(int index) {
setState(() {
_clearSearch();
_stack = _stack.sublist(0, index + 1);
});
_remember();
setState(_clearSearch);
_c.popTo(index);
_body.requestFocus();
}
@@ -175,12 +147,6 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
_term = '';
}
void _pick(JellyfinItem item) {
final account = _account;
if (account == null) return;
widget.onPick(pickFor(account, item));
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
if (e is! KeyDownEvent && e is! KeyRepeatEvent) return KeyEventResult.ignored;
final keys = HardwareKeyboard.instance;
@@ -217,10 +183,9 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _page(BrowseRoute r) => switch (r) {
HomeRoute() => const JellyfinHomePage(),
LibraryRoute(:final folder) => JellyfinLibraryPage(folder: folder),
LibraryRoute(:final folder, :final sort) => JellyfinLibraryPage(folder: folder, sort: sort),
SeriesRoute(:final series, :final seasonId) =>
JellyfinSeriesPage(series: series, seasonId: seasonId),
DetailRoute(:final item) => JellyfinDetailPage(item: item),
};
@override
@@ -233,15 +198,16 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
final status = _accounts.statusOf(account.serverId);
final sel = widget.selected;
final searching = _term.isNotEmpty;
final stack = _c.stack;
final pages = Stack(
children: [
for (var i = 0; i < _stack.length; i++)
for (var i = 0; i < stack.length; i++)
Visibility(
key: ValueKey('$_refresh:${account.serverId}:$i:${_stack[i].key}'),
visible: !searching && i == _stack.length - 1,
key: ValueKey('$_refresh:${account.serverId}:$i:${stack[i].key}'),
visible: !searching && i == stack.length - 1,
maintainState: true,
child: _page(_stack[i]),
child: _page(stack[i]),
),
if (searching) JellyfinSearchResults(key: ValueKey('$_refresh:$_term'), term: _term),
],
@@ -251,10 +217,11 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
accounts: _accounts,
account: account,
client: client,
controller: _c,
selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null,
onPick: _pick,
onOpen: _open,
onPeek: (item) => _peek.value = item,
onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)),
onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)),
libraryStrip: widget.libraryStrip,
child: Focus(
onKeyEvent: _onKey,
child: Column(
@@ -265,21 +232,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
if (status == JellyfinAccountStatus.expired || status == JellyfinAccountStatus.offline)
_StatusBanner(accounts: _accounts, account: account, status: status),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(builder: (context, box) {
final inspector = box.maxWidth >= inspectorFrom;
final body = FocusScope(node: _body, child: pages);
if (!inspector) return body;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: body),
const SizedBox(width: 16),
SizedBox(width: inspectorWidth, child: JellyfinInspector(item: _peek)),
],
);
}),
),
Expanded(child: FocusScope(node: _body, child: pages)),
],
),
),
@@ -289,9 +242,10 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
Widget _header(BuildContext context, JellyfinAccount account) {
final theme = Theme.of(context);
final t = theme.textTheme;
final stack = _c.stack;
final crumbs = <Widget>[];
for (var i = 0; i < _stack.length; i++) {
final last = i == _stack.length - 1 && _term.isEmpty;
for (var i = 0; i < stack.length; i++) {
final last = i == stack.length - 1 && _term.isEmpty;
if (i > 0) {
crumbs.add(const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
@@ -299,7 +253,7 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
));
}
final label = Text(
_stack[i].label,
stack[i].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
@@ -328,73 +282,68 @@ class _JellyfinBrowserState extends State<JellyfinBrowser> {
style: t.titleSmall?.copyWith(fontWeight: FontWeight.w600))));
}
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
onSelect: _switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
const SizedBox(width: 4),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: _stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
return LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 640;
return Row(
children: [
_ServerSwitcher(
accounts: _accounts,
current: account,
compact: narrow,
onSelect: _c.switchServer,
onOpenIntegrations: widget.onOpenIntegrations,
),
IconButton(
tooltip: 'Back (Esc)',
visualDensity: VisualDensity.compact,
onPressed: stack.length > 1 ? _back : null,
icon: const Icon(Icons.arrow_back, size: 20),
),
const SizedBox(width: 4),
Expanded(child: Row(children: crumbs)),
const SizedBox(width: 8),
SizedBox(
width: narrow ? 170 : 260,
child: TextField(
key: const ValueKey('jf-search'),
controller: _search,
focusNode: _searchFocus,
onChanged: _onSearchChanged,
onSubmitted: (v) {
_debounce?.cancel();
setState(() => _term = v.trim());
},
decoration: InputDecoration(
isDense: true,
hintText: 'Search ( / )',
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
suffixIcon: ListenableBuilder(
listenable: _search,
builder: (context, _) => _search.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Clear (Esc)',
iconSize: 16,
onPressed: () => setState(_clearSearch),
icon: const Icon(Icons.close),
),
),
),
),
),
),
IconButton(
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
if (widget.onToggleExpanded != null)
IconButton(
key: const ValueKey('jf-expand'),
tooltip: widget.expanded ? 'Show the host form' : 'Bigger browser',
tooltip: 'Refresh',
visualDensity: VisualDensity.compact,
onPressed: widget.onToggleExpanded,
icon: Icon(widget.expanded ? Icons.close_fullscreen : Icons.open_in_full, size: 18),
onPressed: () {
_accounts.invalidate(account.serverId);
setState(() => _refresh++);
},
icon: const Icon(Icons.refresh, size: 20),
),
],
);
],
);
});
}
}
@@ -404,11 +353,14 @@ class _ServerSwitcher extends StatelessWidget {
final JellyfinAccount current;
final ValueChanged<String> onSelect;
final VoidCallback? onOpenIntegrations;
/// Narrow: the name is shorter.
final bool compact;
const _ServerSwitcher({
required this.accounts,
required this.current,
required this.onSelect,
this.onOpenIntegrations,
this.compact = false,
});
@override
@@ -420,7 +372,7 @@ class _ServerSwitcher extends StatelessWidget {
StatusDot(status: accounts.statusOf(current.serverId)),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200),
constraints: BoxConstraints(maxWidth: compact ? 110 : 200),
child: Text(current.displayName,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleSmall),
),
+118 -120
View File
@@ -2,12 +2,12 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
import '../shell/motion.dart' show HScroll;
enum CardShape {
/// 2:3 poster (movies, series, latest).
@@ -49,20 +49,29 @@ IconData kindIcon(JellyfinItem item) => switch (item.kind) {
_ => Icons.movie_outlined,
};
/// Two clicks on the same card within this time put it up.
const doubleClickTime = Duration(milliseconds: 350);
/// Poster or thumb card: image with progress bar and state badges, title
/// and one meta line. Click / Enter activates (pick or open), hover and
/// focus feed the inspector, "i" or the hover button opens the details.
/// and one meta line. Click / Enter selects it (details panel; libraries
/// and folders open), a second click soon after puts it up. [display]
/// gives the picture and title when they are not [item]'s own (a series
/// card for a new episode), [subtitle] replaces the meta line.
class MediaCard extends StatefulWidget {
final JellyfinItem item;
final JellyfinItem? display;
final String? subtitle;
final CardShape shape;
/// Rows mixing series (Continue watching, Next up): series name on top,
/// "S2E3 · 12 min left" below.
/// Rows mixing series (Continue): series name on top, "S2E3 · 12 min
/// left" below.
final bool seriesFirst;
final bool autofocus;
const MediaCard({
super.key,
required this.item,
required this.shape,
this.display,
this.subtitle,
this.seriesFirst = false,
this.autofocus = false,
});
@@ -74,18 +83,20 @@ class MediaCard extends StatefulWidget {
class _MediaCardState extends State<MediaCard> {
bool _hover = false;
bool _focus = false;
DateTime? _lastTap;
JellyfinItem get item => widget.item;
void _details(BrowseScope scope) => scope.onOpen(DetailRoute(item));
KeyEventResult _onKey(BrowseScope scope, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.keyI && isPlayable(item)) {
_details(scope);
return KeyEventResult.handled;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime && isSelectable(item)) {
_lastTap = null;
scope.onPutUp(item);
return;
}
return KeyEventResult.ignored;
scope.activate(item);
}
@override
@@ -93,10 +104,11 @@ class _MediaCardState extends State<MediaCard> {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final shown = widget.display ?? item;
final selected = scope.selectedItemId == item.id;
final played = item.userData.played;
final progress = progressOf(item);
final unplayed = item.userData.unplayedItemCount;
final unplayed = shown.userData.unplayedItemCount;
final ring = selected
? scheme.primary
: _focus
@@ -106,20 +118,21 @@ class _MediaCardState extends State<MediaCard> {
final image = AspectRatio(
aspectRatio: widget.shape.aspect,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
duration: const Duration(milliseconds: 160),
curve: Curves.fastEaseInToSlowEaseOut,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ring, width: 2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ring, width: 3),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(7),
child: Stack(
fit: StackFit.expand,
children: [
JellyfinImage(
client: scope.client,
image: cardImage(item, widget.shape),
icon: kindIcon(item),
image: cardImage(shown, widget.shape),
icon: kindIcon(shown),
),
if (_hover || _focus)
const ColoredBox(color: Color(0x22FFFFFF)),
@@ -141,7 +154,7 @@ class _MediaCardState extends State<MediaCard> {
? _Badge(color: scheme.primary, child: Icon(Icons.check, size: 14, color: scheme.onPrimary))
: played
? const _Badge(color: Color(0xCC1B5E20), child: Icon(Icons.check, size: 14, color: Colors.white))
: unplayed != null && unplayed > 0 && !isPlayable(item)
: unplayed != null && unplayed > 0 && !isPlayable(shown) && widget.subtitle == null
? _Badge(
color: scheme.primaryContainer,
child: Text('$unplayed',
@@ -151,78 +164,55 @@ class _MediaCardState extends State<MediaCard> {
color: scheme.onPrimaryContainer)))
: const SizedBox.shrink(),
),
if (_hover && isPlayable(item))
Positioned(
top: 4,
left: 4,
child: IconButton.filledTonal(
key: ValueKey('card-info-${item.id}'),
tooltip: 'Details (I)',
visualDensity: VisualDensity.compact,
iconSize: 16,
onPressed: () => _details(scope),
icon: const Icon(Icons.info_outline),
),
),
],
),
),
),
);
final title = cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item);
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, e) => _onKey(scope, e),
child: Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(8),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => scope.activate(item),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(item);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(item);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
final title = widget.display != null ? shown.name : cardTitle(item, seriesFirst: widget.seriesFirst);
final sub = widget.subtitle ??
(widget.seriesFirst && item.kind == JellyfinItemKind.episode
? [cardSubtitle(item), item.name].where((s) => s.isNotEmpty).join(' · ')
: cardSubtitle(item));
return Semantics(
button: true,
selected: selected,
label: title,
child: InkWell(
key: ValueKey('card-${item.id}'),
autofocus: widget.autofocus,
borderRadius: BorderRadius.circular(10),
focusColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
image,
const SizedBox(height: 6),
SizedBox(
height: cardTextHeight - 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: selected ? scheme.primary : null)),
Text(sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
),
],
),
),
],
),
),
);
@@ -275,18 +265,25 @@ class CardGridDelegate extends SliverGridDelegate {
bool shouldRelayout(CardGridDelegate old) => old.shape != shape || old.minWidth != minWidth;
}
/// A titled horizontal row of cards sized to fill the width; arrows (on
/// hover) page through it. Lazy: only visible cards are built.
/// One card of a [MediaRow]: see [MediaCard].
typedef CardEntry = ({JellyfinItem item, JellyfinItem? display, String? subtitle});
List<CardEntry> cardEntries(Iterable<JellyfinItem> items) =>
[for (final i in items) (item: i, display: null, subtitle: null)];
/// A titled horizontal row of cards sized to fill the width; mouse drag
/// and the wheel move it ([HScroll]), arrows (on hover) page through it.
/// Lazy: only visible cards are built.
class MediaRow extends StatefulWidget {
final String title;
final List<JellyfinItem> items;
final List<CardEntry> entries;
final CardShape shape;
final bool seriesFirst;
final Widget? trailing;
const MediaRow({
super.key,
required this.title,
required this.items,
required this.entries,
required this.shape,
this.seriesFirst = false,
this.trailing,
@@ -315,44 +312,38 @@ class _MediaRowState extends State<MediaRow> {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final entries = widget.entries;
return MouseRegion(
onEnter: (_) => setState(() => _hover = true),
onExit: (_) => setState(() => _hover = false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(widget.title, style: t.titleMedium),
const SizedBox(width: 8),
Text('${widget.items.length}', style: t.bodySmall?.copyWith(color: Colors.grey)),
const Spacer(),
?widget.trailing,
],
),
),
SectionHeader(widget.title, count: '${entries.length}', trailing: widget.trailing),
LayoutBuilder(builder: (context, box) {
final fit = fitColumns(box.maxWidth, widget.shape.minWidth);
final h = fit.itemWidth / widget.shape.aspect + cardTextHeight;
final overflow = widget.items.length > fit.cols;
final overflow = entries.length > fit.cols;
return SizedBox(
height: h,
child: Stack(
children: [
ListView.separated(
HScroll(
controller: _scroll,
scrollDirection: Axis.horizontal,
itemCount: widget.items.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: widget.items[i],
shape: widget.shape,
seriesFirst: widget.seriesFirst,
builder: (c) => ListView.separated(
controller: c,
scrollDirection: Axis.horizontal,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: cardGap),
itemBuilder: (context, i) => SizedBox(
width: fit.itemWidth,
child: MediaCard(
item: entries[i].item,
display: entries[i].display,
subtitle: entries[i].subtitle,
shape: widget.shape,
seriesFirst: widget.seriesFirst,
),
),
),
),
@@ -410,12 +401,19 @@ class SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 8, top: 4),
child: Row(
children: [
Text(title, style: t.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
const Spacer(),
Expanded(
child: Row(
children: [
Flexible(
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (count != null) ...[
const SizedBox(width: 8),
Text(count!, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
],
),
),
?trailing,
],
),
@@ -1,180 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Movie / episode detail: art, title, meta, overview, the pick button and
/// what is in the file (video, audio and subtitle tracks).
class JellyfinDetailPage extends StatelessWidget {
final JellyfinItem item;
const JellyfinDetailPage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<JellyfinItem>(
key: ValueKey('detail-${item.id}-${scope.account.serverId}'),
load: () => scope.load('item:${item.id}', (c) => c.item(item.id)),
builder: (context, full) => _Detail(item: full),
);
}
}
class _Detail extends StatelessWidget {
final JellyfinItem item;
const _Detail({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final theme = Theme.of(context);
final t = theme.textTheme;
final episode = item.kind == JellyfinItemKind.episode;
final selected = scope.selectedItemId == item.id;
final meta = [
?item.episodeLabel,
if (item.productionYear != null) '${item.productionYear}',
if (item.runTime != null) formatRuntime(item.runTime!),
?timeLeft(item),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (item.userData.played) 'Watched',
].join(' · ');
final art = episode
? AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client,
image: item.ownImage(JellyfinImageType.primary) ??
item.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined),
),
)
: AspectRatio(
aspectRatio: 2 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: JellyfinImage(
client: scope.client, image: item.image(JellyfinImageType.primary)),
),
);
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
TextButton.icon(
key: const ValueKey('detail-series'),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
onPressed: () => scope.openSeriesOf(item),
icon: const Icon(Icons.live_tv_outlined, size: 18),
label: Text(item.seriesName!, style: t.titleMedium),
),
SelectableText(item.name, style: t.headlineSmall),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
if (progressOf(item) > 0) ...[
const SizedBox(height: 8),
SizedBox(
width: 240,
child: LinearProgressIndicator(value: progressOf(item), minHeight: 4)),
],
const SizedBox(height: 16),
FilledButton.icon(
key: const ValueKey('detail-pick'),
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow),
label: Text(selected ? 'Picked' : 'Pick to watch'),
),
if (item.overview != null) ...[
const SizedBox(height: 16),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: SelectableText(item.overview!, style: t.bodyMedium),
),
],
const SizedBox(height: 16),
_Tracks(item: item),
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: LayoutBuilder(builder: (context, box) {
final wide = box.maxWidth >= 760;
final artWidth = episode ? (box.maxWidth * 0.32).clamp(280.0, 480.0) : 240.0;
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.topLeft,
child: SizedBox(width: episode ? 320 : 180, child: art)),
const SizedBox(height: 16),
info,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: artWidth, child: art),
const SizedBox(width: 24),
Expanded(child: info),
],
);
}),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 84,
child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
@@ -0,0 +1,520 @@
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../shell/motion.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime, kindIcon;
import 'format.dart';
import 'home_rows.dart' show continueKey;
import 'images.dart';
/// Width of the details panel (docked or floating).
const detailsPanelWidth = 400.0;
/// What putting up [item] plays: itself when playable; for a series its
/// next episode (resume point or next up), else its first unwatched, else
/// its first episode. Null when there is nothing to play.
Future<JellyfinItem?> playableFor(JellyfinClient c, JellyfinItem item) async {
if (isPlayable(item)) return item;
if (item.kind != JellyfinItemKind.series) return null;
final next = await c.nextUp(seriesId: item.id, limit: 1, enableResumable: true);
if (next.isNotEmpty) return next.first;
final eps = await c.episodes(item.id);
return eps.where((e) => !e.userData.played).firstOrNull ?? eps.firstOrNull;
}
typedef _Data = ({
JellyfinItem item,
JellyfinItem? series,
List<JellyfinItem> seasons,
JellyfinItem? next,
});
/// The selected card's details, at the right of the library: banner,
/// title, meta, "Put it up · S1E3" (a disabled Play with [offlineHint]
/// offline), mark watched / unwatched, remove from Continue, overview and,
/// for a series or an episode, the episode list (click picks which
/// episode goes up, double-click puts it up). Reloads when the
/// [controller]'s data version moves.
class JellyfinDetailsPanel extends StatefulWidget {
final BrowseController controller;
final JellyfinSelection selection;
/// Null when online; else why nothing can go up.
final String? offlineHint;
/// Puts [item] (playable) up; returns an error text, or null when sent.
final Future<String?> Function(JellyfinSelection item) onPutUp;
final VoidCallback onClose;
const JellyfinDetailsPanel({
super.key,
required this.controller,
required this.selection,
required this.onPutUp,
required this.onClose,
this.offlineHint,
});
@override
State<JellyfinDetailsPanel> createState() => _JellyfinDetailsPanelState();
}
class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
String? _seasonId;
String? _targetId;
bool _busy = false;
String? _error;
DateTime? _lastTap;
String? _lastTapId;
BrowseController get _c => widget.controller;
JellyfinAccounts get _accounts => _c.accounts;
String get _serverId => widget.selection.serverId;
JellyfinClient? get _client => _accounts.clientFor(_serverId);
Future<T> _cached<T>(String key, Future<T> Function(JellyfinClient c) fn) {
final c = _client;
if (c == null) return Future.error(const JellyfinException('that Jellyfin server is not linked any more'));
return _accounts.cached(_serverId, key, () => fn(c));
}
Future<_Data> _load() async {
await _accounts.ensureLoaded();
final id = widget.selection.itemId;
final item = await _cached('item:$id', (c) => c.item(id));
final sid = item.kind == JellyfinItemKind.series ? item.id : item.seriesId;
if (sid == null || (item.kind != JellyfinItemKind.series && item.kind != JellyfinItemKind.episode)) {
return (item: item, series: null, seasons: const <JellyfinItem>[], next: null);
}
final r = await Future.wait<Object?>([
item.kind == JellyfinItemKind.series ? Future.value(item) : _cached('item:$sid', (c) => c.item(sid)),
_cached('seasons:$sid', (c) => c.seasons(sid)),
_cached('next:$sid', (c) => c.nextUp(seriesId: sid, limit: 1, enableResumable: true))
.then<List<JellyfinItem>?>((v) => v, onError: (_) => null),
]);
final next = r[2] as List<JellyfinItem>?;
return (
item: item,
series: r[0] as JellyfinItem,
seasons: r[1] as List<JellyfinItem>,
next: next == null || next.isEmpty ? null : next.first,
);
}
Future<List<JellyfinItem>> _episodes(String seriesId, String seasonId) =>
_cached('episodes:$seriesId:$seasonId', (c) => c.episodes(seriesId, seasonId: seasonId));
Future<void> _putUp(JellyfinItem target) async {
if (_busy || widget.offlineHint != null) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
error = await widget.onPutUp(JellyfinSelection(_serverId, target.id, target));
} catch (e) {
error = '$e';
}
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
/// Runs a watched-state change, then lets every row reload.
Future<void> _change(Future<void> Function(JellyfinClient c) action) async {
final c = _client;
if (c == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
String? error;
try {
await action(c);
} on JellyfinException catch (e) {
if (e.isUnauthorized) _accounts.markExpired(_serverId);
error = e.isUnauthorized ? 'Jellyfin sign-in expired' : e.message;
} catch (e) {
error = '$e';
}
_c.dataChanged();
if (!mounted) return;
setState(() {
_busy = false;
_error = error;
});
}
void _toggleWatched(JellyfinItem t) =>
_change((c) => t.userData.played ? c.markUnplayed(t.id) : c.markPlayed(t.id));
void _removeFromContinue(JellyfinItem t) => _change((c) async {
if (t.userData.isResumable) await c.clearResumePosition(t.id);
await _accounts.dismissContinue(_serverId, continueKey(t));
});
void _tapEpisode(JellyfinItem e) {
final now = DateTime.now();
final again = _lastTapId == e.id && _lastTap != null && now.difference(_lastTap!) < doubleClickTime;
_lastTap = again ? null : now;
_lastTapId = e.id;
setState(() => _targetId = e.id);
if (again) _putUp(e);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('jf-details'),
color: scheme.surfaceContainerLow,
child: SizedBox(
width: detailsPanelWidth,
child: ListenableBuilder(
listenable: _c,
builder: (context, _) => Loader<_Data>(
key: ValueKey('details-${widget.selection.serverId}-${widget.selection.itemId}'),
version: _c.version,
load: _load,
loading: Stack(children: [
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
Positioned(right: 4, top: 4, child: _closeButton()),
]),
builder: (context, d) => _body(context, d),
),
),
),
);
}
Widget _closeButton() => IconButton(
key: const ValueKey('details-close'),
tooltip: 'Close',
onPressed: widget.onClose,
icon: const Icon(Icons.close),
);
Widget _body(BuildContext context, _Data d) {
final series = d.series;
if (series == null) return _view(context, d, null, null);
final preferred = d.item.kind == JellyfinItemKind.episode ? d.item : null;
final baseTarget = preferred ?? d.next;
final seasons = d.seasons;
final seasonId = (_seasonId != null && seasons.any((s) => s.id == _seasonId))
? _seasonId
: seasons.any((s) => s.id == baseTarget?.seasonId)
? baseTarget!.seasonId
: (seasons.where((s) => !s.userData.played).firstOrNull ?? seasons.firstOrNull)?.id;
if (seasonId == null) return _view(context, d, baseTarget, const []);
return Loader<List<JellyfinItem>>(
key: ValueKey('details-episodes-$seasonId'),
version: _c.version,
load: () => _episodes(series.id, seasonId),
loading: _view(context, d, baseTarget, null),
builder: (context, eps) {
final chosen = _targetId == null
? null
: eps.where((e) => e.id == _targetId).firstOrNull ??
(baseTarget?.id == _targetId ? baseTarget : null);
final fresh = baseTarget == null ? null : eps.where((e) => e.id == baseTarget.id).firstOrNull;
final target = chosen ??
fresh ??
baseTarget ??
eps.where((e) => !e.userData.played).firstOrNull ??
eps.firstOrNull;
return _view(context, d, target, eps, seasonId: seasonId);
},
);
}
/// [episodes] null = loading, empty = none (or not a series).
Widget _view(BuildContext context, _Data d, JellyfinItem? target, List<JellyfinItem>? episodes,
{String? seasonId}) {
final theme = Theme.of(context);
final t = theme.textTheme;
final scheme = theme.colorScheme;
final client = _client;
final shown = d.series ?? d.item;
final isSeries = d.series != null;
final playable = isSeries ? target : (isPlayable(d.item) ? d.item : null);
final banner = shown.image(JellyfinImageType.backdrop) ??
shown.image(JellyfinImageType.thumb) ??
(d.item.kind == JellyfinItemKind.episode ? d.item.ownImage(JellyfinImageType.primary) : null) ??
shown.image(JellyfinImageType.primary);
final eps = shown.recursiveItemCount;
final meta = [
if (shown.productionYear != null) '${shown.productionYear}',
if (isSeries && d.seasons.length > 1) '${d.seasons.length} seasons',
if (isSeries && eps != null && eps > 0) '$eps episodes',
if (!isSeries && shown.runTime != null) formatRuntime(shown.runTime!),
?shown.officialRating,
if (shown.communityRating != null) '★ ${shown.communityRating!.toStringAsFixed(1)}',
if (!isSeries && shown.userData.played) 'Watched',
].join(' · ');
final hint = widget.offlineHint;
final label = playable == null
? (hint == null ? 'Put it up' : 'Play')
: hint != null
? 'Play'
: isSeries
? 'Put it up · ${playable.episodeLabel ?? playable.name}'
: 'Put it up';
final inContinue = playable != null &&
(playable.userData.isResumable || _c.continueKeys.contains(continueKey(playable)));
final left = playable == null ? null : timeLeft(playable);
return ListView(
key: const ValueKey('details-list'),
padding: EdgeInsets.zero,
children: [
SizedBox(
height: 180,
child: Stack(
fit: StackFit.expand,
children: [
if (client != null)
JellyfinImage(client: client, image: banner, alignment: Alignment.topCenter, icon: kindIcon(shown))
else
ColoredBox(color: scheme.surfaceContainerHighest),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, scheme.surfaceContainerLow],
stops: const [0.55, 1],
),
),
),
Positioned(right: 4, top: 4, child: _closeButton()),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(shown.name, style: t.titleLarge),
if (meta.isNotEmpty) Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 12),
Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FilledButton.icon(
key: const ValueKey('details-put-up'),
onPressed: playable == null || hint != null || _busy ? null : () => _putUp(playable),
icon: _busy
? const SizedBox.square(dimension: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(label, overflow: TextOverflow.ellipsis),
),
if (playable != null)
IconButton(
key: const ValueKey('details-watched'),
onPressed: _busy ? null : () => _toggleWatched(playable),
tooltip: playable.userData.played ? 'Mark unwatched' : 'Mark watched',
icon: Icon(playable.userData.played ? Icons.check_circle : Icons.check_circle_outline),
),
if (inContinue)
IconButton(
key: const ValueKey('details-remove-continue'),
onPressed: _busy ? null : () => _removeFromContinue(playable),
tooltip: 'Remove from Continue',
icon: const Icon(Icons.remove_circle_outline),
),
if (isSeries)
IconButton(
key: const ValueKey('details-open-series'),
tooltip: 'Open the series page',
onPressed: () => _c.open(SeriesRoute(d.series!, seasonId: seasonId)),
icon: const Icon(Icons.open_in_new),
),
],
),
if (hint != null || _error != null) ...[
const SizedBox(height: 6),
Text(
_error ?? hint!,
key: const ValueKey('details-hint'),
style: t.bodySmall?.copyWith(color: _error != null ? scheme.error : Colors.grey),
),
],
if (isSeries && playable != null) ...[
const SizedBox(height: 10),
Text(
[playable.episodeLabel ?? '', playable.name].where((s) => s.isNotEmpty).join(' · '),
style: t.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (left != null) ...[
const SizedBox(height: 6),
Row(children: [
SizedBox(width: 160, child: LinearProgressIndicator(value: progressOf(playable!), minHeight: 3)),
const SizedBox(width: 8),
Text(left, style: t.bodySmall?.copyWith(color: Colors.grey)),
]),
],
if (shown.overview != null) ...[
const SizedBox(height: 12),
Text(shown.overview!, style: t.bodyMedium),
],
if (!isSeries) ...[
const SizedBox(height: 12),
_Tracks(item: d.item),
],
if (isSeries) ...[
const SizedBox(height: 16),
_episodesHeader(context, d, seasonId),
if (episodes == null)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (episodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('No episodes', style: TextStyle(color: Colors.grey)),
)
else
for (final e in episodes) _episodeRow(context, e, target?.id == e.id),
],
],
),
),
],
);
}
Widget _episodesHeader(BuildContext context, _Data d, String? seasonId) {
final seasons = d.seasons;
return Row(
children: [
const Expanded(child: Text('Episodes', style: TextStyle(fontWeight: FontWeight.w600))),
if (seasons.length > 1)
DropdownButton<String>(
key: const ValueKey('details-season'),
value: seasonId,
isDense: true,
underline: const SizedBox.shrink(),
borderRadius: BorderRadius.circular(10),
items: [
for (final s in seasons)
DropdownMenuItem(value: s.id, child: Text(s.name, overflow: TextOverflow.ellipsis)),
],
onChanged: (id) => setState(() => _seasonId = id),
),
],
);
}
Widget _episodeRow(BuildContext context, JellyfinItem e, bool isTarget) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
final client = _client;
final sub = e.userData.played ? 'watched' : timeLeft(e) ?? (e.runTime == null ? '' : formatRuntime(e.runTime!));
return Material(
color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
key: ValueKey('details-episode-${e.id}'),
borderRadius: BorderRadius.circular(8),
onTap: () => _tapEpisode(e),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 64,
height: 36,
child: client == null
? ColoredBox(color: scheme.surfaceContainerHighest)
: JellyfinImage(
client: client,
image: e.ownImage(JellyfinImageType.primary) ?? e.image(JellyfinImageType.thumb),
icon: Icons.tv_outlined,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.indexNumber == null ? e.name : 'E${e.indexNumber} · ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodyMedium?.copyWith(
color: isTarget ? scheme.primary : e.userData.played ? Colors.grey : null,
fontWeight: isTarget ? FontWeight.w600 : null,
),
),
if (sub.isNotEmpty) Text(sub, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
AnimatedOpacity(
opacity: isTarget ? 1 : 0,
duration: easeTime,
child: Icon(Icons.play_circle, color: scheme.primary),
),
],
),
),
),
);
}
}
/// What the file holds, one line per track kind.
class _Tracks extends StatelessWidget {
final JellyfinItem item;
const _Tracks({required this.item});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final source = item.mediaSources.firstOrNull;
final streams = source?.mediaStreams.isNotEmpty == true ? source!.mediaStreams : item.mediaStreams;
if (streams.isEmpty) return const SizedBox.shrink();
String names(JellyfinStreamType type) => streams
.where((s) => s.type == type)
.map((s) => s.displayTitle ?? [s.language, s.codec].whereType<String>().join(' '))
.join(' · ');
final rows = [
('Video', names(JellyfinStreamType.video)),
('Audio', names(JellyfinStreamType.audio)),
('Subtitles', names(JellyfinStreamType.subtitle)),
if (source?.container != null || source?.size != null)
('File', [
?source?.container,
if (source?.size != null) '${(source!.size! / (1 << 30)).toStringAsFixed(2)} GB',
].join(' · ')),
].where((r) => r.$2.isNotEmpty);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 76, child: Text(label, style: t.bodySmall?.copyWith(color: Colors.grey))),
Expanded(child: Text(value, style: t.bodySmall)),
],
),
),
],
);
}
}
@@ -33,6 +33,9 @@ bool isPlayable(JellyfinItem item) => switch (item.kind) {
_ => !item.isFolder && item.mediaType == 'Video',
};
/// Cards that select (details panel) on click: playable items and series.
bool isSelectable(JellyfinItem item) => isPlayable(item) || item.kind == JellyfinItemKind.series;
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
+143 -73
View File
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../state/settings.dart';
import 'browse_scope.dart';
import 'cards.dart';
import 'home_rows.dart';
/// Libraries that hold videos; music, books, photos and playlists are left
/// out of the browser.
@@ -19,9 +22,15 @@ IconData libraryIcon(JellyfinItem view) => switch (view.collectionType) {
_ => Icons.folder_outlined,
};
/// Start page: library shortcuts, then Continue watching, Next up and the
/// latest additions of each library. Each row loads on its own, so a slow
/// library does not hold up the rest.
/// The video libraries of the scope's server (cached).
Future<List<JellyfinItem>> videoLibraries(BrowseScope scope) =>
scope.load('views', (c) => c.userViews()).then((all) => all.where(isVideoLibrary).toList());
/// Start page: (library shortcuts,) one Continue row (resume points and
/// next up merged per series, see [mergeContinue]), then the newest
/// additions of each library as series cards ([latestCards]). Each row
/// loads on its own, so a slow library does not hold up the rest; rows
/// reload when watched state changes ([BrowseController.dataChanged]).
class JellyfinHomePage extends StatelessWidget {
const JellyfinHomePage({super.key});
@@ -30,58 +39,49 @@ class JellyfinHomePage extends StatelessWidget {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
key: ValueKey('views-${scope.account.serverId}'),
load: () => scope.load('views', (c) => c.userViews()),
builder: (context, all) {
final views = all.where(isVideoLibrary).toList();
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
_LibraryStrip(views: views),
const SizedBox(height: 20),
_Section(
key: const ValueKey('row-resume'),
title: 'Continue watching',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('resume', (c) => c.resume(limit: 30)),
),
_Section(
key: const ValueKey('row-nextup'),
title: 'Next up',
shape: CardShape.thumb,
seriesFirst: true,
load: () => scope.load('nextup', (c) => c.nextUp(limit: 30)),
),
for (final v in views)
_Section(
key: ValueKey('row-latest-${v.id}'),
title: 'Latest in ${v.name}',
shape: v.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster,
load: () => scope.load('latest:${v.id}', (c) => c.latest(v.id, limit: 30)),
trailing: TextButton(
onPressed: () => scope.onOpen(LibraryRoute(v)),
child: const Text('Show all'),
),
),
],
);
},
load: () => videoLibraries(scope),
builder: (context, views) => ListenableBuilder(
listenable: scope.controller,
builder: (context, _) {
final v = scope.controller.version;
return ListView(
key: const PageStorageKey('jf-home'),
padding: const EdgeInsets.fromLTRB(0, 4, 0, 24),
children: [
if (scope.libraryStrip) ...[
LibraryStrip(views: views),
const SizedBox(height: 20),
] else if (views.isEmpty)
const _NoLibraries(),
_ContinueRow(version: v),
for (final view in views) _LatestRow(key: ValueKey('row-latest-${view.id}'), view: view, version: v),
],
);
},
),
);
}
}
class _LibraryStrip extends StatelessWidget {
class _NoLibraries extends StatelessWidget {
const _NoLibraries();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(bottom: 20),
child: Text('No video libraries on this server for your user.', style: TextStyle(color: Colors.grey)),
);
}
/// The libraries as chips; a click opens one.
class LibraryStrip extends StatelessWidget {
final List<JellyfinItem> views;
const _LibraryStrip({required this.views});
const LibraryStrip({super.key, required this.views});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
if (views.isEmpty) {
return const Text('No video libraries on this server for your user.',
style: TextStyle(color: Colors.grey));
}
if (views.isEmpty) return const _NoLibraries();
return Wrap(
spacing: 8,
runSpacing: 8,
@@ -92,46 +92,116 @@ class _LibraryStrip extends StatelessWidget {
avatar: Icon(libraryIcon(v), size: 18),
label: Text(v.name),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => scope.onOpen(LibraryRoute(v)),
onPressed: () => scope.controller.openTop(LibraryRoute(v)),
),
],
);
}
}
class _Section extends StatelessWidget {
final String title;
final CardShape shape;
final bool seriesFirst;
final Future<List<JellyfinItem>> Function() load;
final Widget? trailing;
const _Section({
super.key,
required this.title,
required this.shape,
required this.load,
this.seriesFirst = false,
this.trailing,
});
class _ContinueRow extends StatelessWidget {
final int version;
const _ContinueRow({required this.version});
static const title = 'Continue';
Future<List<JellyfinItem>> _load(BrowseScope scope) async {
final lists = await Future.wait([
scope.load('resume', (c) => c.resume(limit: 40)),
scope.load('nextup', (c) => c.nextUp(limit: 40)),
// Only for ordering: a failure leaves the row as it is.
scope
.load('played', (c) => c.recentlyPlayed(limit: 150))
.catchError((Object _) => const <JellyfinItem>[]),
]);
return mergeContinue(
resume: lists[0],
nextUp: lists[1],
recentlyPlayed: lists[2],
dismissed: scope.accounts.continueDismissed(scope.account.serverId),
now: scope.controller.now(),
);
}
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
return Loader<List<JellyfinItem>>(
load: load,
version: version,
load: () => _load(scope),
showError: false,
loading: const _RowSkeleton(title: title, shape: CardShape.thumb),
builder: (context, items) {
scope.controller.continueKeys = {for (final i in items) continueKey(i)};
if (items.isEmpty) return const SizedBox.shrink();
return Padding(
key: const ValueKey('row-continue'),
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
entries: cardEntries(items),
shape: CardShape.thumb,
seriesFirst: true,
),
);
},
);
}
}
class _LatestRow extends StatelessWidget {
final JellyfinItem view;
final int version;
const _LatestRow({super.key, required this.view, required this.version});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final title = 'Latest in ${view.name}';
final shape = view.collectionType == 'homevideos' ? CardShape.thumb : CardShape.poster;
return Loader<List<JellyfinItem>>(
version: version,
load: () => scope.load('latest:${view.id}', (c) => c.latest(view.id, limit: 100, groupItems: false)),
showError: false,
loading: _RowSkeleton(title: title, shape: shape),
builder: (context, items) => items.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
title: title,
items: items,
shape: shape,
seriesFirst: seriesFirst,
trailing: trailing,
),
builder: (context, items) => SignalBuilder(builder: (context) {
final hide = AppSettings.instance.hideReReleases.value;
final cards = latestCards(items, hideReReleases: hide);
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: MediaRow(
key: ValueKey('latest-${view.id}'),
title: title,
entries: [for (final c in cards) (item: c.item, display: c.display, subtitle: c.subtitle)],
shape: shape,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: ValueKey('show-all-${view.id}'),
// Newest first, like the row.
onPressed: () => scope.onOpen(LibraryRoute(view, sort: 'added')),
child: const Text('Show all'),
),
PopupMenuButton<void>(
key: ValueKey('latest-menu-${view.id}'),
tooltip: 'Row options',
icon: const Icon(Icons.more_vert, size: 18),
itemBuilder: (context) => [
CheckedPopupMenuItem(
key: const ValueKey('hide-rereleases'),
checked: hide,
onTap: () => AppSettings.instance.setHideReReleases(!hide),
child: const Text('Hide re-releases in Latest'),
),
],
),
],
),
),
);
}),
);
}
}
@@ -0,0 +1,163 @@
import 'package:sync_relay_core/core.dart';
import 'format.dart';
// What the Jellyfin home rows show, as pure functions over the API lists
// (tested without widgets).
/// Series not watched for longer than this leave the Continue row, unless
/// nothing newer is left.
const continueMaxAge = Duration(days: 60);
/// Added this long after it first aired: a re-release ([isReRelease]).
const reReleaseAge = Duration(days: 30);
/// One Continue card per series: an episode's series, else the item itself.
String continueKey(JellyfinItem item) => item.seriesId ?? item.id;
DateTime? _later(DateTime? a, DateTime? b) =>
a == null ? b : b == null || !b.isAfter(a) ? a : b;
/// The Continue row: Continue watching ([resume]) and Next up ([nextUp])
/// merged into one card per series, the series watched most recently first.
/// A series shows its resume item when that was the last thing played in
/// it, else its next-up episode. When a series was last played comes from
/// the resume points and from [recentlyPlayed] (finished episodes, which
/// next-up items do not carry). Series untouched for [maxAge] are left out
/// unless nothing else is left; [dismissed] (key -> when) hides a series
/// until it is played again after that.
List<JellyfinItem> mergeContinue({
required List<JellyfinItem> resume,
required List<JellyfinItem> nextUp,
List<JellyfinItem> recentlyPlayed = const [],
Map<String, DateTime> dismissed = const {},
required DateTime now,
Duration maxAge = continueMaxAge,
}) {
final finished = <String, DateTime>{};
for (final p in recentlyPlayed) {
final at = p.userData.lastPlayedDate;
if (at != null) finished[continueKey(p)] = _later(finished[continueKey(p)], at)!;
}
final resumeBy = <String, JellyfinItem>{};
final nextBy = <String, JellyfinItem>{};
final order = <String>[];
for (final r in resume) {
final k = continueKey(r);
final had = resumeBy[k];
if (had == null) order.add(k);
final at = r.userData.lastPlayedDate;
if (had == null || (at != null && at.isAfter(had.userData.lastPlayedDate ?? DateTime(0)))) {
resumeBy[k] = r;
}
}
for (final n in nextUp) {
final k = continueKey(n);
if (nextBy.containsKey(k)) continue;
nextBy[k] = n;
if (!resumeBy.containsKey(k)) order.add(k);
}
final cards = <({JellyfinItem item, DateTime? at, int index})>[];
for (final (i, k) in order.indexed) {
final r = resumeBy[k], n = nextBy[k], done = finished[k];
final resumedAt = r?.userData.lastPlayedDate;
final JellyfinItem item;
if (r != null && (n == null || (resumedAt != null && (done == null || !resumedAt.isBefore(done))))) {
item = r;
} else {
item = n!;
}
final at = _later(resumedAt, done);
final hiddenAt = dismissed[k];
if (hiddenAt != null && (at == null || !at.isAfter(hiddenAt))) continue;
cards.add((item: item, at: at, index: i));
}
cards.sort((a, b) {
if (a.at == null || b.at == null) {
return a.at == b.at ? a.index.compareTo(b.index) : (a.at == null ? 1 : -1);
}
final c = b.at!.compareTo(a.at!);
return c != 0 ? c : a.index.compareTo(b.index);
});
final fresh = [for (final c in cards) if (c.at != null && now.difference(c.at!) <= maxAge) c.item];
return fresh.isNotEmpty ? fresh : [for (final c in cards) c.item];
}
/// Added to the library long ([age]) after it first aired: a re-upload or
/// an old season added late, not news. Unknown dates are never re-releases.
bool isReRelease(JellyfinItem item, {Duration age = reReleaseAge}) {
final aired = item.premiereDate, added = item.dateCreated;
return aired != null && added != null && added.difference(aired) > age;
}
/// One card of a "Latest in X" row: [item] is what selecting or putting it
/// up acts on, [display] gives the picture and title, [subtitle] the line
/// under it.
class LatestCard {
final JellyfinItem item;
final JellyfinItem display;
final String subtitle;
const LatestCard(this.item, this.display, this.subtitle);
}
/// A series to show for [episode]: its id, name and poster.
JellyfinItem seriesOf(JellyfinItem episode) => JellyfinItem(
id: episode.seriesId!,
name: episode.seriesName ?? episode.name,
type: JellyfinItemType.series,
isFolder: true,
imageTags: {
if (episode.seriesPrimaryImageTag != null)
JellyfinImageType.primary.apiName: episode.seriesPrimaryImageTag!,
},
parentBackdropItemId: episode.parentBackdropItemId,
parentBackdropImageTags: episode.parentBackdropImageTags,
);
/// "Latest in X" from the library's newest items (ungrouped, newest
/// first): episodes become one card per series (its poster and name,
/// "S1E1" for a single new episode, else "N new"), in order of the newest
/// episode; movies and other items stay as they are. With [hideReReleases]
/// items that [isReRelease] are left out first.
List<LatestCard> latestCards(List<JellyfinItem> items, {bool hideReReleases = true}) {
final kept = hideReReleases ? items.where((i) => !isReRelease(i)) : items;
final groups = <String, List<JellyfinItem>>{};
final out = <Object>[]; // a JellyfinItem, or a series id (group)
for (final i in kept) {
final sid = i.seriesId;
if (i.kind == JellyfinItemKind.episode && sid != null) {
final g = groups[sid];
if (g == null) {
groups[sid] = [i];
out.add(sid);
} else {
g.add(i);
}
} else {
out.add(i);
}
}
return [
for (final o in out)
if (o is JellyfinItem)
LatestCard(
o,
o,
o.kind == JellyfinItemKind.series && (o.childCount ?? 0) > 1
? '${o.childCount} new'
: cardSubtitle(o),
)
else
_seriesCard(groups[o]!),
];
}
LatestCard _seriesCard(List<JellyfinItem> eps) {
final series = seriesOf(eps.first);
if (eps.length == 1) {
final e = eps.single;
return LatestCard(e, series, e.episodeLabel ?? e.name);
}
return LatestCard(series, series, '${eps.length} new');
}
@@ -1,128 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'format.dart';
import 'images.dart';
/// Side pane on wide windows: details of whatever card is hovered or
/// focused, so browsing needs no extra clicks. Pick / Open act on it.
class JellyfinInspector extends StatelessWidget {
final ValueListenable<JellyfinItem?> item;
const JellyfinInspector({super.key, required this.item});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
key: const ValueKey('jf-inspector'),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.4)),
),
child: ValueListenableBuilder<JellyfinItem?>(
valueListenable: item,
builder: (context, it, _) => it == null
? const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('Hover or focus a title to see its details here.',
textAlign: TextAlign.center, style: TextStyle(color: Colors.grey)),
),
)
: _Body(item: it),
),
);
}
}
class _Body extends StatelessWidget {
final JellyfinItem item;
const _Body({required this.item});
@override
Widget build(BuildContext context) {
final scope = BrowseScope.of(context);
final t = Theme.of(context).textTheme;
final playable = isPlayable(item);
final selected = scope.selectedItemId == item.id;
final episode = item.kind == JellyfinItemKind.episode;
final art = episode
? item.ownImage(JellyfinImageType.primary) ?? item.image(JellyfinImageType.backdrop)
: item.image(JellyfinImageType.backdrop) ?? item.image(JellyfinImageType.thumb);
final meta = [
if (item.productionYear != null) '${item.productionYear}',
if (playable && item.runTime != null) formatRuntime(item.runTime!),
if (item.officialRating != null) item.officialRating!,
if (item.communityRating != null) '★ ${item.communityRating!.toStringAsFixed(1)}',
if (!playable && (item.userData.unplayedItemCount ?? 0) > 0)
'${item.userData.unplayedItemCount} unwatched',
if (item.userData.played) 'Watched',
].join(' · ');
return ListView(
padding: EdgeInsets.zero,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: JellyfinImage(client: scope.client, image: art),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (episode && item.seriesName != null)
Text(item.seriesName!, style: t.labelLarge?.copyWith(color: Colors.grey)),
Text(
episode && item.episodeLabel != null ? '${item.episodeLabel} ${item.name}' : item.name,
style: t.titleMedium,
),
const SizedBox(height: 4),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (timeLeft(item) != null) ...[
const SizedBox(height: 8),
LinearProgressIndicator(value: progressOf(item), minHeight: 3),
const SizedBox(height: 4),
Text(timeLeft(item)!, style: t.bodySmall),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
if (playable)
FilledButton.icon(
onPressed: () => scope.onPick(item),
icon: Icon(selected ? Icons.check : Icons.play_arrow, size: 18),
label: Text(selected ? 'Picked' : 'Pick'),
)
else
FilledButton.tonalIcon(
onPressed: () => scope.activate(item),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Open'),
),
if (playable)
OutlinedButton(
onPressed: () => scope.onOpen(DetailRoute(item)),
child: const Text('Details'),
),
if (episode && item.seriesId != null)
OutlinedButton(
onPressed: () => scope.openSeriesOf(item),
child: const Text('Series'),
),
]),
if (item.overview != null) ...[
const SizedBox(height: 12),
Text(item.overview!, style: t.bodyMedium),
],
],
),
),
],
);
}
}
@@ -39,15 +39,18 @@ const libraryPageSize = 100;
/// pages load as they come near the viewport.
class JellyfinLibraryPage extends StatefulWidget {
final JellyfinItem folder;
const JellyfinLibraryPage({super.key, required this.folder});
/// A [LibrarySort] name to start with (in its natural direction).
final String? sort;
const JellyfinLibraryPage({super.key, required this.folder, this.sort});
@override
State<JellyfinLibraryPage> createState() => _JellyfinLibraryPageState();
}
class _JellyfinLibraryPageState extends State<JellyfinLibraryPage> {
LibrarySort _sort = LibrarySort.name;
bool _descending = false;
late LibrarySort _sort =
LibrarySort.values.where((s) => s.name == widget.sort).firstOrNull ?? LibrarySort.name;
late bool _descending = _sort.descending;
bool _unplayed = false;
bool _favorites = false;
@@ -1,31 +1,32 @@
import 'package:flutter/material.dart';
import '../screens/home/picked_media.dart';
import '../screens/home/ui.dart';
import 'accounts.dart';
import 'browse_scope.dart';
import 'browser.dart';
import 'sign_in_panel.dart';
/// The media picker's Jellyfin source: the library browser once a server
/// is linked, else an inline add-server panel.
/// The Library's Jellyfin source: the library browser once a server is
/// linked, else an inline add-server panel.
class JellyfinPickerSource extends StatefulWidget {
final JellyfinAccounts accounts;
final JellyfinMedia? selected;
final ValueChanged<JellyfinMedia> onPick;
final BrowseController? controller;
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> onPutUp;
final VoidCallback? onOpenIntegrations;
final VoidCallback? onToggleExpanded;
final bool expanded;
final BrowseStart? start;
final bool libraryStrip;
const JellyfinPickerSource({
super.key,
this.start,
required this.accounts,
required this.onPick,
required this.onSelect,
required this.onPutUp,
this.controller,
this.selected,
this.onOpenIntegrations,
this.onToggleExpanded,
this.expanded = false,
this.libraryStrip = true,
});
@override
@@ -52,12 +53,13 @@ class _JellyfinPickerSourceState extends State<JellyfinPickerSource> {
return JellyfinBrowser(
key: const ValueKey('media-jellyfin'),
accounts: a,
controller: widget.controller,
selected: widget.selected,
onPick: widget.onPick,
onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onOpenIntegrations: widget.onOpenIntegrations,
onToggleExpanded: widget.onToggleExpanded,
expanded: widget.expanded,
start: widget.start,
libraryStrip: widget.libraryStrip,
);
},
);
+112 -116
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import 'browse_scope.dart';
import 'cards.dart' show doubleClickTime;
import 'format.dart';
import 'images.dart';
@@ -60,6 +60,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
final scope = BrowseScope.of(context);
return Loader<_SeriesData>(
key: ValueKey('series-${widget.series.id}-${scope.account.serverId}'),
version: scope.controller.version,
load: () => _load(scope),
builder: (context, d) {
final seasonId = (_seasonId != null && d.seasons.any((s) => s.id == _seasonId))
@@ -99,6 +100,7 @@ class _JellyfinSeriesPageState extends State<JellyfinSeriesPage> {
key: ValueKey('episodes-$seasonId'),
seriesId: widget.series.id,
seasonId: seasonId,
version: scope.controller.version,
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
@@ -218,7 +220,7 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('series-next'),
onPressed: next == null ? null : () => scope.onPick(next),
onPressed: next == null ? null : () => scope.onSelect(next),
icon: Icon(selectedNext ? Icons.check : Icons.play_arrow),
label: Text(nextLabel, overflow: TextOverflow.ellipsis),
),
@@ -242,7 +244,9 @@ class _SeriesHeaderState extends State<_SeriesHeader> {
class _EpisodeList extends StatefulWidget {
final String seriesId;
final String seasonId;
const _EpisodeList({super.key, required this.seriesId, required this.seasonId});
final int version;
const _EpisodeList(
{super.key, required this.seriesId, required this.seasonId, required this.version});
@override
State<_EpisodeList> createState() => _EpisodeListState();
@@ -250,6 +254,13 @@ class _EpisodeList extends StatefulWidget {
class _EpisodeListState extends State<_EpisodeList> {
Future<List<JellyfinItem>>? _future;
List<JellyfinItem>? _last;
@override
void didUpdateWidget(_EpisodeList old) {
super.didUpdateWidget(old);
if (old.version != widget.version) _future = null;
}
void _load(BrowseScope scope) => _future = scope.load('episodes:${widget.seriesId}:${widget.seasonId}',
(c) => c.episodes(widget.seriesId, seasonId: widget.seasonId));
@@ -265,7 +276,8 @@ class _EpisodeListState extends State<_EpisodeList> {
return SliverToBoxAdapter(
child: LoadError(error: snap.error!, onRetry: () => setState(() => _load(scope))));
}
final eps = snap.data;
final eps = snap.data ?? _last;
if (snap.data != null) _last = snap.data;
if (eps == null) {
return const SliverToBoxAdapter(
child: Padding(
@@ -302,7 +314,8 @@ class _EpisodeListState extends State<_EpisodeList> {
const episodeTileHeight = 6 + 176 * 9 / 16 + 6 + 2;
/// One episode: thumbnail with progress, number + title, runtime / air
/// date, two lines of overview. Click picks it; I opens details.
/// date, two lines of overview. Click selects it (details panel), a second
/// click soon after puts it up.
class EpisodeTile extends StatefulWidget {
final JellyfinItem episode;
const EpisodeTile({super.key, required this.episode});
@@ -314,6 +327,19 @@ class EpisodeTile extends StatefulWidget {
class _EpisodeTileState extends State<EpisodeTile> {
bool _focus = false;
bool _hover = false;
DateTime? _lastTap;
void _tap(BrowseScope scope) {
final now = DateTime.now();
final last = _lastTap;
_lastTap = now;
if (last != null && now.difference(last) < doubleClickTime) {
_lastTap = null;
scope.onPutUp(widget.episode);
} else {
scope.onSelect(widget.episode);
}
}
@override
Widget build(BuildContext context) {
@@ -332,122 +358,92 @@ class _EpisodeTileState extends State<EpisodeTile> {
if (e.premiereDate != null)
'${e.premiereDate!.year}-${e.premiereDate!.month.toString().padLeft(2, '0')}-${e.premiereDate!.day.toString().padLeft(2, '0')}',
].join(' · ');
return Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: (_, k) {
if (k is KeyDownEvent && k.logicalKey == LogicalKeyboardKey.keyI) {
scope.onOpen(DetailRoute(e));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => scope.onPick(e),
onHover: (h) {
setState(() => _hover = h);
if (h) scope.onPeek(e);
},
onFocusChange: (f) {
setState(() => _focus = f);
if (f) scope.onPeek(e);
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
return Material(
color: selected
? scheme.primaryContainer.withValues(alpha: 0.35)
: _hover || _focus
? scheme.surfaceContainerHigh
: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selected ? scheme.primary : _focus ? scheme.onSurface : Colors.transparent,
width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: ValueKey('episode-${e.id}'),
focusColor: Colors.transparent,
onTap: () => _tap(scope),
onHover: (h) => setState(() => _hover = h),
onFocusChange: (f) => setState(() => _focus = f),
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 176,
child: AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(fit: StackFit.expand, children: [
JellyfinImage(
client: scope.client,
image: e.ownImage(JellyfinImageType.primary) ??
e.image(JellyfinImageType.thumb) ??
e.image(JellyfinImageType.backdrop),
icon: Icons.tv_outlined,
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
),
if (progress > 0)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: progress, minHeight: 3, backgroundColor: Colors.black54),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
if (played || selected)
Positioned(
top: 4,
right: 4,
child: CircleAvatar(
radius: 10,
backgroundColor: selected ? scheme.primary : const Color(0xCC1B5E20),
child: Icon(Icons.check,
size: 13, color: selected ? scheme.onPrimary : Colors.white),
),
),
]),
),
]),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(
e.indexNumber == null ? e.name : '${e.indexNumber}. ${e.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(
color: selected ? scheme.primary : played ? Colors.grey : null,
),
),
),
if (_hover)
IconButton(
tooltip: 'Details (I)',
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 22),
iconSize: 18,
onPressed: () => scope.onOpen(DetailRoute(e)),
icon: const Icon(Icons.info_outline),
),
]),
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
Text(meta, style: t.bodySmall?.copyWith(color: Colors.grey)),
if (e.overview != null) ...[
const SizedBox(height: 4),
Text(e.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
],
),
],
),
],
),
),
],
),
),
),