feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
This commit is contained in:
@@ -52,7 +52,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
|
||||
/// Folders/items returned by the backend's folder API and mapped to neutral
|
||||
/// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key'];
|
||||
/// Jellyfin folders use the item id as their recursive parent id.
|
||||
/// MediaBrowser folders use the item id as their recursive parent id.
|
||||
List<MediaItem> _rootFolders = [];
|
||||
final Map<String, List<MediaItem>> _childrenCache = {};
|
||||
final Set<String> _expandedFolders = {};
|
||||
@@ -60,9 +60,9 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
bool _isLoadingRoot = false;
|
||||
String? _errorMessage;
|
||||
|
||||
/// Generation counter for in-flight loads. Jellyfin folder fetches render
|
||||
/// page-by-page via `onPage`; a root reload or deletion refresh bumps the
|
||||
/// epoch so superseded pagination callbacks are dropped.
|
||||
/// Generation counter for in-flight loads. MediaBrowser folder fetches
|
||||
/// render page-by-page via `onPage`; a root reload or deletion refresh
|
||||
/// bumps the epoch so superseded pagination callbacks are dropped.
|
||||
int _loadEpoch = 0;
|
||||
|
||||
/// Stable expand/cache key for an expandable row: the backend folder key
|
||||
@@ -250,7 +250,7 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
/// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution.
|
||||
Future<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
|
||||
final MediaListPlaybackLauncher launcher;
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
if (folder.backend.usesMediaBrowserApi) {
|
||||
launcher = JellyfinSequentialLauncher(context: context);
|
||||
} else {
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
@@ -259,22 +259,22 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
|
||||
}
|
||||
|
||||
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
||||
/// Expandable rows: directory rows plus MediaBrowser media containers whose
|
||||
/// direct children form the folder tree. Music libraries expose folder-
|
||||
/// backed artists and albums as MusicArtist/MusicAlbum rather than generic
|
||||
/// Folder DTOs, so those rows must expand instead of opening empty details.
|
||||
bool _isExpandable(MediaItem item) {
|
||||
return item.kind == MediaKind.folder || (item.backend == MediaBackend.jellyfin && _isJellyfinMediaContainer(item));
|
||||
return item.kind == MediaKind.folder || (item.backend.usesMediaBrowserApi && _isMediaBrowserMediaContainer(item));
|
||||
}
|
||||
|
||||
bool _isJellyfinMediaContainer(MediaItem item) {
|
||||
bool _isMediaBrowserMediaContainer(MediaItem item) {
|
||||
if (item.kind == MediaKind.show || item.kind == MediaKind.season) return true;
|
||||
return widget.libraryKind?.isMusic == true && (item.kind == MediaKind.artist || item.kind == MediaKind.album);
|
||||
}
|
||||
|
||||
bool _canPlayFolder(MediaItem item) {
|
||||
if (item.backend == MediaBackend.plex) return true;
|
||||
if (item.backend == MediaBackend.jellyfin) return widget.libraryKind?.isMusic != true;
|
||||
if (item.backend.usesMediaBrowserApi) return widget.libraryKind?.isMusic != true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import 'alpha_jump_helper.dart';
|
||||
/// driven — tapping a letter scrolls to that letter's cumulative offset and
|
||||
/// the highlighted letter follows the visible row.
|
||||
///
|
||||
/// Jellyfin libraries have no per-letter count endpoint. The bar synthesises
|
||||
/// the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix filter
|
||||
/// that refetches the page when the user picks a letter (matches the JF web
|
||||
/// client's UX).
|
||||
/// MediaBrowser libraries have no per-letter count endpoint. The bar
|
||||
/// synthesises the 27-letter alphabet (`#`, `A`–`Z`) and acts as a name-prefix
|
||||
/// filter that refetches the page when the user picks a letter (matching the
|
||||
/// server web clients' UX).
|
||||
abstract class LibraryAlphaBarStrategy {
|
||||
/// Whether the bar should be rendered at all. Implementations consider
|
||||
/// total item count, sort key, and current filter state.
|
||||
@@ -22,7 +22,7 @@ abstract class LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
});
|
||||
|
||||
@@ -36,22 +36,22 @@ abstract class LibraryAlphaBarStrategy {
|
||||
});
|
||||
|
||||
/// Letter to highlight given the current scroll-derived index. Plex maps
|
||||
/// the index back through the cumulative offsets; Jellyfin echoes back
|
||||
/// whatever filter is active.
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix});
|
||||
/// the index back through the cumulative offsets; MediaBrowser backends echo
|
||||
/// back whatever filter is active.
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix});
|
||||
|
||||
/// Handle a tap on the letter at [targetIndex]. Plex strategies invoke
|
||||
/// [onPlexJump] with the cumulative item index for in-grid scrolling;
|
||||
/// Jellyfin strategies invoke [onJellyfinPrefixChange] with the next
|
||||
/// MediaBrowser strategies invoke [onMediaBrowserPrefixChange] with the next
|
||||
/// `NameStartsWith` prefix (or `null` to clear the filter when the user
|
||||
/// re-taps the active letter). Each strategy ignores the callback that
|
||||
/// doesn't apply to its UX, so callers can wire both unconditionally.
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
});
|
||||
|
||||
/// Construct the right strategy for [backend].
|
||||
@@ -67,7 +67,7 @@ abstract class LibraryAlphaBarStrategy {
|
||||
libraryKey: libraryKey,
|
||||
isShared: isShared,
|
||||
),
|
||||
MediaBackend.jellyfin => const JellyfinAlphaBarStrategy(),
|
||||
MediaBackend.jellyfin || MediaBackend.emby => const MediaBrowserAlphaBarStrategy(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
}) {
|
||||
if (isFolderGrouping) return false;
|
||||
@@ -115,7 +115,8 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
}
|
||||
|
||||
@override
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => helper.currentLetter(index);
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||||
helper.currentLetter(index);
|
||||
|
||||
/// Plex jumps the grid to the cumulative offset for the tapped letter —
|
||||
/// the helper's letter list already encodes the per-letter ranges from
|
||||
@@ -124,17 +125,17 @@ class PlexAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
}) {
|
||||
onPlexJump(targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin strategy — synthesises the 27-letter alphabet locally and uses
|
||||
/// the bar as a `NameStartsWith` filter.
|
||||
class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
/// MediaBrowser strategy — synthesises the 27-letter alphabet locally and
|
||||
/// uses the bar as a `NameStartsWith` filter.
|
||||
class MediaBrowserAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
static const _letters = [
|
||||
'#',
|
||||
'A',
|
||||
@@ -165,7 +166,7 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
'Z',
|
||||
];
|
||||
|
||||
const JellyfinAlphaBarStrategy();
|
||||
const MediaBrowserAlphaBarStrategy();
|
||||
|
||||
@override
|
||||
bool shouldShow({
|
||||
@@ -173,13 +174,13 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
required int loadedCharacterCount,
|
||||
required String? sortKey,
|
||||
required bool isFolderGrouping,
|
||||
required String? jellyfinAlphaPrefix,
|
||||
required String? mediaBrowserAlphaPrefix,
|
||||
required bool isPhone,
|
||||
}) {
|
||||
if (isPhone) return false;
|
||||
if (isFolderGrouping) return false;
|
||||
if (loadedCharacterCount == 0) return false;
|
||||
return totalItemCount >= 80 || jellyfinAlphaPrefix != null;
|
||||
return totalItemCount >= 80 || mediaBrowserAlphaPrefix != null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -193,23 +194,24 @@ class JellyfinAlphaBarStrategy implements LibraryAlphaBarStrategy {
|
||||
}
|
||||
|
||||
@override
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? jellyfinAlphaPrefix}) => jellyfinAlphaPrefix ?? '';
|
||||
String currentLetter(int index, AlphaJumpHelper helper, {String? mediaBrowserAlphaPrefix}) =>
|
||||
mediaBrowserAlphaPrefix ?? '';
|
||||
|
||||
/// Jellyfin reuses the alpha bar as a `NameStartsWith` filter. We map the
|
||||
/// bar offset back to a letter (the synthesised `size: 1` entries make
|
||||
/// offset == position in [helper.letters]) and toggle the filter — re-tap
|
||||
/// the active letter to clear, otherwise set the new prefix.
|
||||
/// MediaBrowser backends reuse the alpha bar as a `NameStartsWith` filter.
|
||||
/// We map the bar offset back to a letter (the synthesised `size: 1` entries
|
||||
/// make offset == position in [helper.letters]) and toggle the filter —
|
||||
/// re-tap the active letter to clear, otherwise set the new prefix.
|
||||
@override
|
||||
void onLetterPressed(
|
||||
int targetIndex,
|
||||
AlphaJumpHelper helper, {
|
||||
required String? currentJellyfinPrefix,
|
||||
required String? currentMediaBrowserPrefix,
|
||||
required void Function(int index) onPlexJump,
|
||||
required void Function(String? nextPrefix) onJellyfinPrefixChange,
|
||||
required void Function(String? nextPrefix) onMediaBrowserPrefixChange,
|
||||
}) {
|
||||
if (targetIndex < 0 || targetIndex >= helper.letters.length) return;
|
||||
final letter = helper.letters[targetIndex];
|
||||
final next = (currentJellyfinPrefix == letter) ? null : letter;
|
||||
onJellyfinPrefixChange(next);
|
||||
final next = (currentMediaBrowserPrefix == letter) ? null : letter;
|
||||
onMediaBrowserPrefixChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../media/library_first_character.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_backend.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../media/media_kind.dart';
|
||||
import '../../../media/media_library.dart';
|
||||
@@ -211,15 +210,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
AlphaJumpHelper _alphaHelper = AlphaJumpHelper(const []);
|
||||
late LibraryAlphaBarStrategy _alphaStrategy = _createAlphaStrategy();
|
||||
|
||||
/// On Jellyfin libraries the alpha bar acts as a filter (matches the
|
||||
/// JF web client's UX). Holds the active letter (`#`, `A`–`Z`) or null
|
||||
/// when no filter is applied.
|
||||
String? _jellyfinAlphaPrefix;
|
||||
/// On MediaBrowser libraries the alpha bar acts as a filter. Holds the
|
||||
/// active letter (`#`, `A`–`Z`) or null when no filter is applied.
|
||||
String? _mediaBrowserAlphaPrefix;
|
||||
|
||||
/// Pre-fetched filter values for Jellyfin libraries — populated by
|
||||
/// Pre-fetched filter values for MediaBrowser libraries — populated by
|
||||
/// `_loadContent` and consumed by the FiltersBottomSheet so the sheet
|
||||
/// doesn't need to call back into a Plex client for value listings.
|
||||
Map<String, List<MediaFilterValue>> _jellyfinFilterValues = const {};
|
||||
Map<String, List<MediaFilterValue>> _mediaBrowserFilterValues = const {};
|
||||
final ValueNotifier<int> _currentFirstVisibleIndex = ValueNotifier<int>(0);
|
||||
LibraryAlphaScrollMetrics _scrollMetrics = LibraryAlphaScrollMetrics.empty;
|
||||
|
||||
@@ -257,7 +255,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
int _firstCharactersRequestId = 0;
|
||||
static const int _fetchSize = 200;
|
||||
static const int _jellyfinFetchSize = 72;
|
||||
static const int _mediaBrowserFetchSize = 72;
|
||||
Timer? _scrollIdleTimer;
|
||||
bool _rangeLoadScheduled = false;
|
||||
bool _topScrollResetScheduled = false;
|
||||
@@ -306,8 +304,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isJellyfinLibrary => widget.library.backend == MediaBackend.jellyfin;
|
||||
int get _activeFetchSize => _isJellyfinLibrary ? _jellyfinFetchSize : _fetchSize;
|
||||
bool get _isMediaBrowserLibrary => widget.library.backend.usesMediaBrowserApi;
|
||||
int get _activeFetchSize => _isMediaBrowserLibrary ? _mediaBrowserFetchSize : _fetchSize;
|
||||
|
||||
// Focus nodes for filter chips
|
||||
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
|
||||
@@ -518,9 +516,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_currentFirstVisibleIndex.value = 0;
|
||||
|
||||
// Plex returns categories from `/library/sections/{id}/filters` +
|
||||
// `/sorts`; Jellyfin maps `/Items/Filters` into the same shape with
|
||||
// values pre-cached and a hardcoded client-side sort list. Both flow
|
||||
// through the unified [MediaServerClient.fetchLibraryFiltersWithValues].
|
||||
// `/sorts`; MediaBrowser clients map their filter endpoints into the same
|
||||
// shape with values pre-cached and a hardcoded client-side sort list. Both
|
||||
// flow through [MediaServerClient.fetchLibraryFiltersWithValues].
|
||||
try {
|
||||
final client = context.getMediaClientForLibrary(library);
|
||||
final loader = LibraryFilterSortLoader(clientFor: (_) => client);
|
||||
@@ -536,10 +534,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final sortLibraryType = _sortOptionsLibraryType(restoredGrouping);
|
||||
|
||||
final LoadedFiltersAndSorts loaded;
|
||||
if (library.backend == MediaBackend.jellyfin) {
|
||||
// `/Items/Filters` can be much slower than the paged `/Items` browse
|
||||
// request on large Jellyfin libraries. Load only the local sort list
|
||||
// before page 1, then fill filter values in the background.
|
||||
if (library.backend.usesMediaBrowserApi) {
|
||||
// MediaBrowser filter discovery can be much slower than the paged
|
||||
// `/Items` browse request on large libraries. Load only the local sort
|
||||
// list before page 1, then fill filter values in the background.
|
||||
final sorts = await client.fetchSortOptions(library.id, libraryType: sortLibraryType);
|
||||
loaded = LoadedFiltersAndSorts(filters: const [], sorts: sorts);
|
||||
} else {
|
||||
@@ -554,8 +552,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_filters = loaded.filters;
|
||||
_sortOptions = loaded.sorts;
|
||||
// Plex returns no cached values (filters fetched lazily per-category);
|
||||
// assigning the empty map is a no-op for Plex and a real payload for Jellyfin.
|
||||
_jellyfinFilterValues = loaded.cachedValues;
|
||||
// assigning the empty map is a no-op for Plex and a real payload for MediaBrowser libraries.
|
||||
_mediaBrowserFilterValues = loaded.cachedValues;
|
||||
_selectedFilters = Map.from(savedFilters);
|
||||
_selectedGrouping = restoredGrouping;
|
||||
|
||||
@@ -573,8 +571,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
_notifyFiltersActive();
|
||||
|
||||
if (library.backend == MediaBackend.jellyfin) {
|
||||
_loadJellyfinFiltersInBackground(generation, libraryGlobalKey, library);
|
||||
if (library.backend.usesMediaBrowserApi) {
|
||||
_loadMediaBrowserFiltersInBackground(generation, libraryGlobalKey, library);
|
||||
}
|
||||
|
||||
// Load items and first characters in parallel.
|
||||
@@ -593,7 +591,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
}
|
||||
}
|
||||
|
||||
void _loadJellyfinFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
|
||||
void _loadMediaBrowserFiltersInBackground(int generation, String libraryGlobalKey, MediaLibrary library) {
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(library.serverId));
|
||||
if (client == null) return;
|
||||
unawaited(
|
||||
@@ -603,12 +601,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
setState(() {
|
||||
_filters = result.filters;
|
||||
_jellyfinFilterValues = result.cachedValues;
|
||||
_mediaBrowserFilterValues = result.cachedValues;
|
||||
});
|
||||
})
|
||||
.catchError((Object e, StackTrace st) {
|
||||
if (!isCurrentLibraryLoad(generation, libraryGlobalKey)) return;
|
||||
appLogger.w('Jellyfin library filters failed; browse content remains available', error: e, stackTrace: st);
|
||||
appLogger.w(
|
||||
'MediaBrowser library filters failed; browse content remains available',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -627,7 +629,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
});
|
||||
}
|
||||
|
||||
/// Initial UI state both Plex and Jellyfin paths need before fetching:
|
||||
/// Initial UI state both Plex and MediaBrowser paths need before fetching:
|
||||
/// loading flag set, lists cleared, filter/sort caches reset.
|
||||
void _resetTopOfPageState() {
|
||||
setState(() {
|
||||
@@ -637,8 +639,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
resetPaginationState();
|
||||
_filters = [];
|
||||
_sortOptions = [];
|
||||
_jellyfinFilterValues = const {};
|
||||
_jellyfinAlphaPrefix = null;
|
||||
_mediaBrowserFilterValues = const {};
|
||||
_mediaBrowserAlphaPrefix = null;
|
||||
_selectedFilters = {};
|
||||
_selectedSort = null;
|
||||
_isSortDescending = false;
|
||||
@@ -673,10 +675,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
|
||||
filterParams['includeCollections'] = '1';
|
||||
|
||||
// Jellyfin alpha-bar filter — picked up by DataAggregationService and
|
||||
// converted to NameStartsWith / NameLessThan on the wire.
|
||||
if (_jellyfinAlphaPrefix != null) {
|
||||
filterParams['alphaPrefix'] = _jellyfinAlphaPrefix!;
|
||||
// MediaBrowser alpha-bar filter — converted to NameStartsWith /
|
||||
// NameLessThan on the wire.
|
||||
if (_mediaBrowserAlphaPrefix != null) {
|
||||
filterParams['alphaPrefix'] = _mediaBrowserAlphaPrefix!;
|
||||
}
|
||||
|
||||
return filterParams;
|
||||
@@ -1008,10 +1010,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
libraryKey: widget.library.globalKey,
|
||||
loadFilterValues: _loadFilterValues,
|
||||
onBack: onBack,
|
||||
// Pre-populated values arrive only from backends that bundle them
|
||||
// with the category listing (Jellyfin's `/Items/Filters`). The empty
|
||||
// map for Plex libraries falls through to lazy `getFilterValues`.
|
||||
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
|
||||
// Pre-populated values arrive from MediaBrowser filter discovery. The
|
||||
// empty map for Plex libraries falls through to lazy `getFilterValues`.
|
||||
cachedValues: _mediaBrowserFilterValues.isEmpty ? null : _mediaBrowserFilterValues,
|
||||
onFiltersChanged: _applyFilters,
|
||||
);
|
||||
}
|
||||
@@ -1039,9 +1040,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
if (client != null) return client.getFilterValues(filter.key);
|
||||
|
||||
// Jellyfin's canonical filter values come from the cached `/Items/Filters`
|
||||
// payload. If that payload missed a category, there is no neutral endpoint
|
||||
// to query yet, so return an empty list instead of routing to a Plex-only API.
|
||||
// MediaBrowser canonical filter values come from the cached filter
|
||||
// discovery payload. If that payload missed a category, there is no
|
||||
// neutral endpoint to query, so don't route to a Plex-only API.
|
||||
return const [];
|
||||
}
|
||||
|
||||
@@ -1217,7 +1218,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// how many items we've scrolled past relative to the API's cumulative
|
||||
/// firstCharacter counts.
|
||||
String _alphaLetterFor(int index) =>
|
||||
_alphaStrategy.currentLetter(index, _alphaHelper, jellyfinAlphaPrefix: _jellyfinAlphaPrefix);
|
||||
_alphaStrategy.currentLetter(index, _alphaHelper, mediaBrowserAlphaPrefix: _mediaBrowserAlphaPrefix);
|
||||
|
||||
/// Whether the alpha jump bar should be shown.
|
||||
/// Only shown when sorting by title (titleSort) and not in folders mode.
|
||||
@@ -1233,7 +1234,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
loadedCharacterCount: _firstCharacters.length,
|
||||
sortKey: _selectedSort?.key,
|
||||
isFolderGrouping: _selectedGrouping == 'folders',
|
||||
jellyfinAlphaPrefix: _jellyfinAlphaPrefix,
|
||||
mediaBrowserAlphaPrefix: _mediaBrowserAlphaPrefix,
|
||||
isPhone: _isPhone(context),
|
||||
);
|
||||
|
||||
@@ -1351,15 +1352,15 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
/// Handle a tap on the letter at [targetIndex] in the alpha bar. The
|
||||
/// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and
|
||||
/// invokes one of the two callbacks — Plex scrolls the grid to the
|
||||
/// cumulative item offset, Jellyfin toggles a `NameStartsWith` filter
|
||||
/// (matches the JF web client UX).
|
||||
/// cumulative item offset, while MediaBrowser backends toggle a
|
||||
/// `NameStartsWith` filter.
|
||||
void _jumpToIndex(int targetIndex) {
|
||||
_alphaStrategy.onLetterPressed(
|
||||
targetIndex,
|
||||
_alphaHelper,
|
||||
currentJellyfinPrefix: _jellyfinAlphaPrefix,
|
||||
currentMediaBrowserPrefix: _mediaBrowserAlphaPrefix,
|
||||
onPlexJump: _scrollGridToIndex,
|
||||
onJellyfinPrefixChange: _applyJellyfinAlphaPrefix,
|
||||
onMediaBrowserPrefixChange: _applyMediaBrowserAlphaPrefix,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1376,12 +1377,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
_scrollToItemIndex(clamped);
|
||||
}
|
||||
|
||||
/// Apply the new Jellyfin `NameStartsWith` prefix from the alpha bar and
|
||||
/// Apply a MediaBrowser `NameStartsWith` prefix from the alpha bar and
|
||||
/// refetch from the top of the now-filtered dataset. Used by
|
||||
/// [JellyfinAlphaBarStrategy] via [_jumpToIndex].
|
||||
void _applyJellyfinAlphaPrefix(String? nextPrefix) {
|
||||
/// [MediaBrowserAlphaBarStrategy] via [_jumpToIndex].
|
||||
void _applyMediaBrowserAlphaPrefix(String? nextPrefix) {
|
||||
setState(() {
|
||||
_jellyfinAlphaPrefix = nextPrefix;
|
||||
_mediaBrowserAlphaPrefix = nextPrefix;
|
||||
// Clear loaded items + total so the grid blanks while the new filtered
|
||||
// page loads. PaginatedItemLoader internals will repopulate from
|
||||
// offset 0 once the next fetchPage call returns.
|
||||
@@ -1595,8 +1596,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
if (rowHeight <= 0) return _activeFetchSize;
|
||||
final visibleRows = (screenSize.height / rowHeight).ceil() + 1;
|
||||
final visibleCount = visibleRows * columnCount;
|
||||
if (_isJellyfinLibrary) {
|
||||
return (visibleCount * 2).clamp(36, _jellyfinFetchSize).toInt();
|
||||
if (_isMediaBrowserLibrary) {
|
||||
return (visibleCount * 2).clamp(36, _mediaBrowserFetchSize).toInt();
|
||||
}
|
||||
return (visibleCount * 3).clamp(100, 500).toInt();
|
||||
} catch (_) {
|
||||
|
||||
Reference in New Issue
Block a user