From fce794cd6c12bc65fcd7e33949064d82befdb7b3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 13 May 2026 15:17:38 +0200 Subject: [PATCH] fix(discover): preserve library order on home close #1034 --- lib/screens/discover_screen.dart | 66 +++++++++++------- lib/utils/media_hub_ordering.dart | 56 +++++++++++++++ test/utils/media_hub_ordering_test.dart | 92 +++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 lib/utils/media_hub_ordering.dart create mode 100644 test/utils/media_hub_ordering_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d32de005..7970d049 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 bool _isAutoScrollPaused = false; bool _isTabVisible = true; HiddenLibrariesProvider? _hiddenLibrariesProvider; + LibrariesProvider? _librariesProvider; Set _lastSeenHiddenKeys = {}; + List _lastSeenLibraryOrderKeys = const []; // WatchStateAware: watch on-deck items and their parent shows/seasons @override @@ -308,6 +311,13 @@ class _DiscoverScreenState extends State _hiddenLibrariesProvider = provider; _hiddenLibrariesProvider!.addListener(_onHiddenLibrariesChanged); } + final librariesProvider = context.read(); + if (librariesProvider != _librariesProvider) { + _librariesProvider?.removeListener(_onLibrariesChanged); + _librariesProvider = librariesProvider; + _lastSeenLibraryOrderKeys = _libraryOrderKeys(librariesProvider); + _librariesProvider!.addListener(_onLibrariesChanged); + } } void _onHiddenLibrariesChanged() { @@ -319,6 +329,34 @@ class _DiscoverScreenState extends State _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.from(_hubs); + if (!sortMediaHubsByLibraryOrder(sortedHubs, provider.libraries)) return; + setState(() { + _hubs = sortedHubs; + _updateHubKeys(); + }); + } + + List _libraryOrderKeys(LibrariesProvider provider) { + return [for (final library in provider.libraries) library.globalKey]; + } + + bool _sameStringList(List a, List 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 @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 !title.contains('next up'); }).toList(); - // Sort hubs by the user's library order final libraryOrder = context.read().libraries; - if (!useGlobalHubs && libraryOrder.isNotEmpty) { - final orderMap = {}; - 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 } } - /// 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 _refreshContinueWatching() async { diff --git a/lib/utils/media_hub_ordering.dart b/lib/utils/media_hub_ordering.dart new file mode 100644 index 00000000..f090ebde --- /dev/null +++ b/lib/utils/media_hub_ordering.dart @@ -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 hubs, List libraryOrder) { + if (hubs.length < 2 || libraryOrder.isEmpty) return false; + + final orderByGlobalKey = {}; + 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 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); +} diff --git a/test/utils/media_hub_ordering_test.dart b/test/utils/media_hub_ordering_test.dart new file mode 100644 index 00000000..6002fd5d --- /dev/null +++ b/test/utils/media_hub_ordering_test.dart @@ -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 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']); + }); + }); +}