Skip to content

Commit 2f62849

Browse files
committed
feat(favorites): support grid view toggle and D-pad row navigation locking
- Restored focus indicator border on MediaCards on Favorites screen. - Introduced FavoritesViewStyle enum (home/library) in preferences. - Added 'Switch View' setting to toggled display settings dialog on the Favorites screen. - Implemented flat grid view layout builder with scroll-to-bottom lazy-loading and focus node mixin. - Upgraded LockedFocusRow key event handling to propagate unhandled vertical focus events instead of swallowing them. - Implemented onVerticalNavigation row key tracking and viewport centering (via Scrollable.ensureVisible) to easily jump between Movie, TV Show, and other category rows on TV/desktop.
1 parent aae216c commit 2f62849

5 files changed

Lines changed: 558 additions & 67 deletions

File tree

lib/data/viewmodels/favorites_view_model.dart

Lines changed: 153 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ class FavoritesViewModel extends ChangeNotifier {
3636
Map<FavoriteTypeFilter, List<AggregatedItem>> _rowItems = {};
3737
Map<FavoriteTypeFilter, List<AggregatedItem>> get rowItems => _rowItems;
3838

39-
int get totalCount =>
40-
_rowItems.values.fold(0, (sum, list) => sum + list.length);
39+
int get totalCount => _viewStyle == FavoritesViewStyle.home
40+
? _rowItems.values.fold(0, (sum, list) => sum + list.length)
41+
: _gridTotalCount;
4142

4243
late LibrarySortBy _sortBy;
4344
LibrarySortBy get sortBy => _sortBy;
@@ -64,6 +65,23 @@ class FavoritesViewModel extends ChangeNotifier {
6465

6566
ImageApi get imageApi => _client.imageApi;
6667

68+
late FavoritesViewStyle _viewStyle;
69+
FavoritesViewStyle get viewStyle => _viewStyle;
70+
71+
List<AggregatedItem> _gridItems = const [];
72+
List<AggregatedItem> get gridItems => _gridItems;
73+
74+
int _gridTotalCount = 0;
75+
int get gridTotalCount => _gridTotalCount;
76+
77+
bool _loadingMoreGrid = false;
78+
bool get loadingMoreGrid => _loadingMoreGrid;
79+
80+
bool get hasMoreGrid => _gridItems.length < _gridTotalCount;
81+
82+
final Map<FavoriteTypeFilter, int> _rowTotalCounts = {};
83+
final Set<FavoriteTypeFilter> _inFlightPagingRowTypes = {};
84+
6785
FavoritesViewModel({
6886
required MediaServerClient client,
6987
required UserPreferences prefs,
@@ -75,6 +93,7 @@ class FavoritesViewModel extends ChangeNotifier {
7593
_sortDirection = _prefs.get(UserPreferences.librarySortDirection(_prefKey));
7694
_imageType = _prefs.get(UserPreferences.libraryImageType(_prefKey));
7795
_posterSize = _prefs.resolveLibraryPosterSize();
96+
_viewStyle = _prefs.get(UserPreferences.favoritesViewStyle);
7897
}
7998

8099
void setFocusedItem(AggregatedItem? item) {
@@ -84,6 +103,13 @@ class FavoritesViewModel extends ChangeNotifier {
84103
if (item != null) _loadFocusedRatings(item);
85104
}
86105

106+
Future<void> setViewStyle(FavoritesViewStyle value) async {
107+
if (_viewStyle == value) return;
108+
_viewStyle = value;
109+
await _prefs.set(UserPreferences.favoritesViewStyle, value);
110+
await load();
111+
}
112+
87113
Future<void> _loadFocusedRatings(AggregatedItem item) async {
88114
if (!_prefs.get(UserPreferences.enableAdditionalRatings)) return;
89115
var tmdbId = item.tmdbId;
@@ -117,18 +143,28 @@ class FavoritesViewModel extends ChangeNotifier {
117143
Future<void> load() async {
118144
_state = FavoritesState.loading;
119145
_rowItems = {};
146+
_rowTotalCounts.clear();
147+
_gridItems = const [];
148+
_gridTotalCount = 0;
120149
_tmdbIdByItemId.clear();
121150
notifyListeners();
122151

123152
try {
124-
final results = await Future.wait(
125-
rowTypes.map((type) => _fetchRowItems(type)),
126-
);
127-
final map = <FavoriteTypeFilter, List<AggregatedItem>>{};
128-
for (var i = 0; i < rowTypes.length; i++) {
129-
if (results[i].isNotEmpty) map[rowTypes[i]] = results[i];
153+
if (_viewStyle == FavoritesViewStyle.home) {
154+
final results = await Future.wait(
155+
rowTypes.map((type) => _fetchRowItems(type, startIndex: 0)),
156+
);
157+
final map = <FavoriteTypeFilter, List<AggregatedItem>>{};
158+
for (var i = 0; i < rowTypes.length; i++) {
159+
if (results[i].$1.isNotEmpty) {
160+
map[rowTypes[i]] = results[i].$1;
161+
_rowTotalCounts[rowTypes[i]] = results[i].$2;
162+
}
163+
}
164+
_rowItems = map;
165+
} else {
166+
await _fetchGridPage(0);
130167
}
131-
_rowItems = map;
132168
_state = FavoritesState.ready;
133169
} catch (e) {
134170
_errorMessage = e.toString();
@@ -137,7 +173,10 @@ class FavoritesViewModel extends ChangeNotifier {
137173
notifyListeners();
138174
}
139175

140-
Future<List<AggregatedItem>> _fetchRowItems(FavoriteTypeFilter type) async {
176+
Future<(List<AggregatedItem>, int)> _fetchRowItems(
177+
FavoriteTypeFilter type, {
178+
int startIndex = 0,
179+
}) async {
141180
try {
142181
final sortValue = _sortBy.apiValue;
143182
final sortOrder = _sortDirection == SortDirection.ascending
@@ -148,6 +187,7 @@ class FavoritesViewModel extends ChangeNotifier {
148187
response = await _client.itemsApi.getItems(
149188
sortBy: sortValue,
150189
sortOrder: sortOrder,
190+
startIndex: startIndex,
151191
limit: _rowLimit,
152192
recursive: true,
153193
isFavorite: true,
@@ -163,6 +203,7 @@ class FavoritesViewModel extends ChangeNotifier {
163203
response = await _client.itemsApi.getItems(
164204
sortBy: fallbackSort,
165205
sortOrder: sortOrder,
206+
startIndex: startIndex,
166207
limit: _rowLimit,
167208
recursive: true,
168209
isFavorite: true,
@@ -172,7 +213,7 @@ class FavoritesViewModel extends ChangeNotifier {
172213
);
173214
}
174215
final rawItems = (response['Items'] as List?) ?? [];
175-
return rawItems
216+
final items = rawItems
176217
.cast<Map<String, dynamic>>()
177218
.map(
178219
(raw) => AggregatedItem(
@@ -182,9 +223,109 @@ class FavoritesViewModel extends ChangeNotifier {
182223
),
183224
)
184225
.toList();
226+
final totalCount = response['TotalRecordCount'] as int? ?? items.length;
227+
return (items, totalCount);
228+
} catch (_) {
229+
return (const <AggregatedItem>[], 0);
230+
}
231+
}
232+
233+
Future<void> loadMoreForType(FavoriteTypeFilter type) async {
234+
final items = _rowItems[type];
235+
if (items == null || items.isEmpty) return;
236+
final total = _rowTotalCounts[type] ?? 0;
237+
if (items.length >= total) return;
238+
if (_inFlightPagingRowTypes.contains(type)) return;
239+
240+
_inFlightPagingRowTypes.add(type);
241+
try {
242+
final currentOffset = items.length;
243+
final (newItems, totalCount) = await _fetchRowItems(type, startIndex: currentOffset);
244+
_rowTotalCounts[type] = totalCount;
245+
246+
if (newItems.isNotEmpty) {
247+
_rowItems[type] = [...items, ...newItems];
248+
notifyListeners();
249+
}
185250
} catch (_) {
186-
return const [];
251+
} finally {
252+
_inFlightPagingRowTypes.remove(type);
253+
}
254+
}
255+
256+
Future<void> _fetchGridPage(int startIndex) async {
257+
final sortValue = _sortBy.apiValue;
258+
final sortOrder = _sortDirection == SortDirection.ascending
259+
? 'Ascending'
260+
: 'Descending';
261+
final pageSize = startIndex == 0 ? 75 : 48;
262+
263+
Map<String, dynamic> response;
264+
try {
265+
response = await _client.itemsApi.getItems(
266+
sortBy: sortValue,
267+
sortOrder: sortOrder,
268+
startIndex: startIndex,
269+
limit: pageSize,
270+
recursive: true,
271+
isFavorite: true,
272+
fields: _browseFields,
273+
);
274+
} on DioException catch (e) {
275+
final statusCode = e.response?.statusCode ?? 0;
276+
if (statusCode < 500) rethrow;
277+
final fallbackSort = sortValue.toLowerCase().contains('isfolder')
278+
? 'SortName'
279+
: sortValue;
280+
response = await _client.itemsApi.getItems(
281+
sortBy: fallbackSort,
282+
sortOrder: sortOrder,
283+
startIndex: startIndex,
284+
limit: pageSize,
285+
recursive: true,
286+
isFavorite: true,
287+
fields: _browseFields,
288+
enableTotalRecordCount: false,
289+
);
290+
}
291+
292+
final rawItems = (response['Items'] as List?) ?? [];
293+
final totalFromServer = response['TotalRecordCount'] as int?;
294+
if (totalFromServer != null) {
295+
_gridTotalCount = totalFromServer;
296+
} else {
297+
_gridTotalCount = startIndex + rawItems.length + (rawItems.length == pageSize ? 1 : 0);
187298
}
299+
300+
final mapped = rawItems
301+
.cast<Map<String, dynamic>>()
302+
.map(
303+
(raw) => AggregatedItem(
304+
id: raw['Id'] as String,
305+
serverId: _client.baseUrl,
306+
rawData: raw,
307+
),
308+
)
309+
.toList();
310+
311+
if (startIndex == 0) {
312+
_gridItems = mapped;
313+
} else {
314+
_gridItems = [..._gridItems, ...mapped];
315+
}
316+
}
317+
318+
Future<void> loadMoreGrid() async {
319+
if (_loadingMoreGrid || !hasMoreGrid) return;
320+
_loadingMoreGrid = true;
321+
notifyListeners();
322+
323+
try {
324+
await _fetchGridPage(_gridItems.length);
325+
} catch (_) {}
326+
327+
_loadingMoreGrid = false;
328+
notifyListeners();
188329
}
189330

190331
Future<void> setSortBy(LibrarySortBy value) async {

lib/preference/preference_constants.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ enum PosterSize {
7171
final int landscapeHeight;
7272
}
7373

74+
enum FavoritesViewStyle {
75+
home,
76+
library,
77+
}
78+
7479
enum HomeRowsStyle {
7580
v1,
7681
v2,

lib/preference/user_preferences.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ class UserPreferences extends ChangeNotifier {
272272
defaultValue: PlatformDetection.isTV,
273273
);
274274

275+
static final favoritesViewStyle = EnumPreference(
276+
key: 'pref_favorites_view_style',
277+
defaultValue: FavoritesViewStyle.home,
278+
values: FavoritesViewStyle.values,
279+
);
280+
275281
static final displayFavoritesRows = Preference(
276282
key: 'pref_display_favorites_rows',
277283
defaultValue: false,

0 commit comments

Comments
 (0)