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
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user