280 lines
8.9 KiB
Dart
280 lines
8.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../../../i18n/strings.g.dart';
|
|
import '../../../media/media_hub.dart';
|
|
import '../../../media/media_item.dart';
|
|
import '../../../mixins/item_updatable.dart';
|
|
import '../../../mixins/watch_state_aware.dart';
|
|
import '../../../utils/global_key_utils.dart';
|
|
import '../../../utils/provider_extensions.dart';
|
|
import '../../../utils/watch_state_notifier.dart';
|
|
import '../../../widgets/hub_section.dart';
|
|
import '../../main_screen.dart';
|
|
import 'base_library_tab.dart';
|
|
|
|
/// Recommended tab for library screen
|
|
/// Shows library-specific hubs and recommendations, including dedicated Continue Watching
|
|
class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
|
const LibraryRecommendedTab({
|
|
super.key,
|
|
required super.library,
|
|
super.onDataLoaded,
|
|
super.isActive,
|
|
super.suppressAutoFocus,
|
|
super.onBack,
|
|
});
|
|
|
|
@override
|
|
State<LibraryRecommendedTab> createState() => _LibraryRecommendedTabState();
|
|
}
|
|
|
|
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
|
with ItemUpdatable, WatchStateAware {
|
|
/// GlobalKeys for each hub section to enable vertical navigation
|
|
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
|
|
|
@override
|
|
String? get itemServerId => widget.library.serverId;
|
|
|
|
@override
|
|
String? get watchStateServerId => widget.library.serverId;
|
|
|
|
@override
|
|
Set<String>? get watchedIds {
|
|
final keys = <String>{};
|
|
for (final hub in items) {
|
|
for (final item in hub.items) {
|
|
keys.add(item.id);
|
|
if (item.parentId != null) keys.add(item.parentId!);
|
|
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
@override
|
|
Set<String>? get watchedGlobalKeys {
|
|
final keys = <String>{};
|
|
for (final hub in items) {
|
|
for (final item in hub.items) {
|
|
final serverId = item.serverId ?? widget.library.serverId;
|
|
if (serverId == null) return null;
|
|
keys.add(buildGlobalKey(serverId, item.id));
|
|
if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!));
|
|
if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!));
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
@override
|
|
void updateItemInLists(String itemId, MediaItem updatedItem) {
|
|
// Update the item in any hub that contains it. MediaHub items are
|
|
// immutable lists; rebuild the affected hub in-place.
|
|
for (var i = 0; i < items.length; i++) {
|
|
final hub = items[i];
|
|
final itemIndex = hub.items.indexWhere((item) => item.id == itemId);
|
|
if (itemIndex != -1) {
|
|
final newItems = List<MediaItem>.from(hub.items);
|
|
newItems[itemIndex] = updatedItem;
|
|
items[i] = hub.copyWith(items: newItems);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWatchStateChanged(WatchStateEvent event) {
|
|
if (event.changeType == WatchStateChangeType.progressUpdate) return;
|
|
|
|
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) {
|
|
_removeContinueWatchingItem(event.itemId);
|
|
unawaited(loadItems());
|
|
return;
|
|
}
|
|
|
|
final affectedIds = {event.itemId, ...event.parentChain};
|
|
final refreshIds = <String>{};
|
|
for (final hub in items) {
|
|
for (final item in hub.items) {
|
|
if (affectedIds.contains(item.id)) {
|
|
refreshIds.add(item.id);
|
|
}
|
|
}
|
|
}
|
|
for (final itemId in refreshIds) {
|
|
unawaited(updateItem(itemId));
|
|
}
|
|
}
|
|
|
|
void _removeContinueWatchingItem(String itemId) {
|
|
setState(() {
|
|
for (var i = 0; i < items.length; i++) {
|
|
final hub = items[i];
|
|
if (!_isContinueWatchingHub(hub)) continue;
|
|
final newItems = hub.items.where((item) => item.id != itemId).toList();
|
|
if (newItems.length != hub.items.length) {
|
|
items[i] = hub.copyWith(items: newItems, size: newItems.length);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
IconData get emptyIcon => Symbols.recommend_rounded;
|
|
|
|
@override
|
|
String get emptyMessage => t.libraries.noRecommendations;
|
|
|
|
@override
|
|
String get errorContext => t.libraries.tabs.recommended;
|
|
|
|
/// Detects Continue Watching hubs by hub identifier.
|
|
/// Section-specific CW hubs use identifiers like "movie.inprogress.1".
|
|
static bool _isContinueWatchingHub(MediaHub hub) {
|
|
final hubId = hub.identifier?.toLowerCase() ?? '';
|
|
return hubId.contains('inprogress');
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaHub>> loadData() async {
|
|
// Clear hub keys before loading new hubs to prevent stale references
|
|
_hubKeys.clear();
|
|
|
|
// Backend-aware fetch: Plex hits /hubs/sections, Jellyfin synthesises
|
|
// Continue Watching + Next Up + Recently Added.
|
|
final client = context.tryGetMediaClientForServer(widget.library.serverId);
|
|
final hubs = client == null
|
|
? <MediaHub>[]
|
|
: List.of(
|
|
await client.fetchLibraryHubs(
|
|
widget.library.id,
|
|
libraryName: widget.library.title,
|
|
limit: 12,
|
|
libraryKind: widget.library.kind,
|
|
),
|
|
);
|
|
|
|
// Move Continue Watching hub to the front if present
|
|
final cwIndex = hubs.indexWhere(_isContinueWatchingHub);
|
|
if (cwIndex > 0) {
|
|
final cwHub = hubs.removeAt(cwIndex);
|
|
hubs.insert(0, cwHub);
|
|
}
|
|
|
|
return hubs;
|
|
}
|
|
|
|
/// Ensure we have enough GlobalKeys for all hubs
|
|
void _ensureHubKeys(int count) {
|
|
while (_hubKeys.length < count) {
|
|
_hubKeys.add(GlobalKey<HubSectionState>());
|
|
}
|
|
}
|
|
|
|
/// Handle vertical navigation between hubs
|
|
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
|
|
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
|
|
|
|
// Check if target is valid
|
|
if (targetIndex < 0) {
|
|
// At top boundary - return false to allow onNavigateUp to handle it
|
|
return false;
|
|
}
|
|
|
|
if (targetIndex >= _hubKeys.length) {
|
|
// At bottom boundary, block navigation
|
|
return true;
|
|
}
|
|
|
|
// Navigate to target hub with column memory
|
|
final targetState = _hubKeys[targetIndex].currentState;
|
|
if (targetState != null) {
|
|
targetState.requestFocusFromMemory();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Focus the first item in the first hub (for tab activation)
|
|
@override
|
|
void focusFirstItem() {
|
|
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
|
|
_hubKeys.first.currentState?.requestFocusAt(0);
|
|
}
|
|
}
|
|
|
|
/// Navigate focus to the sidebar
|
|
void _navigateToSidebar() {
|
|
MainScreenFocusScope.of(context)?.focusSidebar();
|
|
}
|
|
|
|
// Extra top padding for focus decoration (scale + border extends beyond item bounds)
|
|
static const double _focusDecorationPadding = 8.0;
|
|
|
|
@override
|
|
Widget buildContent(List<MediaHub> items) {
|
|
_ensureHubKeys(items.length);
|
|
|
|
return CustomScrollView(
|
|
// Allow focus decoration to render outside scroll bounds
|
|
clipBehavior: Clip.none,
|
|
slivers: [
|
|
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(0, _focusDecorationPadding, 0, 8),
|
|
sliver: SliverList.builder(
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final hub = items[index];
|
|
final isContinueWatching = _isContinueWatchingHub(hub);
|
|
|
|
return HubSection(
|
|
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
|
hub: hub,
|
|
icon: _getHubIcon(hub),
|
|
isInContinueWatching: isContinueWatching,
|
|
onRefresh: updateItem,
|
|
onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null,
|
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp),
|
|
onBack: widget.onBack,
|
|
onNavigateUp: index == 0 ? widget.onBack : null,
|
|
onNavigateToSidebar: _navigateToSidebar,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Refresh the Continue Watching section
|
|
void _refreshContinueWatching() {
|
|
// Reload all data to refresh the continue watching section
|
|
loadItems();
|
|
}
|
|
|
|
IconData _getHubIcon(MediaHub hub) {
|
|
final title = hub.title.toLowerCase();
|
|
if (title.contains('continue watching') || title.contains('on deck')) {
|
|
return Symbols.play_circle_rounded;
|
|
} else if (title.contains('recently') || title.contains('new')) {
|
|
return Symbols.fiber_new_rounded;
|
|
} else if (title.contains('popular') || title.contains('trending')) {
|
|
return Symbols.trending_up_rounded;
|
|
} else if (title.contains('top') || title.contains('rated')) {
|
|
return Symbols.star_rounded;
|
|
} else if (title.contains('recommended')) {
|
|
return Symbols.thumb_up_rounded;
|
|
} else if (title.contains('unwatched')) {
|
|
return Symbols.visibility_off_rounded;
|
|
} else if (title.contains('genre')) {
|
|
return Symbols.category_rounded;
|
|
}
|
|
return Symbols.movie_rounded;
|
|
}
|
|
}
|