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>
430 lines
13 KiB
Dart
430 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
|
|
import 'accounts.dart';
|
|
import 'format.dart';
|
|
|
|
/// One level of the library browser's back stack.
|
|
sealed class BrowseRoute {
|
|
const BrowseRoute();
|
|
String get label;
|
|
/// Identity for keeping a page's state while it is in the stack.
|
|
String get key;
|
|
}
|
|
|
|
final class HomeRoute extends BrowseRoute {
|
|
const HomeRoute();
|
|
@override
|
|
String get label => 'Home';
|
|
@override
|
|
String get key => 'home';
|
|
}
|
|
|
|
/// 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;
|
|
/// 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 => sort == null ? 'lib:${folder.id}' : 'lib:${folder.id}:$sort';
|
|
}
|
|
|
|
final class SeriesRoute extends BrowseRoute {
|
|
final JellyfinItem series;
|
|
/// Season to open on (from a season card or an episode's "open series").
|
|
final String? seasonId;
|
|
const SeriesRoute(this.series, {this.seasonId});
|
|
@override
|
|
String get label => series.name;
|
|
@override
|
|
String get key => 'series:${series.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).
|
|
List<Map<String, Object?>> encodeBrowseStack(List<BrowseRoute> stack) => [
|
|
for (final r in stack)
|
|
switch (r) {
|
|
HomeRoute() => {'r': 'home'},
|
|
LibraryRoute(:final folder, :final sort) => {'r': 'lib', ..._itemJson(folder), 'sort': ?sort},
|
|
SeriesRoute(:final series, :final seasonId) => {
|
|
'r': 'series',
|
|
..._itemJson(series),
|
|
'seasonId': ?seasonId,
|
|
},
|
|
}
|
|
];
|
|
|
|
/// Inverse of [encodeBrowseStack]; always starts with Home. Null when
|
|
/// [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()];
|
|
for (final e in raw) {
|
|
if (e is! Map) return null;
|
|
final item = _itemFrom(e);
|
|
final route = switch (e['r']) {
|
|
'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),
|
|
_ => null,
|
|
};
|
|
if (route != null && out.every((r) => r.key != route.key)) out.add(route);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Map<String, Object?> _itemJson(JellyfinItem i) => {
|
|
'id': i.id,
|
|
'name': i.name,
|
|
'type': i.type,
|
|
if (i.isFolder) 'isFolder': true,
|
|
'collectionType': ?i.collectionType,
|
|
'seriesId': ?i.seriesId,
|
|
'seriesName': ?i.seriesName,
|
|
'seasonId': ?i.seasonId,
|
|
'indexNumber': ?i.indexNumber,
|
|
'parentIndexNumber': ?i.parentIndexNumber,
|
|
};
|
|
|
|
JellyfinItem? _itemFrom(Map e) {
|
|
String? s(String k) => e[k] is String && (e[k] as String).isNotEmpty ? e[k] as String : null;
|
|
final id = s('id');
|
|
if (id == null) return null;
|
|
return JellyfinItem(
|
|
id: id,
|
|
name: s('name') ?? '',
|
|
type: s('type') ?? '',
|
|
isFolder: e['isFolder'] == true,
|
|
collectionType: s('collectionType'),
|
|
seriesId: s('seriesId'),
|
|
seriesName: s('seriesName'),
|
|
seasonId: e['r'] == 'series' ? null : s('seasonId'),
|
|
indexNumber: e['indexNumber'] is int ? e['indexNumber'] as int : null,
|
|
parentIndexNumber: e['parentIndexNumber'] is int ? e['parentIndexNumber'] as int : null,
|
|
);
|
|
}
|
|
|
|
/// Where a browser opens instead of the remembered location: a server and
|
|
/// a route stack (e.g. the series of the episode playing).
|
|
class BrowseStart {
|
|
final String serverId;
|
|
final List<BrowseRoute> stack;
|
|
const BrowseStart(this.serverId, this.stack);
|
|
|
|
/// The series page of [episode] with its season selected.
|
|
static BrowseStart? seriesOf(String serverId, JellyfinItem episode) {
|
|
final sid = episode.seriesId;
|
|
if (sid == null) return null;
|
|
return BrowseStart(serverId, [
|
|
const HomeRoute(),
|
|
SeriesRoute(JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'),
|
|
seasonId: episode.seasonId),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
final BrowseController controller;
|
|
/// Item id of the selected card on this server, highlighted everywhere.
|
|
final String? selectedItemId;
|
|
/// 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.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: series, movies and episodes are selected,
|
|
/// seasons open their series, libraries and folders open.
|
|
void activate(JellyfinItem item) {
|
|
if (isSelectable(item)) {
|
|
onSelect(item);
|
|
return;
|
|
}
|
|
switch (item.kind) {
|
|
case JellyfinItemKind.season:
|
|
final sid = item.seriesId;
|
|
if (sid != null) {
|
|
onOpen(SeriesRoute(
|
|
JellyfinItem(id: sid, name: item.seriesName ?? item.name, type: 'Series'),
|
|
seasonId: item.id));
|
|
}
|
|
default:
|
|
onOpen(LibraryRoute(item));
|
|
}
|
|
}
|
|
|
|
/// Opens the series of an episode.
|
|
void openSeriesOf(JellyfinItem episode) {
|
|
final sid = episode.seriesId;
|
|
if (sid == null) return;
|
|
onOpen(SeriesRoute(
|
|
JellyfinItem(id: sid, name: episode.seriesName ?? '', type: 'Series'),
|
|
seasonId: episode.seasonId));
|
|
}
|
|
|
|
@override
|
|
bool updateShouldNotify(BrowseScope old) =>
|
|
old.selectedItemId != selectedItemId ||
|
|
old.account != account ||
|
|
old.client != client ||
|
|
old.libraryStrip != libraryStrip;
|
|
}
|
|
|
|
/// 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
|
|
State<Loader<T>> createState() => _LoaderState<T>();
|
|
}
|
|
|
|
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 || (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);
|
|
}
|
|
final last = _last;
|
|
if (last != null) return widget.builder(context, last.$1);
|
|
return widget.loading ??
|
|
const Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class LoadError extends StatelessWidget {
|
|
final Object error;
|
|
final VoidCallback onRetry;
|
|
const LoadError({super.key, required this.error, required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final e = error;
|
|
final msg = e is JellyfinException
|
|
? (e.isUnauthorized ? 'Sign-in expired' : e.message)
|
|
: '$e';
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.cloud_off, size: 18, color: Theme.of(context).colorScheme.error),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(msg.length > 160 ? '${msg.substring(0, 160)}…' : msg,
|
|
style: const TextStyle(color: Colors.grey)),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(onPressed: onRetry, child: const Text('Retry')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|