feat: jellyfin
This commit is contained in:
@@ -11,16 +11,16 @@ import 'event_aware.dart';
|
||||
/// Example usage:
|
||||
/// ```dart
|
||||
/// class _MyScreenState extends State<MyScreen> with DeletionAware {
|
||||
/// List<PlexMetadata> _items = [];
|
||||
/// List<MediaItem> _items = [];
|
||||
///
|
||||
/// @override
|
||||
/// Set<String>? get deletionRatingKeys =>
|
||||
/// _items.map((e) => e.ratingKey).toSet();
|
||||
/// Set<String>? get deletionIds =>
|
||||
/// _items.map((e) => e.id).toSet();
|
||||
///
|
||||
/// @override
|
||||
/// void onDeletionEvent(DeletionEvent event) {
|
||||
/// setState(() {
|
||||
/// _items.removeWhere((e) => e.ratingKey == event.ratingKey);
|
||||
/// _items.removeWhere((e) => e.id == event.itemId);
|
||||
/// });
|
||||
/// }
|
||||
/// }
|
||||
@@ -36,22 +36,22 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
/// Override to specify which global keys this screen cares about.
|
||||
///
|
||||
/// Use format `serverId:ratingKey`.
|
||||
/// Return null to fall back to [deletionRatingKeys] matching.
|
||||
/// Return null to fall back to [deletionIds] matching.
|
||||
Set<String>? get deletionGlobalKeys => null;
|
||||
|
||||
/// Override to specify which ratingKeys this screen cares about.
|
||||
/// Override to specify which item ids this screen cares about.
|
||||
///
|
||||
/// Return null to receive ALL events (not recommended for performance).
|
||||
/// Return an empty set to receive no events.
|
||||
///
|
||||
/// The set should include:
|
||||
/// - Direct items displayed (e.g., episode ratingKeys in a season view)
|
||||
/// - Parent items that affect display (e.g., show ratingKey for seasons)
|
||||
Set<String>? get deletionRatingKeys;
|
||||
/// - Direct items displayed (e.g., episode ids in a season view)
|
||||
/// - Parent items that affect display (e.g., show id for seasons)
|
||||
Set<String>? get deletionIds;
|
||||
|
||||
/// Called when a relevant deletion event occurs.
|
||||
///
|
||||
/// Only called if [deletionRatingKeys] is null or contains an affected key.
|
||||
/// Only called if [deletionIds] is null or contains an affected key.
|
||||
void onDeletionEvent(DeletionEvent event);
|
||||
|
||||
@override
|
||||
@@ -62,7 +62,7 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
mounted: () => mounted,
|
||||
serverId: () => deletionServerId,
|
||||
globalKeys: () => deletionGlobalKeys,
|
||||
ratingKeys: () => deletionRatingKeys,
|
||||
itemIds: () => deletionIds,
|
||||
onEvent: onDeletionEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventM
|
||||
required bool Function() mounted,
|
||||
required String? Function() serverId,
|
||||
required Set<String>? Function() globalKeys,
|
||||
required Set<String>? Function() ratingKeys,
|
||||
required Set<String>? Function() itemIds,
|
||||
required void Function(E event) onEvent,
|
||||
}) {
|
||||
return notifier.stream.listen((event) {
|
||||
@@ -28,8 +28,8 @@ StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventM
|
||||
return;
|
||||
}
|
||||
|
||||
final rk = ratingKeys();
|
||||
if (rk == null || event.affectsAnyOf(rk)) {
|
||||
final ids = itemIds();
|
||||
if (ids == null || event.affectsAnyOf(ids)) {
|
||||
onEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Mixin for screens that need to update individual items after watch state changes
|
||||
///
|
||||
@@ -8,9 +8,10 @@ import '../models/plex_metadata.dart';
|
||||
/// and replacing items in lists, while allowing each screen to customize
|
||||
/// which lists should be updated.
|
||||
mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
/// The Plex client to use for fetching updated metadata
|
||||
/// Each screen must provide access to their client
|
||||
PlexClient get client;
|
||||
/// Override to enable backend-aware item refresh. [updateItem] resolves
|
||||
/// the right [MediaServerClient] for the item's server. When null,
|
||||
/// [updateItem] is a no-op.
|
||||
String? get itemServerId => null;
|
||||
|
||||
/// Updates a single item in the screen's list(s) after watch state changes
|
||||
///
|
||||
@@ -19,12 +20,14 @@ mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// If the fetch fails, the error is silently caught and the item will
|
||||
/// be updated on the next full refresh.
|
||||
Future<void> updateItem(String ratingKey) async {
|
||||
Future<void> updateItem(String itemId) async {
|
||||
try {
|
||||
final updatedMetadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (updatedMetadata != null) {
|
||||
final serverId = itemServerId;
|
||||
if (serverId == null) return;
|
||||
final updatedItem = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId);
|
||||
if (updatedItem != null) {
|
||||
setState(() {
|
||||
updateItemInLists(ratingKey, updatedMetadata);
|
||||
updateItemInLists(itemId, updatedItem);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -40,12 +43,12 @@ mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// @override
|
||||
/// void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
/// final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
/// void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
/// final index = _items.indexWhere((item) => item.id == itemId);
|
||||
/// if (index != -1) {
|
||||
/// _items[index] = updatedMetadata;
|
||||
/// _items[index] = updatedItem;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata);
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Mixin providing common functionality for library tab screens
|
||||
/// Provides server-specific client resolution for multi-server support
|
||||
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// The library being displayed
|
||||
PlexLibrary get library;
|
||||
MediaLibrary get library;
|
||||
|
||||
/// Get the correct PlexClient for this library's server
|
||||
/// Throws an exception if no client is available
|
||||
PlexClient getClientForLibrary() => context.getClientForLibrary(library);
|
||||
/// Get the [PlexClient] for this library's server. Throws if unavailable.
|
||||
/// Use [getMediaClientForLibrary] in code paths that work for both Plex
|
||||
/// and Jellyfin via the [MediaServerClient] interface — this getter is
|
||||
/// for Plex-only methods (collections, metadata edit, etc.).
|
||||
PlexClient getClientForLibrary() => context.getPlexClientForLibrary(library);
|
||||
|
||||
/// Get a backend-neutral [MediaServerClient] for this library's server.
|
||||
/// Throws if unavailable. Prefer this over [getClientForLibrary] for any
|
||||
/// flow that doesn't strictly need Plex-only APIs.
|
||||
MediaServerClient getMediaClientForLibrary() => context.getMediaClientForLibrary(library);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/plex_http_client.dart';
|
||||
import '../utils/plex_http_exception.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
|
||||
/// Sparse-loading state + fetch orchestration for paginated item grids/lists.
|
||||
///
|
||||
@@ -22,7 +22,7 @@ import '../utils/plex_http_exception.dart';
|
||||
/// 3. On dispose, subclass calls [disposePagination].
|
||||
mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// Sparse map of loaded items, keyed by position.
|
||||
final Map<int, PlexMetadata> loadedItems = {};
|
||||
final Map<int, MediaItem> loadedItems = {};
|
||||
|
||||
/// Total items on the server. 0 until the first page completes.
|
||||
int totalSize = 0;
|
||||
@@ -43,12 +43,12 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
VoidCallback? _scheduledRetry;
|
||||
|
||||
/// Fetch a page of items. Subclass implements this — typically delegating
|
||||
/// to a paginated `PlexClient` method that returns a [LibraryContentResult].
|
||||
Future<LibraryContentResult> fetchPage(int start, int size, AbortController? abort);
|
||||
/// to a paginated client method that returns a [LibraryPage] of [MediaItem].
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort);
|
||||
|
||||
/// Hook fired after each successful page merge. Default: no-op.
|
||||
/// Override for image prefetch, syncing a base-class `items` list, etc.
|
||||
void onPageLoaded(int start, List<PlexMetadata> items) {}
|
||||
void onPageLoaded(int start, List<MediaItem> items) {}
|
||||
|
||||
/// Synchronously clear pagination state and bump the generation counter.
|
||||
/// Call from inside the subclass's `setState` before awaiting
|
||||
@@ -69,7 +69,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
|
||||
/// Fetch the first page. Await from outside `setState`. Mutates
|
||||
/// [loadedItems] and [totalSize] on success; throws on failure.
|
||||
Future<LibraryContentResult> loadInitialPage(int pageSize) async {
|
||||
Future<LibraryPage<MediaItem>> loadInitialPage(int pageSize) async {
|
||||
final generation = _requestId;
|
||||
final result = await fetchPage(0, pageSize, _cancelToken);
|
||||
if (generation != _requestId || !mounted) return result;
|
||||
@@ -77,7 +77,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[i] = result.items[i];
|
||||
}
|
||||
totalSize = result.totalSize;
|
||||
totalSize = result.totalCount;
|
||||
onPageLoaded(0, result.items);
|
||||
return result;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// [totalSize] even if [index] wasn't in the sparse map (evicted).
|
||||
void removeLoadedItemAndShift(int index) {
|
||||
loadedItems.remove(index);
|
||||
final shifted = <int, PlexMetadata>{};
|
||||
final shifted = <int, MediaItem>{};
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.key > index) {
|
||||
shifted[entry.key - 1] = entry.value;
|
||||
@@ -229,14 +229,14 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[start + i] = result.items[i];
|
||||
}
|
||||
if (result.totalSize != totalSize) totalSize = result.totalSize;
|
||||
if (result.totalCount != totalSize) totalSize = result.totalCount;
|
||||
});
|
||||
|
||||
_retryCount = 0;
|
||||
onPageLoaded(start, result.items);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false;
|
||||
if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false;
|
||||
_retryCount++;
|
||||
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
|
||||
_retryTimer?.cancel();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Shared helpers for screens bound to a single [PlexMetadata] item/server.
|
||||
/// Shared helpers for screens bound to a single [MediaItem]/server.
|
||||
mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
|
||||
PlexMetadata get serverBoundMetadata;
|
||||
MediaItem get serverBoundMetadata;
|
||||
|
||||
bool get isServerBoundOffline => false;
|
||||
|
||||
@@ -16,6 +17,16 @@ mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
|
||||
String toServerBoundGlobalKey(String ratingKey, {String? serverId}) =>
|
||||
buildGlobalKey(serverId ?? serverBoundServerId ?? '', ratingKey);
|
||||
|
||||
PlexClient? getServerBoundClient(BuildContext context) =>
|
||||
context.getClientForMetadataOrNull(serverBoundMetadata, isOffline: isServerBoundOffline);
|
||||
/// Returns the [PlexClient] for the bound server, or null when offline /
|
||||
/// the server is Jellyfin / not registered. Use [getServerBoundMediaClient]
|
||||
/// for backend-neutral flows.
|
||||
PlexClient? getServerBoundPlexClient(BuildContext context) {
|
||||
if (isServerBoundOffline) return null;
|
||||
return context.tryGetPlexClientForServer(serverBoundMetadata.serverId);
|
||||
}
|
||||
|
||||
/// Returns a backend-neutral [MediaServerClient] for the bound server, or
|
||||
/// null when offline / not registered.
|
||||
MediaServerClient? getServerBoundMediaClient(BuildContext context) =>
|
||||
context.getMediaClientForItemOrNull(serverBoundMetadata, isOffline: isServerBoundOffline);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import '../widgets/focusable_tab_chip.dart';
|
||||
///
|
||||
/// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab.
|
||||
mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderStateMixin<T> {
|
||||
late final TabController tabController;
|
||||
/// Mutable so [initTabNavigation] can be called more than once during a
|
||||
/// single State lifetime — the libraries screen rebuilds the controller
|
||||
/// when the visible tab set changes (Jellyfin shows Browse only;
|
||||
/// switching back to a Plex library goes from 1 tab to 4).
|
||||
late TabController tabController;
|
||||
|
||||
/// When true, suppress auto-focus in tabs (used when navigating via tab bar).
|
||||
bool suppressAutoFocus = false;
|
||||
|
||||
@@ -11,16 +11,16 @@ import 'event_aware.dart';
|
||||
/// Example usage:
|
||||
/// ```dart
|
||||
/// class _MyScreenState extends State<MyScreen> with WatchStateAware {
|
||||
/// List<PlexMetadata> _items = [];
|
||||
/// List<MediaItem> _items = [];
|
||||
///
|
||||
/// @override
|
||||
/// Set<String>? get watchedRatingKeys =>
|
||||
/// _items.map((e) => e.ratingKey).toSet();
|
||||
/// Set<String>? get watchedIds =>
|
||||
/// _items.map((e) => e.id).toSet();
|
||||
///
|
||||
/// @override
|
||||
/// void onWatchStateChanged(WatchStateEvent event) {
|
||||
/// // Refresh affected item
|
||||
/// _refreshItem(event.ratingKey);
|
||||
/// _refreshItem(event.itemId);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@@ -35,22 +35,22 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
|
||||
/// Override to specify which global keys this screen cares about.
|
||||
///
|
||||
/// Use format `serverId:ratingKey`.
|
||||
/// Return null to fall back to [watchedRatingKeys] matching.
|
||||
/// Return null to fall back to [watchedIds] matching.
|
||||
Set<String>? get watchedGlobalKeys => null;
|
||||
|
||||
/// Override to specify which ratingKeys this screen cares about.
|
||||
/// Override to specify which item ids this screen cares about.
|
||||
///
|
||||
/// Return null to receive ALL events (not recommended for performance).
|
||||
/// Return an empty set to receive no events.
|
||||
///
|
||||
/// The set should include:
|
||||
/// - Direct items displayed (e.g., episode ratingKeys in a season view)
|
||||
/// - Parent items that affect display (e.g., show ratingKey for on-deck)
|
||||
Set<String>? get watchedRatingKeys;
|
||||
/// - Direct items displayed (e.g., episode ids in a season view)
|
||||
/// - Parent items that affect display (e.g., show id for on-deck)
|
||||
Set<String>? get watchedIds;
|
||||
|
||||
/// Called when a relevant watch state change occurs.
|
||||
///
|
||||
/// Only called if [watchedRatingKeys] is null or contains an affected key.
|
||||
/// Only called if [watchedIds] is null or contains an affected key.
|
||||
void onWatchStateChanged(WatchStateEvent event);
|
||||
|
||||
@override
|
||||
@@ -61,7 +61,7 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
|
||||
mounted: () => mounted,
|
||||
serverId: () => watchStateServerId,
|
||||
globalKeys: () => watchedGlobalKeys,
|
||||
ratingKeys: () => watchedRatingKeys,
|
||||
itemIds: () => watchedIds,
|
||||
onEvent: onWatchStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user