feat: jellyfin

This commit is contained in:
edde746
2026-05-01 01:20:36 +02:00
parent 4080c812d2
commit 31d2d9dc98
408 changed files with 56022 additions and 13747 deletions
+11 -11
View File
@@ -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,
);
}
+3 -3
View File
@@ -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);
}
});
+16 -13
View File
@@ -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);
}
+13 -5
View File
@@ -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);
}
+13 -13
View File
@@ -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();
+16 -5
View File
@@ -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);
}
+5 -1
View File
@@ -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 -11
View File
@@ -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,
);
}