fix(discover): preserve library order on home

close #1034
This commit is contained in:
edde746
2026-05-13 15:26:23 +02:00
parent 8f9947a874
commit fce794cd6c
3 changed files with 188 additions and 26 deletions
+40 -26
View File
@@ -49,6 +49,7 @@ import '../utils/watch_state_notifier.dart';
import '../utils/app_logger.dart';
import '../utils/dialogs.dart';
import '../utils/formatters.dart';
import '../utils/media_hub_ordering.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../utils/layout_constants.dart';
@@ -128,7 +129,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bool _isAutoScrollPaused = false;
bool _isTabVisible = true;
HiddenLibrariesProvider? _hiddenLibrariesProvider;
LibrariesProvider? _librariesProvider;
Set<String> _lastSeenHiddenKeys = {};
List<String> _lastSeenLibraryOrderKeys = const [];
// WatchStateAware: watch on-deck items and their parent shows/seasons
@override
@@ -308,6 +311,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_hiddenLibrariesProvider = provider;
_hiddenLibrariesProvider!.addListener(_onHiddenLibrariesChanged);
}
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider != _librariesProvider) {
_librariesProvider?.removeListener(_onLibrariesChanged);
_librariesProvider = librariesProvider;
_lastSeenLibraryOrderKeys = _libraryOrderKeys(librariesProvider);
_librariesProvider!.addListener(_onLibrariesChanged);
}
}
void _onHiddenLibrariesChanged() {
@@ -319,6 +329,34 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_loadContent();
}
void _onLibrariesChanged() {
final provider = _librariesProvider;
if (provider == null) return;
final currentKeys = _libraryOrderKeys(provider);
if (_sameStringList(currentKeys, _lastSeenLibraryOrderKeys)) return;
_lastSeenLibraryOrderKeys = currentKeys;
if (_hubs.isEmpty || !mounted) return;
final sortedHubs = List<MediaHub>.from(_hubs);
if (!sortMediaHubsByLibraryOrder(sortedHubs, provider.libraries)) return;
setState(() {
_hubs = sortedHubs;
_updateHubKeys();
});
}
List<String> _libraryOrderKeys(LibrariesProvider provider) {
return [for (final library in provider.libraries) library.globalKey];
}
bool _sameStringList(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// Handle key events for the hero section
late final _handleHeroKeyEvent = dpadKeyHandler(
onDown: () {
@@ -348,6 +386,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
void dispose() {
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
_librariesProvider?.removeListener(_onLibrariesChanged);
WidgetsBinding.instance.removeObserver(this);
_autoScrollTimer?.cancel();
_indicatorTimer?.cancel();
@@ -601,24 +640,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
!title.contains('next up');
}).toList();
// Sort hubs by the user's library order
final libraryOrder = context.read<LibrariesProvider>().libraries;
if (!useGlobalHubs && libraryOrder.isNotEmpty) {
final orderMap = <String, int>{};
for (var i = 0; i < libraryOrder.length; i++) {
orderMap[libraryOrder[i].globalKey] = i;
}
filteredHubs.sort((a, b) {
final aKey = _hubLibraryGlobalKey(a);
final bKey = _hubLibraryGlobalKey(b);
final aIndex = aKey != null ? orderMap[aKey] : null;
final bIndex = bKey != null ? orderMap[bKey] : null;
if (aIndex == null && bIndex == null) return 0;
if (aIndex == null) return 1;
if (bIndex == null) return -1;
return aIndex.compareTo(bIndex);
});
}
sortMediaHubsByLibraryOrder(filteredHubs, libraryOrder);
appLogger.d('Received ${onDeck.length} on deck items and ${filteredHubs.length} global hubs from all servers');
if (!mounted) return;
@@ -640,15 +663,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
}
/// Resolve the library globalKey for a hub (for sorting by library order).
String? _hubLibraryGlobalKey(MediaHub hub) {
final serverId = hub.serverId;
if (serverId == null) return null;
final sectionId = hub.libraryId ?? hub.items.firstOrNull?.libraryId;
if (sectionId == null) return null;
return buildGlobalKey(serverId, sectionId);
}
/// Refresh only the Continue Watching section in the background
/// This is called when returning to the home screen to avoid blocking UI
Future<void> _refreshContinueWatching() async {
+56
View File
@@ -0,0 +1,56 @@
import '../media/media_hub.dart';
import '../media/media_library.dart';
import 'global_key_utils.dart';
/// Sorts home hubs by the user's library order. Hubs without a known library
/// stay after known-library hubs, preserving their relative server order.
bool sortMediaHubsByLibraryOrder(List<MediaHub> hubs, List<MediaLibrary> libraryOrder) {
if (hubs.length < 2 || libraryOrder.isEmpty) return false;
final orderByGlobalKey = <String, int>{};
for (var i = 0; i < libraryOrder.length; i++) {
orderByGlobalKey.putIfAbsent(libraryOrder[i].globalKey, () => i);
}
final indexedHubs = [for (var i = 0; i < hubs.length; i++) (index: i, hub: hubs[i])];
indexedHubs.sort((a, b) {
final aIndex = _hubLibraryOrderIndex(a.hub, orderByGlobalKey);
final bIndex = _hubLibraryOrderIndex(b.hub, orderByGlobalKey);
if (aIndex == null && bIndex == null) return a.index.compareTo(b.index);
if (aIndex == null) return 1;
if (bIndex == null) return -1;
final order = aIndex.compareTo(bIndex);
if (order != 0) return order;
return a.index.compareTo(b.index);
});
var changed = false;
for (var i = 0; i < hubs.length; i++) {
final hub = indexedHubs[i].hub;
if (!identical(hubs[i], hub)) changed = true;
hubs[i] = hub;
}
return changed;
}
int? _hubLibraryOrderIndex(MediaHub hub, Map<String, int> orderByGlobalKey) {
final hubLibraryKey = _globalKey(hub.serverId, hub.libraryId);
final hubIndex = hubLibraryKey == null ? null : orderByGlobalKey[hubLibraryKey];
if (hubIndex != null) return hubIndex;
int? bestIndex;
for (final item in hub.items) {
final key = _globalKey(item.serverId ?? hub.serverId, item.libraryId);
final index = key == null ? null : orderByGlobalKey[key];
if (index != null && (bestIndex == null || index < bestIndex)) {
bestIndex = index;
}
}
return bestIndex;
}
String? _globalKey(String? serverId, String? libraryId) {
if (serverId == null || libraryId == null) return null;
return buildGlobalKey(serverId, libraryId);
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_library.dart';
import 'package:plezy/utils/media_hub_ordering.dart';
MediaLibrary _library(String id, {String serverId = 'server'}) {
return MediaLibrary(
id: id,
backend: MediaBackend.plex,
title: 'Library $id',
kind: MediaKind.movie,
serverId: serverId,
);
}
MediaItem _item(String id, {String? libraryId, String? serverId = 'server'}) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, libraryId: libraryId, serverId: serverId);
}
MediaHub _hub(String id, {String? libraryId, String? serverId = 'server', List<MediaItem> items = const []}) {
return MediaHub(id: id, title: id, type: 'movie', libraryId: libraryId, serverId: serverId, items: items);
}
void main() {
group('sortMediaHubsByLibraryOrder', () {
test('sorts explicit library hubs by library order', () {
final hubs = [_hub('movies', libraryId: '1'), _hub('anime', libraryId: '3'), _hub('shows', libraryId: '2')];
final changed = sortMediaHubsByLibraryOrder(hubs, [_library('3'), _library('1'), _library('2')]);
expect(changed, isTrue);
expect(hubs.map((hub) => hub.id), ['anime', 'movies', 'shows']);
});
test('uses item library ids for promoted hubs', () {
final hubs = [
_hub('recent-movies', items: [_item('movie', libraryId: '1')]),
_hub('recent-tv', items: [_item('episode', libraryId: '2')]),
];
final changed = sortMediaHubsByLibraryOrder(hubs, [_library('2'), _library('1')]);
expect(changed, isTrue);
expect(hubs.map((hub) => hub.id), ['recent-tv', 'recent-movies']);
});
test('uses the earliest ordered item library within mixed hubs', () {
final hubs = [
_hub('later', items: [_item('later', libraryId: '3')]),
_hub(
'mixed',
items: [
_item('last', libraryId: '4'),
_item('first', libraryId: '1'),
],
),
];
final changed = sortMediaHubsByLibraryOrder(hubs, [_library('1'), _library('3'), _library('4')]);
expect(changed, isTrue);
expect(hubs.map((hub) => hub.id), ['mixed', 'later']);
});
test('keeps equal and unknown hubs stable after known libraries', () {
final hubs = [
_hub('unknown-a'),
_hub('second-a', libraryId: '2'),
_hub('unknown-b', serverId: null, items: [_item('missing-server', libraryId: '1', serverId: null)]),
_hub('first', libraryId: '1'),
_hub('second-b', libraryId: '2'),
];
final changed = sortMediaHubsByLibraryOrder(hubs, [_library('1'), _library('2')]);
expect(changed, isTrue);
expect(hubs.map((hub) => hub.id), ['first', 'second-a', 'second-b', 'unknown-a', 'unknown-b']);
});
test('returns false when the current order is already correct', () {
final hubs = [_hub('first', libraryId: '1'), _hub('second', libraryId: '2')];
final changed = sortMediaHubsByLibraryOrder(hubs, [_library('1'), _library('2')]);
expect(changed, isFalse);
expect(hubs.map((hub) => hub.id), ['first', 'second']);
});
});
}