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:
edde746
2026-08-05 06:09:26 +02:00
parent f36e20bcad
commit 05fd622968
128 changed files with 4917 additions and 1429 deletions
+9 -9
View File
@@ -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 (_) {