feat(explore): Explore tab with catalog detail, search, and Seerr requests
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
|
||||||
|
/// Debounced free-text media search shared by the main search screen and the
|
||||||
|
/// catalog (Explore) search screen: text controller + focus nodes, a 500ms
|
||||||
|
/// debounce, a generation guard against out-of-order responses, in-flight
|
||||||
|
/// invalidation when the text diverges from the query being fetched, and the
|
||||||
|
/// loading/failed/empty state flags the screens render from.
|
||||||
|
///
|
||||||
|
/// Implementations override [performSearchQuery]; everything else (including
|
||||||
|
/// controller/node disposal) is owned here.
|
||||||
|
mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||||
|
static const Duration searchDebounceDuration = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
late final TextEditingController searchController = TextEditingController();
|
||||||
|
late final FocusNode searchFocusNode = FocusNode(debugLabel: '${searchDebugLabel}Input');
|
||||||
|
late final FocusNode firstResultFocusNode = FocusNode(debugLabel: '${searchDebugLabel}FirstResult');
|
||||||
|
|
||||||
|
/// Plain restartable timer instead of rate_limiter's Debounce: that one
|
||||||
|
/// times its trailing edge with DateTime.now(), which never advances under
|
||||||
|
/// the widget-test fake clock, so the debounce would be untestable.
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
|
||||||
|
List<MediaItem> searchResults = [];
|
||||||
|
bool isSearching = false;
|
||||||
|
bool hasSearched = false;
|
||||||
|
bool lastSearchFailed = false;
|
||||||
|
String lastSearchedQuery = '';
|
||||||
|
|
||||||
|
int _searchGeneration = 0;
|
||||||
|
String? _inFlightQuery;
|
||||||
|
bool _showedClearButton = false;
|
||||||
|
|
||||||
|
/// Names the focus nodes and log lines.
|
||||||
|
String get searchDebugLabel => widget.runtimeType.toString();
|
||||||
|
|
||||||
|
/// Run the actual search. Thrown errors flip [lastSearchFailed].
|
||||||
|
Future<List<MediaItem>> performSearchQuery(String query);
|
||||||
|
|
||||||
|
/// A failed search was applied to the state (e.g. show a snackbar).
|
||||||
|
void onSearchError(Object error) {}
|
||||||
|
|
||||||
|
/// A successful search was applied to the state.
|
||||||
|
void onSearchCompleted(String query, List<MediaItem> results) {}
|
||||||
|
|
||||||
|
/// The field was cleared and the state reset.
|
||||||
|
void onSearchCleared() {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
searchController.addListener(_onSearchTextChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
searchController.removeListener(_onSearchTextChanged);
|
||||||
|
searchController.dispose();
|
||||||
|
searchFocusNode.dispose();
|
||||||
|
firstResultFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchTextChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final query = searchController.text.trim();
|
||||||
|
|
||||||
|
// The clear affordance tracks text emptiness; without this rebuild it
|
||||||
|
// only appeared when a search landed ~500ms later.
|
||||||
|
if (query.isNotEmpty != _showedClearButton) {
|
||||||
|
_showedClearButton = query.isNotEmpty;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_searchGeneration++;
|
||||||
|
_inFlightQuery = null;
|
||||||
|
setState(() {
|
||||||
|
searchResults = [];
|
||||||
|
hasSearched = false;
|
||||||
|
isSearching = false;
|
||||||
|
lastSearchFailed = false;
|
||||||
|
lastSearchedQuery = '';
|
||||||
|
});
|
||||||
|
onSearchCleared();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query == lastSearchedQuery) {
|
||||||
|
// Reverted to what's already shown: the pending debounce and any
|
||||||
|
// in-flight pass for the intermediate text must not land afterwards.
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
if (_invalidateStaleInFlight(query)) setState(() => isSearching = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_invalidateStaleInFlight(query);
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(searchDebounceDuration, () => runSearch(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-flight search for text the field no longer shows can only land
|
||||||
|
/// wrong; kill it via the generation. Returns true when one was dropped.
|
||||||
|
bool _invalidateStaleInFlight(String current) {
|
||||||
|
if (_inFlightQuery == null || _inFlightQuery == current) return false;
|
||||||
|
_searchGeneration++;
|
||||||
|
_inFlightQuery = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run [query] now, bypassing the debounce (submit, external refresh).
|
||||||
|
Future<void> runSearch(String query) async {
|
||||||
|
if (!mounted || query.isEmpty) return;
|
||||||
|
final generation = ++_searchGeneration;
|
||||||
|
_inFlightQuery = query;
|
||||||
|
setState(() {
|
||||||
|
isSearching = true;
|
||||||
|
hasSearched = true;
|
||||||
|
lastSearchFailed = false;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final results = await performSearchQuery(query);
|
||||||
|
if (!mounted || generation != _searchGeneration) return;
|
||||||
|
_inFlightQuery = null;
|
||||||
|
setState(() {
|
||||||
|
searchResults = results;
|
||||||
|
isSearching = false;
|
||||||
|
lastSearchedQuery = query;
|
||||||
|
});
|
||||||
|
onSearchCompleted(query, results);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('$searchDebugLabel: search failed', error: e);
|
||||||
|
if (!mounted || generation != _searchGeneration) return;
|
||||||
|
_inFlightQuery = null;
|
||||||
|
setState(() {
|
||||||
|
searchResults = [];
|
||||||
|
isSearching = false;
|
||||||
|
lastSearchFailed = true;
|
||||||
|
lastSearchedQuery = query;
|
||||||
|
});
|
||||||
|
onSearchError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSK "Search" / hardware Enter on TV: jump to results, or force the
|
||||||
|
/// pending search to run now.
|
||||||
|
void handleSearchSubmit() {
|
||||||
|
final query = searchController.text.trim();
|
||||||
|
if (query.isEmpty) return;
|
||||||
|
if (searchResults.isNotEmpty && !isSearching && query == lastSearchedQuery) {
|
||||||
|
firstResultFocusNode.requestFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_debounceTimer?.isActive ?? false) || !isSearching) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
runSearch(query);
|
||||||
|
}
|
||||||
|
// else: the in-flight search already covers the current text.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import '../i18n/strings.g.dart';
|
|||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
|
||||||
/// Navigation tab identifiers
|
/// Navigation tab identifiers
|
||||||
enum NavigationTabId { discover, libraries, liveTv, search, downloads, settings }
|
enum NavigationTabId { discover, explore, libraries, liveTv, search, downloads, settings }
|
||||||
|
|
||||||
/// Represents a navigation tab with its configuration
|
/// Represents a navigation tab with its configuration
|
||||||
class NavigationTab {
|
class NavigationTab {
|
||||||
@@ -22,16 +22,21 @@ class NavigationTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the index for a tab ID in the visible tabs list
|
/// Get the index for a tab ID in the visible tabs list
|
||||||
static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false}) {
|
static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) {
|
||||||
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv);
|
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore);
|
||||||
return tabs.indexWhere((tab) => tab.id == id);
|
return tabs.indexWhere((tab) => tab.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get tabs filtered by offline mode and feature availability
|
/// Get tabs filtered by offline mode and feature availability
|
||||||
static List<NavigationTab> getVisibleTabs({required bool isOffline, bool hasLiveTv = false}) {
|
static List<NavigationTab> getVisibleTabs({
|
||||||
|
required bool isOffline,
|
||||||
|
bool hasLiveTv = false,
|
||||||
|
bool hasExplore = false,
|
||||||
|
}) {
|
||||||
return allNavigationTabs.where((tab) {
|
return allNavigationTabs.where((tab) {
|
||||||
if (isOffline && tab.onlineOnly) return false;
|
if (isOffline && tab.onlineOnly) return false;
|
||||||
if (tab.id == NavigationTabId.liveTv && !hasLiveTv) return false;
|
if (tab.id == NavigationTabId.liveTv && !hasLiveTv) return false;
|
||||||
|
if (tab.id == NavigationTabId.explore && !hasExplore) return false;
|
||||||
if (tab.id == NavigationTabId.downloads && PlatformDetector.isAppleTV()) return false;
|
if (tab.id == NavigationTabId.downloads && PlatformDetector.isAppleTV()) return false;
|
||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -45,9 +50,10 @@ class NavigationTab {
|
|||||||
static NavigationTabId resolveDefaultTab({
|
static NavigationTabId resolveDefaultTab({
|
||||||
required bool isOffline,
|
required bool isOffline,
|
||||||
required bool hasLiveTv,
|
required bool hasLiveTv,
|
||||||
|
bool hasExplore = false,
|
||||||
required NavigationTabId? preferredStartup,
|
required NavigationTabId? preferredStartup,
|
||||||
}) {
|
}) {
|
||||||
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv);
|
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore);
|
||||||
if (isOffline && tabs.any((t) => t.id == NavigationTabId.downloads)) {
|
if (isOffline && tabs.any((t) => t.id == NavigationTabId.downloads)) {
|
||||||
return NavigationTabId.downloads;
|
return NavigationTabId.downloads;
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,7 @@ class NavigationTab {
|
|||||||
|
|
||||||
// Label getters (must be top-level for const constructor)
|
// Label getters (must be top-level for const constructor)
|
||||||
String _getHomeLabel() => t.common.home;
|
String _getHomeLabel() => t.common.home;
|
||||||
|
String _getExploreLabel() => t.navigation.explore;
|
||||||
String _getLibrariesLabel() => t.navigation.libraries;
|
String _getLibrariesLabel() => t.navigation.libraries;
|
||||||
String _getLiveTvLabel() => t.navigation.liveTv;
|
String _getLiveTvLabel() => t.navigation.liveTv;
|
||||||
String _getSearchLabel() => t.common.search;
|
String _getSearchLabel() => t.common.search;
|
||||||
@@ -76,6 +83,12 @@ const allNavigationTabs = [
|
|||||||
getLabel: _getLibrariesLabel,
|
getLabel: _getLibrariesLabel,
|
||||||
),
|
),
|
||||||
NavigationTab(id: NavigationTabId.liveTv, onlineOnly: true, icon: Symbols.live_tv_rounded, getLabel: _getLiveTvLabel),
|
NavigationTab(id: NavigationTabId.liveTv, onlineOnly: true, icon: Symbols.live_tv_rounded, getLabel: _getLiveTvLabel),
|
||||||
|
NavigationTab(
|
||||||
|
id: NavigationTabId.explore,
|
||||||
|
onlineOnly: true,
|
||||||
|
icon: Symbols.explore_rounded,
|
||||||
|
getLabel: _getExploreLabel,
|
||||||
|
),
|
||||||
NavigationTab(id: NavigationTabId.search, onlineOnly: true, icon: Symbols.search_rounded, getLabel: _getSearchLabel),
|
NavigationTab(id: NavigationTabId.search, onlineOnly: true, icon: Symbols.search_rounded, getLabel: _getSearchLabel),
|
||||||
NavigationTab(
|
NavigationTab(
|
||||||
id: NavigationTabId.downloads,
|
id: NavigationTabId.downloads,
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ import '../media/media_server_client.dart';
|
|||||||
import '../profiles/active_profile_provider.dart';
|
import '../profiles/active_profile_provider.dart';
|
||||||
import '../profiles/plex_home_service.dart';
|
import '../profiles/plex_home_service.dart';
|
||||||
import '../profiles/profile_connection_registry.dart';
|
import '../profiles/profile_connection_registry.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
import '../providers/companion_remote_provider.dart';
|
import '../providers/companion_remote_provider.dart';
|
||||||
import '../providers/discover_provider.dart';
|
import '../providers/discover_provider.dart';
|
||||||
|
import '../providers/explore_provider.dart';
|
||||||
import '../providers/hidden_libraries_provider.dart';
|
import '../providers/hidden_libraries_provider.dart';
|
||||||
import '../providers/libraries_provider.dart';
|
import '../providers/libraries_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../providers/playback_state_provider.dart';
|
import '../providers/playback_state_provider.dart';
|
||||||
import '../providers/trakt_account_provider.dart';
|
import '../providers/trakt_account_provider.dart';
|
||||||
|
import '../providers/seerr_account_provider.dart';
|
||||||
import '../providers/trackers_provider.dart';
|
import '../providers/trackers_provider.dart';
|
||||||
import '../providers/watch_state_store.dart';
|
import '../providers/watch_state_store.dart';
|
||||||
import '../database/app_database.dart';
|
import '../database/app_database.dart';
|
||||||
import '../screens/main_screen.dart';
|
import '../screens/main_screen.dart';
|
||||||
import '../services/api_cache.dart';
|
import '../services/api_cache.dart';
|
||||||
|
import '../services/catalog/catalog_library_matcher.dart';
|
||||||
import '../services/music/music_playback_service.dart';
|
import '../services/music/music_playback_service.dart';
|
||||||
import '../services/music/music_playback_service_impl.dart';
|
import '../services/music/music_playback_service_impl.dart';
|
||||||
import '../services/offline_watch_sync_service.dart';
|
import '../services/offline_watch_sync_service.dart';
|
||||||
@@ -143,6 +147,50 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
|||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) {
|
||||||
|
final provider = SeerrAccountProvider();
|
||||||
|
provider.bindPlexTokenSupplier(
|
||||||
|
buildSeerrPlexTokenSupplier(
|
||||||
|
activeProfile: context.read<ActiveProfileProvider>(),
|
||||||
|
connections: context.read<ConnectionRegistry>(),
|
||||||
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
||||||
|
appLogger.w('Seerr profile hydrate failed', error: e, stackTrace: s);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ChangeNotifierProxyProvider3<
|
||||||
|
TraktAccountProvider,
|
||||||
|
TrackersProvider,
|
||||||
|
SeerrAccountProvider,
|
||||||
|
CatalogSourcesProvider
|
||||||
|
>(
|
||||||
|
create: (context) {
|
||||||
|
final provider = CatalogSourcesProvider();
|
||||||
|
unawaited(
|
||||||
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
||||||
|
appLogger.w('Catalog sources profile hydrate failed', error: e, stackTrace: s);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
update: (_, trakt, trackers, seerr, previous) {
|
||||||
|
final provider = previous ?? CatalogSourcesProvider();
|
||||||
|
provider.update(trakt, trackers, seerr);
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => ExploreProvider(context.read<CatalogSourcesProvider>()),
|
||||||
|
lazy: true,
|
||||||
|
),
|
||||||
|
Provider(create: (context) => CatalogLibraryMatcher(context.read<MultiServerProvider>()), lazy: true),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
|
HiddenLibrariesProvider(storageService: context.read<StorageService>(), profileId: activeId),
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../profiles/profile.dart';
|
||||||
|
import '../services/base_shared_preferences_service.dart';
|
||||||
|
import '../services/catalog/catalog_source.dart';
|
||||||
|
import '../services/catalog/mal_catalog_source.dart';
|
||||||
|
import '../services/catalog/seerr_catalog_source.dart';
|
||||||
|
import '../services/catalog/trakt_catalog_source.dart';
|
||||||
|
import '../services/seerr/seerr_client.dart';
|
||||||
|
import '../services/trackers/mal/mal_client.dart';
|
||||||
|
import '../services/trakt/trakt_client.dart';
|
||||||
|
import 'seerr_account_provider.dart';
|
||||||
|
import 'trakt_account_provider.dart';
|
||||||
|
import 'trackers_provider.dart';
|
||||||
|
|
||||||
|
/// Enumerates the connected [CatalogSource]s for the active profile and owns
|
||||||
|
/// which one the Explore tab shows.
|
||||||
|
///
|
||||||
|
/// Profile-scoped; rebuilt through a `ChangeNotifierProxyProvider3` on
|
||||||
|
/// [TraktAccountProvider], [TrackersProvider] (MAL), and
|
||||||
|
/// [SeerrAccountProvider] so sources appear and
|
||||||
|
/// disappear live when a provider is connected or disconnected mid-session
|
||||||
|
/// (which also drives the Explore tab's visibility).
|
||||||
|
class CatalogSourcesProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||||
|
static const String _activeSourceBaseKey = 'catalog_active_source';
|
||||||
|
|
||||||
|
TraktCatalogSource? _trakt;
|
||||||
|
TraktClient? _lastTraktClient;
|
||||||
|
MalCatalogSource? _mal;
|
||||||
|
MalClient? _lastMalClient;
|
||||||
|
SeerrCatalogSource? _seerr;
|
||||||
|
SeerrClient? _lastSeerrClient;
|
||||||
|
CatalogSourceId? _preferredSourceId;
|
||||||
|
String _activeUserUuid = '';
|
||||||
|
|
||||||
|
List<CatalogSource> get connectedSources => [?_trakt, ?_mal, ?_seerr];
|
||||||
|
|
||||||
|
bool get hasAnySource => _trakt != null || _mal != null || _seerr != null;
|
||||||
|
|
||||||
|
/// The connected Seerr source, for the request surfaces (detail-screen
|
||||||
|
/// Request action and sheet) that need Seerr's client beyond the
|
||||||
|
/// [CatalogSource] interface.
|
||||||
|
SeerrCatalogSource? get seerrSource => _seerr;
|
||||||
|
|
||||||
|
/// The source whose rows the Explore tab shows: the user's persisted pick
|
||||||
|
/// when it is still connected, otherwise the first connected source.
|
||||||
|
CatalogSource? get activeSource {
|
||||||
|
final sources = connectedSources;
|
||||||
|
return sources.firstWhereOrNull((s) => s.id == _preferredSourceId) ?? sources.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source backing watchlist membership/mutation surfaces (media-detail
|
||||||
|
/// action). Independent of [activeSource] so switching the Explore tab to a
|
||||||
|
/// watchlist-less source (e.g. a future Seerr) keeps the action alive.
|
||||||
|
CatalogSource? get watchlistCapableSource => connectedSources.firstWhereOrNull((s) => s.supportsWatchlist);
|
||||||
|
|
||||||
|
/// All connected sources whose watchlist can be read and mutated, for
|
||||||
|
/// surfaces that offer a choice (media-detail bookmark with several
|
||||||
|
/// providers connected).
|
||||||
|
List<CatalogSource> get watchlistCapableSources => [...connectedSources.where((source) => source.supportsWatchlist)];
|
||||||
|
|
||||||
|
/// The watchlist source catalog-item surfaces (detail screen, card menu)
|
||||||
|
/// must bind to: the item's OWN source — a MAL card toggles the MAL Plan to
|
||||||
|
/// Watch, never another provider's list. An item whose source is connected
|
||||||
|
/// but has no watchlist (Seerr) gets none at all — no falling back to
|
||||||
|
/// another provider's list. The fallback exists only for items whose
|
||||||
|
/// source got disconnected mid-session.
|
||||||
|
CatalogSource? watchlistSourceFor(CatalogItem item) {
|
||||||
|
final own = connectedSources.firstWhereOrNull((s) => s.id == item.source);
|
||||||
|
if (own != null) return own.supportsWatchlist ? own : null;
|
||||||
|
return watchlistCapableSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hydrate the per-profile active-source preference.
|
||||||
|
Future<void> onActiveProfileChanged(String? userUuid) async {
|
||||||
|
_activeUserUuid = userUuid ?? '';
|
||||||
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
final raw = prefs.getString(profileScopedPrefsKey(_activeUserUuid, _activeSourceBaseKey));
|
||||||
|
if (isDisposed) return;
|
||||||
|
_preferredSourceId = CatalogSourceId.values.asNameMap()[raw];
|
||||||
|
safeNotifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setActiveSource(CatalogSourceId id) async {
|
||||||
|
if (_preferredSourceId == id) return;
|
||||||
|
_preferredSourceId = id;
|
||||||
|
safeNotifyListeners();
|
||||||
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
await prefs.setString(profileScopedPrefsKey(_activeUserUuid, _activeSourceBaseKey), id.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxy-provider update hook: rebuild a source when its catalog client
|
||||||
|
/// was rebound (connect/disconnect/profile switch).
|
||||||
|
void update(TraktAccountProvider trakt, TrackersProvider trackers, SeerrAccountProvider seerr) {
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
final traktClient = trakt.catalogClient;
|
||||||
|
if (!identical(traktClient, _lastTraktClient)) {
|
||||||
|
_lastTraktClient = traktClient;
|
||||||
|
_trakt?.dispose();
|
||||||
|
_trakt = traktClient == null ? null : TraktCatalogSource(traktClient);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final malClient = trackers.malCatalogClient;
|
||||||
|
if (!identical(malClient, _lastMalClient)) {
|
||||||
|
_lastMalClient = malClient;
|
||||||
|
_mal?.dispose();
|
||||||
|
_mal = malClient == null ? null : MalCatalogSource(malClient);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seerrClient = seerr.catalogClient;
|
||||||
|
if (!identical(seerrClient, _lastSeerrClient)) {
|
||||||
|
_lastSeerrClient = seerrClient;
|
||||||
|
_seerr?.dispose();
|
||||||
|
_seerr = seerrClient == null ? null : SeerrCatalogSource(seerrClient);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) safeNotifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_trakt?.dispose();
|
||||||
|
_trakt = null;
|
||||||
|
_mal?.dispose();
|
||||||
|
_mal = null;
|
||||||
|
_seerr?.dispose();
|
||||||
|
_seerr = null;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/media_hub.dart';
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||||
|
import '../services/catalog/catalog_source.dart';
|
||||||
|
import '../services/trackers/future_coalescer.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import 'catalog_sources_provider.dart';
|
||||||
|
|
||||||
|
enum ExploreLoadState { initial, loading, loaded, error }
|
||||||
|
|
||||||
|
/// A row of the Explore tab: the catalog row id plus its rendering hub.
|
||||||
|
typedef ExploreRowHub = ({CatalogRowId row, MediaHub hub});
|
||||||
|
|
||||||
|
/// Owns the Explore tab's data: one [CatalogPage] per row of the active
|
||||||
|
/// [CatalogSource], converted to [MediaHub]s so the existing shelf stack
|
||||||
|
/// renders them.
|
||||||
|
///
|
||||||
|
/// Lives inside the profile-keyed provider subtree. Listens to
|
||||||
|
/// [CatalogSourcesProvider] for the active source (connect/disconnect/switch)
|
||||||
|
/// and to the source's watchlist changes so the Watchlist row stays current
|
||||||
|
/// after mutations from anywhere in the app.
|
||||||
|
class ExploreProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
||||||
|
/// Rows reload when the tab is shown after this long.
|
||||||
|
static const Duration staleAfter = Duration(minutes: 15);
|
||||||
|
static const int rowLimit = 25;
|
||||||
|
static const int viewAllPageLimit = 100;
|
||||||
|
static const int viewAllMaxPages = 3;
|
||||||
|
|
||||||
|
/// Watchlist mutations notify optimistically before the API call finishes;
|
||||||
|
/// the row refetch waits out the burst so it reads settled server state.
|
||||||
|
static const Duration _watchlistRefreshDelay = Duration(seconds: 1);
|
||||||
|
|
||||||
|
ExploreProvider(this._catalogSources) {
|
||||||
|
_catalogSources.addListener(_onSourcesChanged);
|
||||||
|
_source = _catalogSources.activeSource;
|
||||||
|
_source?.watchlistChanges.addListener(_onWatchlistChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
final CatalogSourcesProvider _catalogSources;
|
||||||
|
CatalogSource? _source;
|
||||||
|
|
||||||
|
Map<CatalogRowId, CatalogPage> _rows = {};
|
||||||
|
ExploreLoadState _state = ExploreLoadState.initial;
|
||||||
|
String? _errorMessage;
|
||||||
|
DateTime? _loadedAt;
|
||||||
|
final FutureCoalescer<void> _loadCoalescer = FutureCoalescer();
|
||||||
|
int _generation = 0;
|
||||||
|
Timer? _watchlistRefreshTimer;
|
||||||
|
|
||||||
|
// Watchlist-row freshness: every membership change bumps the mutation
|
||||||
|
// epoch; a successful row refetch records which epoch it covered. A tab
|
||||||
|
// re-shown with uncovered mutations refetches immediately — the debounced
|
||||||
|
// timer alone can lose the race when the user navigates back quickly.
|
||||||
|
int _watchlistMutationEpoch = 0;
|
||||||
|
int _watchlistRowFetchedEpoch = 0;
|
||||||
|
final FutureCoalescer<void> _watchlistRefreshCoalescer = FutureCoalescer();
|
||||||
|
|
||||||
|
List<ExploreRowHub>? _hubsCache;
|
||||||
|
(int, String)? _hubsCacheKey;
|
||||||
|
int _rowsEpoch = 0;
|
||||||
|
|
||||||
|
CatalogSource? get activeSource => _source;
|
||||||
|
|
||||||
|
ExploreLoadState get state => _state;
|
||||||
|
|
||||||
|
bool get isLoading => _state == ExploreLoadState.initial || _state == ExploreLoadState.loading;
|
||||||
|
|
||||||
|
/// Raw load failure (unlocalized); the screen wraps it for display.
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
|
/// Non-empty rows of the active source in display order. Memoized on row
|
||||||
|
/// content (and one localized string, so a locale change busts the cache).
|
||||||
|
List<ExploreRowHub> get rowHubs {
|
||||||
|
final source = _source;
|
||||||
|
if (source == null) return const [];
|
||||||
|
final key = (_rowsEpoch, rowTitle(CatalogRowId.watchlist));
|
||||||
|
if (_hubsCache != null && key == _hubsCacheKey) return _hubsCache!;
|
||||||
|
final hubs = <ExploreRowHub>[
|
||||||
|
for (final row in source.supportedRows)
|
||||||
|
if (_rows[row] case final CatalogPage page)
|
||||||
|
if (page.items.isNotEmpty)
|
||||||
|
(
|
||||||
|
row: row,
|
||||||
|
hub: MediaHub(
|
||||||
|
id: 'explore:${source.id.name}:${row.name}',
|
||||||
|
identifier: 'explore.${row.name}',
|
||||||
|
title: rowTitle(row),
|
||||||
|
type: 'mixed',
|
||||||
|
items: [for (final item in page.items) item.toMediaItem()],
|
||||||
|
size: page.items.length,
|
||||||
|
more: page.hasMore,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
_hubsCache = hubs;
|
||||||
|
_hubsCacheKey = key;
|
||||||
|
return hubs;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String rowTitle(CatalogRowId row) => switch (row) {
|
||||||
|
CatalogRowId.watchlist => t.explore.rows.watchlist,
|
||||||
|
CatalogRowId.recommendedMovies => t.explore.rows.recommendedMovies,
|
||||||
|
CatalogRowId.recommendedShows => t.explore.rows.recommendedShows,
|
||||||
|
CatalogRowId.trendingMovies => t.explore.rows.trendingMovies,
|
||||||
|
CatalogRowId.trendingShows => t.explore.rows.trendingShows,
|
||||||
|
CatalogRowId.popularMovies => t.explore.rows.popularMovies,
|
||||||
|
CatalogRowId.popularShows => t.explore.rows.popularShows,
|
||||||
|
CatalogRowId.suggestedAnime => t.explore.rows.suggestedAnime,
|
||||||
|
CatalogRowId.airingAnime => t.explore.rows.airingAnime,
|
||||||
|
CatalogRowId.popularAnime => t.explore.rows.popularAnime,
|
||||||
|
CatalogRowId.trending => t.explore.rows.trending,
|
||||||
|
CatalogRowId.upcomingMovies => t.explore.rows.upcomingMovies,
|
||||||
|
CatalogRowId.upcomingShows => t.explore.rows.upcomingShows,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Load if never loaded, after an error, or when the content has gone
|
||||||
|
/// stale. Called on first build and every time the tab is shown.
|
||||||
|
void ensureFresh() {
|
||||||
|
if (_source == null) return;
|
||||||
|
if (_state == ExploreLoadState.initial || _state == ExploreLoadState.error) {
|
||||||
|
unawaited(load());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loadedAt = _loadedAt;
|
||||||
|
if (loadedAt != null && DateTime.now().difference(loadedAt) > staleAfter) {
|
||||||
|
unawaited(load());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_watchlistRowFetchedEpoch < _watchlistMutationEpoch) {
|
||||||
|
unawaited(_refreshWatchlistRow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full reload of every supported row (one request per row). Concurrent
|
||||||
|
/// calls coalesce into the in-flight pass; a source switch resets the
|
||||||
|
/// coalescer (see [_onSourcesChanged]) so the new source's load starts
|
||||||
|
/// instead of joining the doomed one.
|
||||||
|
Future<void> load() => _loadCoalescer.run(_loadOnce);
|
||||||
|
|
||||||
|
Future<void> _loadOnce() async {
|
||||||
|
// Yield so a load() kicked off during build can't notify mid-build.
|
||||||
|
await null;
|
||||||
|
if (isDisposed) return;
|
||||||
|
final source = _source;
|
||||||
|
if (source == null) return;
|
||||||
|
final generation = _generation;
|
||||||
|
final mutationEpochAtStart = _watchlistMutationEpoch;
|
||||||
|
|
||||||
|
_state = ExploreLoadState.loading;
|
||||||
|
_errorMessage = null;
|
||||||
|
safeNotifyListeners();
|
||||||
|
|
||||||
|
final rows = source.supportedRows;
|
||||||
|
Object? firstError;
|
||||||
|
final results = await Future.wait([
|
||||||
|
for (final row in rows)
|
||||||
|
source.fetchRow(row, limit: rowLimit).then<CatalogPage?>((page) => page).catchError((Object e) {
|
||||||
|
appLogger.w('Explore: ${source.id.name} row ${row.name} failed', error: e);
|
||||||
|
firstError ??= e;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (isDisposed || generation != _generation) return;
|
||||||
|
|
||||||
|
final fetched = <CatalogRowId, CatalogPage>{
|
||||||
|
for (var i = 0; i < rows.length; i++)
|
||||||
|
if (results[i] case final CatalogPage page) rows[i]: page,
|
||||||
|
};
|
||||||
|
// A debounced watchlist-row refresh that landed while this load was in
|
||||||
|
// flight covered later mutations than our page — keep the fresher one.
|
||||||
|
if (_watchlistRowFetchedEpoch > mutationEpochAtStart) {
|
||||||
|
fetched.remove(CatalogRowId.watchlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetched.isEmpty) {
|
||||||
|
// Nothing succeeded: keep stale rows if any (they beat an error flash),
|
||||||
|
// otherwise surface the failure. A null message falls back to the
|
||||||
|
// localized empty-state text in the screen.
|
||||||
|
if (_rows.isEmpty) {
|
||||||
|
_state = ExploreLoadState.error;
|
||||||
|
_errorMessage = firstError?.toString();
|
||||||
|
} else {
|
||||||
|
_state = ExploreLoadState.loaded;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Failed rows keep their previous page.
|
||||||
|
_rows = {..._rows, ...fetched};
|
||||||
|
_state = ExploreLoadState.loaded;
|
||||||
|
_loadedAt = DateTime.now();
|
||||||
|
_rowsEpoch++;
|
||||||
|
if (fetched.containsKey(CatalogRowId.watchlist) && mutationEpochAtStart > _watchlistRowFetchedEpoch) {
|
||||||
|
_watchlistRowFetchedEpoch = mutationEpochAtStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mutations that landed while the load was in flight aren't reflected in
|
||||||
|
// the page we just stored — schedule the debounced catch-up ourselves
|
||||||
|
// (the mutation-time notification skips rows that aren't loaded yet).
|
||||||
|
if (_rows.containsKey(CatalogRowId.watchlist) && _watchlistRowFetchedEpoch < _watchlistMutationEpoch) {
|
||||||
|
_scheduleWatchlistRefresh();
|
||||||
|
}
|
||||||
|
safeNotifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full item list for a row's View All grid, paging past the shelf cap.
|
||||||
|
Future<List<MediaItem>> loadAllForRow(CatalogRowId row) async {
|
||||||
|
final source = _source;
|
||||||
|
if (source == null) return const [];
|
||||||
|
final items = <MediaItem>[];
|
||||||
|
var page = 1;
|
||||||
|
while (true) {
|
||||||
|
final res = await source.fetchRow(row, page: page, limit: viewAllPageLimit);
|
||||||
|
items.addAll([for (final item in res.items) item.toMediaItem()]);
|
||||||
|
if (!res.hasMore) break;
|
||||||
|
if (page >= viewAllMaxPages) {
|
||||||
|
appLogger.w('Explore: ${row.name} View All truncated at ${items.length} items ($page pages)');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSourcesChanged() {
|
||||||
|
final next = _catalogSources.activeSource;
|
||||||
|
if (identical(next, _source)) return;
|
||||||
|
_source?.watchlistChanges.removeListener(_onWatchlistChanged);
|
||||||
|
_source = next;
|
||||||
|
_source?.watchlistChanges.addListener(_onWatchlistChanged);
|
||||||
|
_generation++;
|
||||||
|
_watchlistRefreshTimer?.cancel();
|
||||||
|
// Detach any in-flight passes for the old source: their generation guard
|
||||||
|
// already discards their results, but the new source's load must not
|
||||||
|
// coalesce into them (that left the tab stuck on the loading state).
|
||||||
|
_loadCoalescer.reset();
|
||||||
|
_watchlistRefreshCoalescer.reset();
|
||||||
|
_watchlistMutationEpoch = 0;
|
||||||
|
_watchlistRowFetchedEpoch = 0;
|
||||||
|
_rows = {};
|
||||||
|
_loadedAt = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
_state = ExploreLoadState.initial;
|
||||||
|
_rowsEpoch++;
|
||||||
|
safeNotifyListeners();
|
||||||
|
if (next != null) unawaited(load());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onWatchlistChanged() {
|
||||||
|
// Always bump: a mutation during the initial full load has no row to
|
||||||
|
// patch yet, but the load's completion checks this epoch to catch up.
|
||||||
|
_watchlistMutationEpoch++;
|
||||||
|
if (!_rows.containsKey(CatalogRowId.watchlist)) return;
|
||||||
|
_scheduleWatchlistRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleWatchlistRefresh() {
|
||||||
|
_watchlistRefreshTimer?.cancel();
|
||||||
|
_watchlistRefreshTimer = Timer(_watchlistRefreshDelay, () => unawaited(_refreshWatchlistRow()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshWatchlistRow() => _watchlistRefreshCoalescer.run(_refreshWatchlistRowOnce);
|
||||||
|
|
||||||
|
Future<void> _refreshWatchlistRowOnce() async {
|
||||||
|
final source = _source;
|
||||||
|
if (source == null || isDisposed) return;
|
||||||
|
final generation = _generation;
|
||||||
|
final coveredEpoch = _watchlistMutationEpoch;
|
||||||
|
try {
|
||||||
|
final page = await source.fetchRow(CatalogRowId.watchlist, limit: rowLimit);
|
||||||
|
if (isDisposed || generation != _generation) return;
|
||||||
|
_rows = {..._rows, CatalogRowId.watchlist: page};
|
||||||
|
_rowsEpoch++;
|
||||||
|
_watchlistRowFetchedEpoch = coveredEpoch;
|
||||||
|
safeNotifyListeners();
|
||||||
|
if (_watchlistMutationEpoch > coveredEpoch) {
|
||||||
|
// Mutations that arrived while this pass was in flight coalesced
|
||||||
|
// into it but aren't reflected in its page — go around once more.
|
||||||
|
_scheduleWatchlistRefresh();
|
||||||
|
} else {
|
||||||
|
// Fully caught up: a still-pending debounce would only refetch the
|
||||||
|
// same state.
|
||||||
|
_watchlistRefreshTimer?.cancel();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Explore: watchlist row refresh failed', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_catalogSources.removeListener(_onSourcesChanged);
|
||||||
|
_source?.watchlistChanges.removeListener(_onWatchlistChanged);
|
||||||
|
_watchlistRefreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../focus/focusable_action_bar.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/media_hub.dart';
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import '../models/catalog/catalog_cast_member.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
|
import '../services/catalog/catalog_library_matcher.dart';
|
||||||
|
import '../services/catalog/catalog_source.dart';
|
||||||
|
import '../services/catalog/seerr_catalog_source.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/desktop_window_padding.dart';
|
||||||
|
import '../utils/formatters.dart';
|
||||||
|
import '../utils/media_navigation_helper.dart';
|
||||||
|
import '../utils/snackbar_helper.dart';
|
||||||
|
import '../widgets/app_bar_back_button.dart';
|
||||||
|
import '../widgets/app_icon.dart';
|
||||||
|
import '../widgets/backend_badge.dart';
|
||||||
|
import '../widgets/cast_member_strip.dart';
|
||||||
|
import '../widgets/hub_section.dart';
|
||||||
|
import '../widgets/optimized_media_image.dart';
|
||||||
|
import '../widgets/overlay_sheet.dart';
|
||||||
|
import '../widgets/seerr_request_sheet.dart';
|
||||||
|
import '../widgets/settings_section.dart';
|
||||||
|
import '../widgets/stat_chip.dart';
|
||||||
|
|
||||||
|
/// Detail screen for a catalog item (Explore tab). Renders from provider
|
||||||
|
/// data — no media server required — and resolves library availability in
|
||||||
|
/// place: an "In these libraries" list when the item is owned, tappable
|
||||||
|
/// through to the normal media detail screen.
|
||||||
|
class CatalogItemDetailScreen extends StatefulWidget {
|
||||||
|
final CatalogItem item;
|
||||||
|
|
||||||
|
const CatalogItemDetailScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CatalogItemDetailScreen> createState() => _CatalogItemDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
||||||
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||||
|
CatalogSource? _watchlistSource;
|
||||||
|
SeerrCatalogSource? _requestSource;
|
||||||
|
bool _mutatingWatchlist = false;
|
||||||
|
|
||||||
|
/// Library items matching this catalog item; null while resolving.
|
||||||
|
List<MediaItem>? _matches;
|
||||||
|
|
||||||
|
/// Cast/characters from the item's own source; null while loading (the
|
||||||
|
/// section only renders once loaded non-empty).
|
||||||
|
List<CatalogCastMember>? _cast;
|
||||||
|
|
||||||
|
/// "More like this" from the item's own source; null while loading (the
|
||||||
|
/// row only renders once loaded non-empty).
|
||||||
|
List<CatalogItem>? _related;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_resolveMatches());
|
||||||
|
unawaited(_loadCast());
|
||||||
|
unawaited(_loadRelated());
|
||||||
|
final sources = context.read<CatalogSourcesProvider>();
|
||||||
|
_watchlistSource = sources.watchlistSourceFor(widget.item);
|
||||||
|
// Request needs a connected Seerr, the permission for this kind, and a
|
||||||
|
// tmdb id (Trakt items carry one natively; MAL items get theirs from the
|
||||||
|
// Fribb mapping at row time).
|
||||||
|
final seerr = sources.seerrSource;
|
||||||
|
if (seerr != null && widget.item.ids.tmdb != null && seerr.canRequest(widget.item.kind)) {
|
||||||
|
_requestSource = seerr;
|
||||||
|
}
|
||||||
|
final source = _watchlistSource;
|
||||||
|
if (source != null) {
|
||||||
|
source.watchlistChanges.addListener(_onWatchlistChanged);
|
||||||
|
if (source.isOnWatchlist(widget.item.kind, widget.item.ids) == null) {
|
||||||
|
unawaited(source.ensureWatchlistLoaded());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _actionBarKey.currentState?.requestFocusOnFirst();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onWatchlistChanged() {
|
||||||
|
// ignore: no-empty-block - membership state lives in the source
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveMatches() async {
|
||||||
|
try {
|
||||||
|
final matches = await context.read<CatalogLibraryMatcher>().match(widget.item);
|
||||||
|
if (mounted) setState(() => _matches = matches);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Catalog library match failed for ${widget.item.identityKey}', error: e);
|
||||||
|
if (mounted) setState(() => _matches = const []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatalogSource? get _ownSource =>
|
||||||
|
context.read<CatalogSourcesProvider>().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source);
|
||||||
|
|
||||||
|
/// One lazy request against the item's own source; failures just leave the
|
||||||
|
/// section hidden.
|
||||||
|
Future<void> _loadCast() async {
|
||||||
|
final source = _ownSource;
|
||||||
|
if (source == null) return;
|
||||||
|
try {
|
||||||
|
final cast = await source.fetchCast(widget.item);
|
||||||
|
if (mounted) setState(() => _cast = cast);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Catalog cast load failed for ${widget.item.identityKey}', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lazy request against the item's own source; failures just leave the
|
||||||
|
/// row hidden.
|
||||||
|
Future<void> _loadRelated() async {
|
||||||
|
final source = _ownSource;
|
||||||
|
if (source == null) return;
|
||||||
|
try {
|
||||||
|
final related = await source.fetchRelated(widget.item);
|
||||||
|
if (mounted) setState(() => _related = related);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Catalog related load failed for ${widget.item.identityKey}', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(widget.item.kind, widget.item.ids);
|
||||||
|
|
||||||
|
Future<void> _toggleWatchlist() async {
|
||||||
|
final source = _watchlistSource;
|
||||||
|
final current = _isOnWatchlist;
|
||||||
|
if (source == null || current == null || _mutatingWatchlist) return;
|
||||||
|
_mutatingWatchlist = true;
|
||||||
|
try {
|
||||||
|
if (current) {
|
||||||
|
await source.removeFromWatchlist(widget.item.kind, widget.item.ids);
|
||||||
|
} else {
|
||||||
|
await source.addToWatchlist(widget.item.kind, widget.item.ids);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||||
|
} finally {
|
||||||
|
_mutatingWatchlist = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Library availability, resolved in place: a progress row while the
|
||||||
|
/// matcher runs, "Not in your library" when nothing matched, otherwise an
|
||||||
|
/// "In these libraries" list whose rows open the normal media detail
|
||||||
|
/// screen. Rows are focusable tiles (dpad-safe, background focus effect).
|
||||||
|
Widget _buildLibrarySection(ThemeData theme) {
|
||||||
|
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5));
|
||||||
|
final matches = _matches;
|
||||||
|
|
||||||
|
if (matches == null) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(t.explore.checkingLibrary, style: mutedStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
AppIcon(Symbols.info_rounded, fill: 1, size: 18, color: theme.colorScheme.onSurface.withValues(alpha: 0.5)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(t.explore.notInLibrary, style: mutedStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(t.explore.inTheseLibraries, style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// M3E grouped cards, same row anatomy as the settings/trackers hub:
|
||||||
|
// server-type logo leading, name, chevron trailing. The tiles' native
|
||||||
|
// ink highlight inside SettingsGroup's shaped Material is the d-pad
|
||||||
|
// focus visual.
|
||||||
|
SettingsGroup(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
for (final match in matches)
|
||||||
|
ListTile(
|
||||||
|
leading: BackendBadge(backend: match.backend, size: 24),
|
||||||
|
// Plex matches carry their library title; Jellyfin's
|
||||||
|
// search-based lookup doesn't, so fall back to the server
|
||||||
|
// name alone (the badge already shows the server type).
|
||||||
|
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
|
||||||
|
subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null,
|
||||||
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||||
|
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _metaLine {
|
||||||
|
final item = widget.item;
|
||||||
|
final parts = <String>[
|
||||||
|
if (item.year != null) '${item.year}',
|
||||||
|
if (item.runtimeMinutes != null) formatDurationTextual(Duration(minutes: item.runtimeMinutes!).inMilliseconds),
|
||||||
|
if (item.certification != null && item.certification!.isNotEmpty) item.certification!,
|
||||||
|
];
|
||||||
|
return parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _statusLabel(CatalogAirStatus status) => switch (status) {
|
||||||
|
CatalogAirStatus.airing => t.explore.status.airing,
|
||||||
|
CatalogAirStatus.ended => t.explore.status.ended,
|
||||||
|
CatalogAirStatus.canceled => t.explore.status.canceled,
|
||||||
|
CatalogAirStatus.upcoming => t.explore.status.upcoming,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Score (with a compact vote count), airing status, episode count, and
|
||||||
|
/// network/studio — all data that rode along on the row fetch.
|
||||||
|
Widget? _buildStatsChips(ThemeData theme) {
|
||||||
|
final item = widget.item;
|
||||||
|
String? score;
|
||||||
|
if (item.rating != null) {
|
||||||
|
score = item.rating!.toStringAsFixed(1);
|
||||||
|
if (item.votes != null && item.votes! > 0) {
|
||||||
|
final compactVotes = NumberFormat.compact(locale: LocaleSettings.currentLocale.languageCode);
|
||||||
|
score = '$score (${compactVotes.format(item.votes)})';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final chips = <Widget>[
|
||||||
|
if (score != null) StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: score),
|
||||||
|
if (item.airStatus != null) StatChip(label: _statusLabel(item.airStatus!)),
|
||||||
|
if (item.episodeCount != null) StatChip(label: t.explore.episodeCount(n: item.episodeCount!)),
|
||||||
|
if (item.network != null) StatChip(label: item.network!),
|
||||||
|
];
|
||||||
|
if (chips.isEmpty) return null;
|
||||||
|
return Wrap(spacing: 8, runSpacing: 8, children: chips);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal cast strip — the same [CastMemberStrip] cards as the media
|
||||||
|
/// detail screen. Trakt serves actors with their character; MAL serves
|
||||||
|
/// characters with their role, so the section is titled accordingly.
|
||||||
|
Widget _buildCastSection(ThemeData theme, List<CatalogCastMember> cast) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.item.source == CatalogSourceId.mal ? t.explore.characters : t.explore.cast,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
CastMemberStrip(
|
||||||
|
members: [
|
||||||
|
for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "More like this" from the item's own source, rendered through the
|
||||||
|
/// standard shelf so cards, long-press menus, and taps behave exactly like
|
||||||
|
/// the Explore rows (tap opens another catalog detail screen).
|
||||||
|
Widget _buildRelatedSection(List<CatalogItem> related) {
|
||||||
|
return HubSection(
|
||||||
|
hub: MediaHub(
|
||||||
|
id: 'catalog-related:${widget.item.source.name}:${widget.item.identityKey}',
|
||||||
|
identifier: 'explore.related',
|
||||||
|
title: t.discover.moreLikeThis,
|
||||||
|
type: 'mixed',
|
||||||
|
items: [for (final item in related) item.toMediaItem()],
|
||||||
|
size: related.length,
|
||||||
|
),
|
||||||
|
icon: Symbols.recommend_rounded,
|
||||||
|
inset: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = widget.item;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final onWatchlist = _isOnWatchlist;
|
||||||
|
|
||||||
|
final viewInsets = MediaQuery.paddingOf(context);
|
||||||
|
// The request sheet uses OverlaySheetController.showAdaptive; the host
|
||||||
|
// keeps it dpad-safe on TV, and canPop opts into its PopScope so a
|
||||||
|
// system back closes an open sheet instead of popping this screen.
|
||||||
|
return OverlaySheetHost(
|
||||||
|
canPop: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
SingleChildScrollView(
|
||||||
|
// The backdrop lives inside the scrollable so it moves with
|
||||||
|
// the content (it extends under the status bar, so the safe
|
||||||
|
// areas are baked into the content padding instead of a
|
||||||
|
// SafeArea around the scroll view).
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (item.backdropUrl != null)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 320,
|
||||||
|
child: ShaderMask(
|
||||||
|
shaderCallback: (rect) => LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
|
||||||
|
stops: const [0.3, 1.0],
|
||||||
|
).createShader(rect),
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
child: OptimizedMediaImage.thumb(
|
||||||
|
imagePath: item.backdropUrl,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 320,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallbackIcon: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: .start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.title,
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: .ellipsis,
|
||||||
|
),
|
||||||
|
if (_metaLine.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_metaLine,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (item.genres?.isNotEmpty ?? false) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
item.genres!.join(' • '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_watchlistSource != null || _requestSource != null)
|
||||||
|
FocusableActionBar(
|
||||||
|
key: _actionBarKey,
|
||||||
|
actions: [
|
||||||
|
if (_watchlistSource != null)
|
||||||
|
FocusableAction(
|
||||||
|
icon: onWatchlist ?? false
|
||||||
|
? Symbols.bookmark_added_rounded
|
||||||
|
: Symbols.bookmark_add_rounded,
|
||||||
|
tooltip: onWatchlist ?? false
|
||||||
|
? t.explore.removeFromWatchlist
|
||||||
|
: t.explore.addToWatchlist,
|
||||||
|
onPressed: onWatchlist == null
|
||||||
|
? () {}
|
||||||
|
: () => unawaited(_toggleWatchlist()),
|
||||||
|
),
|
||||||
|
if (_requestSource case final SeerrCatalogSource seerr)
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.download_rounded,
|
||||||
|
tooltip: t.seerr.request,
|
||||||
|
onPressed: () => unawaited(
|
||||||
|
showSeerrRequestSheet(
|
||||||
|
context,
|
||||||
|
source: seerr,
|
||||||
|
kind: item.kind,
|
||||||
|
tmdbId: item.ids.tmdb!,
|
||||||
|
title: item.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLibrarySection(theme),
|
||||||
|
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildCastSection(theme, cast),
|
||||||
|
],
|
||||||
|
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildRelatedSection(related),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: DesktopAppBarHelper.buildAdjustedLeading(
|
||||||
|
const AppBarBackButton(style: BackButtonStyle.circular),
|
||||||
|
context: context,
|
||||||
|
)!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
import '../focus/focusable_text_field.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import '../mixins/debounced_media_search.dart';
|
||||||
|
import '../services/catalog/catalog_source.dart';
|
||||||
|
import '../utils/focus_utils.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
|
import '../widgets/app_icon.dart';
|
||||||
|
import '../widgets/focusable_media_card.dart';
|
||||||
|
import '../widgets/focused_scroll_scaffold.dart';
|
||||||
|
import '../widgets/loading_indicator_box.dart';
|
||||||
|
import '../widgets/pill_input_decoration.dart';
|
||||||
|
import 'libraries/state_messages.dart';
|
||||||
|
|
||||||
|
/// Free-text search of one catalog source (the Explore tab's active source),
|
||||||
|
/// pushed from the Explore app bar. Results are catalog items rendered
|
||||||
|
/// through the synthesized-MediaItem card stack, so taps land on the catalog
|
||||||
|
/// detail screen with library matching, exactly like the Explore rows.
|
||||||
|
class CatalogSearchScreen extends StatefulWidget {
|
||||||
|
final CatalogSource source;
|
||||||
|
|
||||||
|
const CatalogSearchScreen({super.key, required this.source});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CatalogSearchScreen> createState() => _CatalogSearchScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
|
||||||
|
@override
|
||||||
|
String get searchDebugLabel => 'CatalogSearch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<MediaItem>> performSearchQuery(String query) async {
|
||||||
|
final items = await widget.source.search(query);
|
||||||
|
return [for (final item in items) item.toMediaItem()];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final sourceName = widget.source.displayName;
|
||||||
|
return FocusedScrollScaffold(
|
||||||
|
title: Text(t.explore.searchHint(source: sourceName)),
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||||
|
child: FocusableTextField(
|
||||||
|
controller: searchController,
|
||||||
|
focusNode: searchFocusNode,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||||
|
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||||
|
decoration: pillInputDecoration(
|
||||||
|
context,
|
||||||
|
hintText: t.explore.searchHint(source: sourceName),
|
||||||
|
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||||
|
suffixIcon: searchController.text.isNotEmpty
|
||||||
|
? IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: searchController.clear)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSearching)
|
||||||
|
LoadingIndicatorBox.sliver
|
||||||
|
else if (!hasSearched)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: StateMessageWidget(
|
||||||
|
message: t.explore.searchPrompt(source: sourceName),
|
||||||
|
icon: Symbols.search_rounded,
|
||||||
|
iconSize: 80,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (lastSearchFailed)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: StateMessageWidget(message: t.explore.searchFailed, icon: Symbols.error_rounded, iconSize: 80),
|
||||||
|
)
|
||||||
|
else if (searchResults.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: StateMessageWidget(
|
||||||
|
message: t.explore.searchEmpty(query: lastSearchedQuery),
|
||||||
|
icon: Symbols.search_off_rounded,
|
||||||
|
iconSize: 80,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildResultsList(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultsList() {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final item = searchResults[index];
|
||||||
|
return FocusableMediaCard(
|
||||||
|
key: Key(item.globalKey),
|
||||||
|
item: item,
|
||||||
|
forceListMode: true,
|
||||||
|
disableScale: true,
|
||||||
|
focusNode: index == 0 ? firstResultFocusNode : null,
|
||||||
|
onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: searchResults.length,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addSemanticIndexes: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../focus/focusable_action_bar.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/ids.dart';
|
||||||
|
import '../media/media_hub.dart';
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import '../mixins/refreshable.dart';
|
||||||
|
import '../mixins/tab_visibility_aware.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../navigation/main_screen_scope.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
|
import '../providers/explore_provider.dart';
|
||||||
|
import '../services/catalog/catalog_source.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../utils/layout_constants.dart';
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
|
import '../utils/provider_extensions.dart';
|
||||||
|
import '../widgets/app_icon.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/catalog_source_logo.dart';
|
||||||
|
import '../widgets/desktop_app_bar.dart';
|
||||||
|
import '../widgets/hub_section.dart';
|
||||||
|
import '../widgets/settings_builder.dart';
|
||||||
|
import '../widgets/tv_browse_rail.dart';
|
||||||
|
import '../widgets/tv_spotlight_background.dart';
|
||||||
|
import 'catalog_search_screen.dart';
|
||||||
|
import 'libraries/state_messages.dart';
|
||||||
|
|
||||||
|
/// The Explore tab: watchlist + discover rows from the active external
|
||||||
|
/// catalog source (Trakt). Only mounted when a source is connected (the tab
|
||||||
|
/// is hidden otherwise, see [NavigationTab.getVisibleTabs]).
|
||||||
|
class ExploreScreen extends StatefulWidget {
|
||||||
|
const ExploreScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExploreScreen> createState() => ExploreScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExploreScreenState extends State<ExploreScreen>
|
||||||
|
with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab {
|
||||||
|
late ExploreProvider _explore;
|
||||||
|
|
||||||
|
/// Per-row focus keys, keyed by hub id so focus memory survives reloads.
|
||||||
|
final Map<String, GlobalKey<HubSectionState>> _hubKeysById = {};
|
||||||
|
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||||
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||||
|
|
||||||
|
// TV spotlight layout (mirrors LibraryRecommendedTab's rail + backdrop).
|
||||||
|
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||||
|
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_explore = context.read<ExploreProvider>();
|
||||||
|
_explore.ensureFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void refresh() {
|
||||||
|
_explore.ensureFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void fullRefresh() {
|
||||||
|
unawaited(_explore.load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onTabShown() {
|
||||||
|
_explore.ensureFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onTabHidden() {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_spotlightItem.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void focusActiveTabIfReady() {
|
||||||
|
if (PlatformDetector.isTV()) {
|
||||||
|
_tvBrowseRailKey.currentState?.requestFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setSpotlightItem(MediaItem item) {
|
||||||
|
_spotlightItem.value = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaItem? _effectiveSpotlightItem(List<ExploreRowHub> rowHubs) {
|
||||||
|
final current = _spotlightItem.value;
|
||||||
|
if (current != null) {
|
||||||
|
for (final rowHub in rowHubs) {
|
||||||
|
if (rowHub.hub.items.any((item) => item.id == current.id)) return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rowHubs.firstOrNull?.hub.items.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateHubKeys(List<ExploreRowHub> rowHubs) {
|
||||||
|
final liveIds = <String>{for (final rowHub in rowHubs) rowHub.hub.id};
|
||||||
|
_hubKeysById.removeWhere((id, _) => !liveIds.contains(id));
|
||||||
|
_orderedHubKeys = [
|
||||||
|
for (final rowHub in rowHubs) _hubKeysById.putIfAbsent(rowHub.hub.id, GlobalKey<HubSectionState>.new),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
|
||||||
|
final keys = _orderedHubKeys;
|
||||||
|
if (keys.isEmpty) return false;
|
||||||
|
|
||||||
|
if (isUp && hubIndex == 0) {
|
||||||
|
_actionBarKey.currentState?.requestFocusOnFirst();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1;
|
||||||
|
// At a boundary, consume the event so focus can't escape the rows.
|
||||||
|
if (targetIndex < 0 || targetIndex >= keys.length) return true;
|
||||||
|
keys[targetIndex].currentState?.requestFocusFromMemory();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToSidebar() {
|
||||||
|
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
static IconData _rowIcon(CatalogRowId row) => switch (row) {
|
||||||
|
CatalogRowId.watchlist => Symbols.bookmark_rounded,
|
||||||
|
CatalogRowId.recommendedMovies ||
|
||||||
|
CatalogRowId.recommendedShows ||
|
||||||
|
CatalogRowId.suggestedAnime => Symbols.thumb_up_rounded,
|
||||||
|
CatalogRowId.trendingMovies ||
|
||||||
|
CatalogRowId.trendingShows ||
|
||||||
|
CatalogRowId.airingAnime ||
|
||||||
|
CatalogRowId.trending => Symbols.trending_up_rounded,
|
||||||
|
CatalogRowId.popularMovies || CatalogRowId.popularShows || CatalogRowId.popularAnime => Symbols.whatshot_rounded,
|
||||||
|
CatalogRowId.upcomingMovies || CatalogRowId.upcomingShows => Symbols.event_upcoming_rounded,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// App-bar title: the active source name, as a switcher dropdown when more
|
||||||
|
/// than one source is connected (mirrors the libraries dropdown).
|
||||||
|
Widget _buildTitle(CatalogSourcesProvider sources) {
|
||||||
|
final active = sources.activeSource;
|
||||||
|
if (active == null) return Text(t.explore.title);
|
||||||
|
if (sources.connectedSources.length < 2) {
|
||||||
|
return Text(active.displayName);
|
||||||
|
}
|
||||||
|
return AppMenuButton<CatalogSourceId>(
|
||||||
|
tooltip: t.explore.selectSource,
|
||||||
|
onSelected: (id) => unawaited(sources.setActiveSource(id)),
|
||||||
|
entriesBuilder: (context) => [
|
||||||
|
for (final source in sources.connectedSources)
|
||||||
|
AppMenuItem<CatalogSourceId>(
|
||||||
|
value: source.id,
|
||||||
|
leading: CatalogSourceLogo(source.id),
|
||||||
|
label: source.displayName,
|
||||||
|
selected: source.id == active.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [
|
||||||
|
CatalogSourceLogo(active.id, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(active.displayName, style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1, size: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final explore = context.watch<ExploreProvider>();
|
||||||
|
final sources = context.watch<CatalogSourcesProvider>();
|
||||||
|
final rowHubs = explore.rowHubs;
|
||||||
|
_updateHubKeys(rowHubs);
|
||||||
|
|
||||||
|
// One header mode for every state. Flipping floating/pinned between the
|
||||||
|
// loading/empty scroll view and the content scroll view swaps the
|
||||||
|
// SliverPersistentHeader variant (a different element type), which
|
||||||
|
// reparents the GlobalKey'd action bar into a header that builds its
|
||||||
|
// children during performLayout — and if a tooltip overlay is showing at
|
||||||
|
// that moment (hover on refresh/search), its OverlayPortal re-activation
|
||||||
|
// mutates the render tree mid-layout and asserts. Floating behaves
|
||||||
|
// identically to pinned over the non-scrolling state widgets, so nothing
|
||||||
|
// is lost by unifying.
|
||||||
|
Widget appBar() => DesktopSliverAppBar(
|
||||||
|
title: _buildTitle(sources),
|
||||||
|
pinned: false,
|
||||||
|
floating: true,
|
||||||
|
snap: true,
|
||||||
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
actions: [
|
||||||
|
FocusableActionBar(
|
||||||
|
key: _actionBarKey,
|
||||||
|
onNavigateDown: () => _orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory(),
|
||||||
|
actions: [
|
||||||
|
if (sources.activeSource case final CatalogSource source)
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.search_rounded,
|
||||||
|
tooltip: t.common.search,
|
||||||
|
onPressed: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => CatalogSearchScreen(source: source))),
|
||||||
|
),
|
||||||
|
FocusableAction(
|
||||||
|
icon: Symbols.refresh_rounded,
|
||||||
|
tooltip: t.common.refresh,
|
||||||
|
onPressed: () => unawaited(_explore.load()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget buildSimpleScroll({required Widget body}) {
|
||||||
|
return CustomScrollView(
|
||||||
|
// Android clamping physics won't start a drag on non-filling
|
||||||
|
// content, killing pull-to-refresh in the loading/empty/error
|
||||||
|
// states without this.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
appBar(),
|
||||||
|
SliverFillRemaining(child: body),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget content;
|
||||||
|
if (rowHubs.isEmpty && explore.isLoading) {
|
||||||
|
content = buildSimpleScroll(body: const Center(child: CircularProgressIndicator()));
|
||||||
|
} else if (rowHubs.isEmpty && explore.state == ExploreLoadState.error) {
|
||||||
|
content = buildSimpleScroll(
|
||||||
|
body: ErrorStateWidget(
|
||||||
|
message: explore.errorMessage ?? t.explore.emptyTitle,
|
||||||
|
icon: Symbols.error_outline_rounded,
|
||||||
|
onRetry: () => unawaited(_explore.load()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (rowHubs.isEmpty) {
|
||||||
|
content = buildSimpleScroll(
|
||||||
|
body: EmptyStateWidget(
|
||||||
|
message: t.explore.emptyMessage(source: explore.activeSource?.displayName ?? ''),
|
||||||
|
icon: Symbols.explore_rounded,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (PlatformDetector.isTV()) {
|
||||||
|
return SettingsBuilder(
|
||||||
|
prefs: const [SettingsService.hideSpoilers, SettingsService.libraryDensity, SettingsService.episodePosterMode],
|
||||||
|
builder: (context) => _buildTvContent(rowHubs),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = CustomScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
appBar(),
|
||||||
|
for (var i = 0; i < rowHubs.length; i++)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: HubSection(
|
||||||
|
key: _orderedHubKeys[i],
|
||||||
|
hub: rowHubs[i].hub,
|
||||||
|
icon: _rowIcon(rowHubs[i].row),
|
||||||
|
loadMoreItems: rowHubs[i].hub.more ? () => _explore.loadAllForRow(rowHubs[i].row) : null,
|
||||||
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(i, isUp),
|
||||||
|
onNavigateUp: i == 0 ? () => _actionBarKey.currentState?.requestFocusOnFirst() : null,
|
||||||
|
onNavigateToSidebar: _navigateToSidebar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: RefreshIndicator(onRefresh: _explore.load, child: content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CatalogRowId? _rowForHub(MediaHub hub) {
|
||||||
|
for (final rowHub in _explore.rowHubs) {
|
||||||
|
if (rowHub.hub.id == hub.id) return rowHub.row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTvContent(List<ExploreRowHub> rowHubs) {
|
||||||
|
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final svc = SettingsService.instance;
|
||||||
|
final scale = TvLayoutConstants.scaleForSize(size);
|
||||||
|
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
|
||||||
|
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||||
|
final railHeight = TvBrowseRailLayout.estimateHeight(
|
||||||
|
size: railSize,
|
||||||
|
hubs: tvHubs,
|
||||||
|
density: svc.read(SettingsService.libraryDensity),
|
||||||
|
episodePosterMode: svc.read(SettingsService.episodePosterMode),
|
||||||
|
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
|
||||||
|
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||||
|
);
|
||||||
|
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
|
||||||
|
final minimumSpotlightBottom = railHeight + (8 * scale);
|
||||||
|
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
|
||||||
|
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
|
||||||
|
? minimumSpotlightBottom
|
||||||
|
: baseSpotlightBottom;
|
||||||
|
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
|
||||||
|
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
|
||||||
|
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
||||||
|
return SideNavigationBleedBuilder(
|
||||||
|
targetBleed: foregroundLeft,
|
||||||
|
child: ValueListenableBuilder<MediaItem?>(
|
||||||
|
valueListenable: _spotlightItem,
|
||||||
|
builder: (context, _, _) {
|
||||||
|
final spotlight = _effectiveSpotlightItem(rowHubs);
|
||||||
|
return TvSpotlightBackground(
|
||||||
|
item: spotlight,
|
||||||
|
client: context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId)),
|
||||||
|
hideSpoilers: svc.read(SettingsService.hideSpoilers),
|
||||||
|
contentTop: spotlightTop,
|
||||||
|
contentBottom: spotlightBottom,
|
||||||
|
contentLeft: spotlightLeft + foregroundLeft,
|
||||||
|
compact: true,
|
||||||
|
showPrimaryAction: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
builder: (context, animatedBleed, child) =>
|
||||||
|
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: TvBrowseRail(
|
||||||
|
key: _tvBrowseRailKey,
|
||||||
|
hubs: tvHubs,
|
||||||
|
iconForHub: (hub, _) => _rowIcon(_rowForHub(hub) ?? CatalogRowId.watchlist),
|
||||||
|
onFocusedItemChanged: _setSpotlightItem,
|
||||||
|
loadMoreItems: (hub) {
|
||||||
|
final row = _rowForHub(hub);
|
||||||
|
return row == null ? Future.value(hub.items) : _explore.loadAllForRow(row);
|
||||||
|
},
|
||||||
|
onNavigateToSidebar: _navigateToSidebar,
|
||||||
|
onBack: _navigateToSidebar,
|
||||||
|
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import '../media/catalog_item_ref.dart';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -169,11 +170,17 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Catalog hubs (Explore View All) hold synthesized items with no library
|
||||||
|
/// timestamps, so a Date Added sort would silently no-op — offer only the
|
||||||
|
/// fields those items carry.
|
||||||
|
bool get _isCatalogHub => widget.hub.items.firstOrNull?.isCatalogItem ?? false;
|
||||||
|
|
||||||
List<MediaSort> _getDefaultSortOptions() {
|
List<MediaSort> _getDefaultSortOptions() {
|
||||||
return [
|
return [
|
||||||
MediaSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
|
MediaSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
|
||||||
MediaSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'),
|
MediaSort(key: 'year', descKey: 'year:desc', title: t.hubDetail.releaseYear, defaultDirection: 'desc'),
|
||||||
MediaSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'),
|
if (!_isCatalogHub)
|
||||||
|
MediaSort(key: 'addedAt', descKey: 'addedAt:desc', title: t.hubDetail.dateAdded, defaultDirection: 'desc'),
|
||||||
MediaSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'),
|
MediaSort(key: 'rating', descKey: 'rating:desc', title: t.hubDetail.rating, defaultDirection: 'desc'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import '../profiles/active_profile_binder.dart';
|
|||||||
import '../connection/connection_registry.dart';
|
import '../connection/connection_registry.dart';
|
||||||
import '../profiles/active_profile_provider.dart';
|
import '../profiles/active_profile_provider.dart';
|
||||||
import '../profiles/plex_home_service.dart';
|
import '../profiles/plex_home_service.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../providers/hidden_libraries_provider.dart';
|
import '../providers/hidden_libraries_provider.dart';
|
||||||
@@ -55,6 +56,7 @@ import '../widgets/side_navigation_rail.dart';
|
|||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/dpad_navigator.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import 'discover_screen.dart';
|
import 'discover_screen.dart';
|
||||||
|
import 'explore_screen.dart';
|
||||||
import 'libraries/library_quick_picker_sheet.dart';
|
import 'libraries/library_quick_picker_sheet.dart';
|
||||||
import 'libraries/libraries_screen.dart';
|
import 'libraries/libraries_screen.dart';
|
||||||
import 'livetv/live_tv_screen.dart';
|
import 'livetv/live_tv_screen.dart';
|
||||||
@@ -202,8 +204,10 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
OfflineModeProvider? _offlineModeProvider;
|
OfflineModeProvider? _offlineModeProvider;
|
||||||
MultiServerProvider? _multiServerProvider;
|
MultiServerProvider? _multiServerProvider;
|
||||||
|
CatalogSourcesProvider? _catalogSourcesProvider;
|
||||||
RouteObserver<PageRoute<dynamic>>? _profileRouteObserver;
|
RouteObserver<PageRoute<dynamic>>? _profileRouteObserver;
|
||||||
bool _lastHasLiveTv = false;
|
bool _lastHasLiveTv = false;
|
||||||
|
bool _lastHasExplore = false;
|
||||||
|
|
||||||
/// Whether a reconnection attempt is in progress
|
/// Whether a reconnection attempt is in progress
|
||||||
bool _isReconnecting = false;
|
bool _isReconnecting = false;
|
||||||
@@ -218,6 +222,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
late List<Widget> _screens;
|
late List<Widget> _screens;
|
||||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||||
|
final GlobalKey<State<ExploreScreen>> _exploreKey = GlobalKey();
|
||||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
||||||
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
||||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
||||||
@@ -299,6 +304,11 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
_lastHasLiveTv = false;
|
_lastHasLiveTv = false;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
_lastHasExplore = context.read<CatalogSourcesProvider>().hasAnySource;
|
||||||
|
} catch (_) {
|
||||||
|
_lastHasExplore = false;
|
||||||
|
}
|
||||||
_currentTab = _defaultTabForMode(_isOffline);
|
_currentTab = _defaultTabForMode(_isOffline);
|
||||||
_lastOnlineTabId = _isOffline ? null : NavigationTabId.discover;
|
_lastOnlineTabId = _isOffline ? null : NavigationTabId.discover;
|
||||||
_autoSwitchedToDownloads = _isOffline && _currentTab == NavigationTabId.downloads;
|
_autoSwitchedToDownloads = _isOffline && _currentTab == NavigationTabId.downloads;
|
||||||
@@ -745,6 +755,15 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
_multiServerProvider!.addListener(_handleLiveTvChanged);
|
_multiServerProvider!.addListener(_handleLiveTvChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for catalog sources (Explore tab) appearing/disappearing when a
|
||||||
|
// provider like Trakt is connected or disconnected mid-session.
|
||||||
|
final catalogSources = context.read<CatalogSourcesProvider>();
|
||||||
|
if (catalogSources != _catalogSourcesProvider) {
|
||||||
|
_catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged);
|
||||||
|
_catalogSourcesProvider = catalogSources;
|
||||||
|
_catalogSourcesProvider!.addListener(_handleCatalogSourcesChanged);
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up Companion Remote command routing (host devices only, once)
|
// Wire up Companion Remote command routing (host devices only, once)
|
||||||
if (!_companionRemoteSetup && PlatformDetector.shouldActAsRemoteHost(context)) {
|
if (!_companionRemoteSetup && PlatformDetector.shouldActAsRemoteHost(context)) {
|
||||||
_companionRemoteSetup = true;
|
_companionRemoteSetup = true;
|
||||||
@@ -823,6 +842,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
}
|
}
|
||||||
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
||||||
_multiServerProvider?.removeListener(_handleLiveTvChanged);
|
_multiServerProvider?.removeListener(_handleLiveTvChanged);
|
||||||
|
_catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged);
|
||||||
if (_bindingSettleListener != null) {
|
if (_bindingSettleListener != null) {
|
||||||
_activeProfileForListener?.removeListener(_bindingSettleListener!);
|
_activeProfileForListener?.removeListener(_bindingSettleListener!);
|
||||||
}
|
}
|
||||||
@@ -912,6 +932,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
for (final tab in _getVisibleTabs(offline))
|
for (final tab in _getVisibleTabs(offline))
|
||||||
switch (tab.id) {
|
switch (tab.id) {
|
||||||
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
||||||
|
NavigationTabId.explore => ExploreScreen(key: _exploreKey),
|
||||||
NavigationTabId.libraries => LibrariesScreen(
|
NavigationTabId.libraries => LibrariesScreen(
|
||||||
key: _librariesKey,
|
key: _librariesKey,
|
||||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||||
@@ -936,6 +957,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
NavigationTabId _defaultTabForMode(bool isOffline) => NavigationTab.resolveDefaultTab(
|
NavigationTabId _defaultTabForMode(bool isOffline) => NavigationTab.resolveDefaultTab(
|
||||||
isOffline: isOffline,
|
isOffline: isOffline,
|
||||||
hasLiveTv: _hasLiveTv,
|
hasLiveTv: _hasLiveTv,
|
||||||
|
hasExplore: _lastHasExplore,
|
||||||
preferredStartup: SettingsService.instanceOrNull?.read(SettingsService.startupSection),
|
preferredStartup: SettingsService.instanceOrNull?.read(SettingsService.startupSection),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -994,6 +1016,20 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleCatalogSourcesChanged() {
|
||||||
|
final hasExplore = _catalogSourcesProvider?.hasAnySource ?? false;
|
||||||
|
if (hasExplore == _lastHasExplore) return;
|
||||||
|
_lastHasExplore = hasExplore;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_screens = _buildScreens(_isOffline);
|
||||||
|
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
||||||
|
});
|
||||||
|
// Same as the live-TV handler: the passthrough flag depends on whether
|
||||||
|
// _currentTab is the first tab, which the normalize above can change.
|
||||||
|
_updateTvosMenuPassthrough();
|
||||||
|
}
|
||||||
|
|
||||||
void _handleOfflineStatusChanged() {
|
void _handleOfflineStatusChanged() {
|
||||||
final hasVisibleConnectedServers = context.read<MultiServerProvider>().hasConnectedServers;
|
final hasVisibleConnectedServers = context.read<MultiServerProvider>().hasConnectedServers;
|
||||||
if (hasVisibleConnectedServers) _offlineUntilConnected = false;
|
if (hasVisibleConnectedServers) _offlineUntilConnected = false;
|
||||||
@@ -1527,7 +1563,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
/// Get navigation tabs filtered by offline mode
|
/// Get navigation tabs filtered by offline mode
|
||||||
List<NavigationTab> _getVisibleTabs(bool isOffline) {
|
List<NavigationTab> _getVisibleTabs(bool isOffline) {
|
||||||
return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv);
|
return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv, hasExplore: _lastHasExplore);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<NavigationTab> _getBottomNavigationTabs(BuildContext context) {
|
List<NavigationTab> _getBottomNavigationTabs(BuildContext context) {
|
||||||
@@ -1543,6 +1579,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
GlobalKey? _screenKeyFor(NavigationTabId tab) {
|
GlobalKey? _screenKeyFor(NavigationTabId tab) {
|
||||||
return switch (tab) {
|
return switch (tab) {
|
||||||
NavigationTabId.discover => _discoverKey,
|
NavigationTabId.discover => _discoverKey,
|
||||||
|
NavigationTabId.explore => _exploreKey,
|
||||||
NavigationTabId.libraries => _librariesKey,
|
NavigationTabId.libraries => _librariesKey,
|
||||||
NavigationTabId.liveTv => _liveTvKey,
|
NavigationTabId.liveTv => _liveTvKey,
|
||||||
NavigationTabId.search => _searchKey,
|
NavigationTabId.search => _searchKey,
|
||||||
|
|||||||
@@ -194,6 +194,38 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
|||||||
_buildWatchedToggleButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
|
_buildWatchedToggleButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Watchlist toggle for the connected catalog sources (Trakt, MAL).
|
||||||
|
// Membership reads each source's session snapshot — no per-open API
|
||||||
|
// call. Filled when the item is on ANY source's watchlist; with several
|
||||||
|
// candidates the press opens a source chooser.
|
||||||
|
// Not in the compact tiers: it drops away first on narrow screens.
|
||||||
|
final watchlistStates = [
|
||||||
|
for (final candidate in _watchlistCandidates) candidate.source.isOnWatchlist(metadata.kind, candidate.ids),
|
||||||
|
];
|
||||||
|
final bool? onWatchlist = watchlistStates.contains(true)
|
||||||
|
? true
|
||||||
|
: watchlistStates.contains(false)
|
||||||
|
? false
|
||||||
|
: null;
|
||||||
|
final watchlistAction = _watchlistCandidates.isEmpty
|
||||||
|
? null
|
||||||
|
: FocusableAction(
|
||||||
|
debugLabel: 'detail_watchlist',
|
||||||
|
onPressed: () => unawaited(_handleWatchlistTogglePressed(metadata)),
|
||||||
|
builder: (context, state) => KeyedSubtree(
|
||||||
|
key: _watchlistButtonKey,
|
||||||
|
child: iconActionButton(
|
||||||
|
state,
|
||||||
|
onPressed: onWatchlist == null ? null : () => unawaited(_handleWatchlistTogglePressed(metadata)),
|
||||||
|
icon: AppIcon(
|
||||||
|
(onWatchlist ?? false) ? Symbols.bookmark_added_rounded : Symbols.bookmark_add_rounded,
|
||||||
|
fill: 1,
|
||||||
|
),
|
||||||
|
tooltip: (onWatchlist ?? false) ? t.explore.removeFromWatchlist : t.explore.addToWatchlist,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
void showMoreActions() => _contextMenuKey.currentState?.showContextMenu(context);
|
void showMoreActions() => _contextMenuKey.currentState?.showContextMenu(context);
|
||||||
|
|
||||||
final moreActionsAction = widget.isOffline
|
final moreActionsAction = widget.isOffline
|
||||||
@@ -216,6 +248,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
|||||||
?shuffleAction,
|
?shuffleAction,
|
||||||
?downloadAction,
|
?downloadAction,
|
||||||
watchedAction,
|
watchedAction,
|
||||||
|
?watchlistAction,
|
||||||
?moreActionsAction,
|
?moreActionsAction,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -280,6 +313,69 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleWatchlistTogglePressed(MediaItem metadata) async {
|
||||||
|
final candidates = _watchlistCandidates;
|
||||||
|
if (candidates.isEmpty || _watchlistMutationInFlight) return;
|
||||||
|
// Parity with the disabled pointer button: while every membership is
|
||||||
|
// still unknown, a dpad press kicks the snapshot loads instead of
|
||||||
|
// opening a chooser whose selection would silently no-op.
|
||||||
|
if (candidates.every((c) => c.source.isOnWatchlist(metadata.kind, c.ids) == null)) {
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
unawaited(candidate.source.ensureWatchlistLoaded());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidates.length == 1) {
|
||||||
|
await _toggleWatchlistOn(metadata, candidates.single);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several providers can hold this item: choose per press. Each entry
|
||||||
|
// shows that source's current membership.
|
||||||
|
final renderBox = _watchlistButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox == null) return;
|
||||||
|
final choice = await showAppMenu<WatchlistCandidate>(
|
||||||
|
context,
|
||||||
|
anchorRect: renderBox.localToGlobal(Offset.zero) & renderBox.size,
|
||||||
|
focusFirstItem: true,
|
||||||
|
entries: [
|
||||||
|
for (final candidate in candidates)
|
||||||
|
AppMenuItem(
|
||||||
|
value: candidate,
|
||||||
|
leading: CatalogSourceLogo(candidate.source.id),
|
||||||
|
label: candidate.source.displayName,
|
||||||
|
subtitle: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false)
|
||||||
|
? t.explore.removeFromWatchlist
|
||||||
|
: t.explore.addToWatchlist,
|
||||||
|
trailing: (candidate.source.isOnWatchlist(metadata.kind, candidate.ids) ?? false)
|
||||||
|
? const AppIcon(Symbols.bookmark_added_rounded, fill: 1)
|
||||||
|
: const AppIcon(Symbols.bookmark_add_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (choice == null || !mounted) return;
|
||||||
|
await _toggleWatchlistOn(metadata, choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleWatchlistOn(MediaItem metadata, WatchlistCandidate candidate) async {
|
||||||
|
final current = candidate.source.isOnWatchlist(metadata.kind, candidate.ids);
|
||||||
|
if (current == null || _watchlistMutationInFlight) return;
|
||||||
|
_watchlistMutationInFlight = true;
|
||||||
|
try {
|
||||||
|
// Optimistic inside the source; the row/screens listening to
|
||||||
|
// watchlistChanges (including this one) rebuild immediately.
|
||||||
|
if (current) {
|
||||||
|
await candidate.source.removeFromWatchlist(metadata.kind, candidate.ids);
|
||||||
|
} else {
|
||||||
|
await candidate.source.addToWatchlist(metadata.kind, candidate.ids);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||||
|
} finally {
|
||||||
|
_watchlistMutationInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleWatchedTogglePressed(MediaItem metadata) async {
|
Future<void> _handleWatchedTogglePressed(MediaItem metadata) async {
|
||||||
try {
|
try {
|
||||||
final isWatched = metadata.isWatched;
|
final isWatched = metadata.isWatched;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import '../focus/focusable_action_bar.dart';
|
|||||||
import '../focus/focusable_wrapper.dart';
|
import '../focus/focusable_wrapper.dart';
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../focus/card_focus_scope.dart';
|
import '../widgets/cast_member_strip.dart';
|
||||||
import '../widgets/focus_builders.dart';
|
import '../widgets/focus_builders.dart';
|
||||||
import '../media/library_query.dart';
|
import '../media/library_query.dart';
|
||||||
import '../media/media_hub.dart';
|
import '../media/media_hub.dart';
|
||||||
@@ -51,12 +51,13 @@ import '../utils/download_utils.dart';
|
|||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/watch_actions.dart';
|
import '../services/watch_actions.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../utils/grid_size_calculator.dart';
|
|
||||||
import '../utils/layout_constants.dart';
|
import '../utils/layout_constants.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/offline_watch_provider.dart';
|
import '../providers/offline_watch_provider.dart';
|
||||||
import '../providers/watch_state_store.dart';
|
import '../providers/watch_state_store.dart';
|
||||||
import '../theme/mono_tokens.dart';
|
import '../services/catalog/catalog_source.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/formatters.dart';
|
import '../utils/formatters.dart';
|
||||||
import '../utils/scroll_utils.dart';
|
import '../utils/scroll_utils.dart';
|
||||||
@@ -64,6 +65,9 @@ import '../utils/dialogs.dart';
|
|||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
import '../widgets/app_bar_back_button.dart';
|
import '../widgets/app_bar_back_button.dart';
|
||||||
|
import '../widgets/app_menu.dart';
|
||||||
|
import '../widgets/catalog_source_logo.dart';
|
||||||
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../utils/desktop_window_padding.dart';
|
import '../utils/desktop_window_padding.dart';
|
||||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
import '../widgets/horizontal_scroll_with_arrows.dart';
|
||||||
import '../widgets/media_context_menu.dart';
|
import '../widgets/media_context_menu.dart';
|
||||||
@@ -102,6 +106,10 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
|
|||||||
|
|
||||||
enum _SyncRuleAction { edit, remove, delete }
|
enum _SyncRuleAction { edit, remove, delete }
|
||||||
|
|
||||||
|
/// A watchlist-capable catalog source paired with this item's ids in that
|
||||||
|
/// source's terms (see `_resolveWatchlistIds`).
|
||||||
|
typedef WatchlistCandidate = ({CatalogSource source, CatalogItemIds ids});
|
||||||
|
|
||||||
class _SeasonEpisodePager {
|
class _SeasonEpisodePager {
|
||||||
final Map<String, PagedMediaListState<MediaItem>> _states = {};
|
final Map<String, PagedMediaListState<MediaItem>> _states = {};
|
||||||
final Set<String> _firstPageLoadsInFlight = {};
|
final Set<String> _firstPageLoadsInFlight = {};
|
||||||
@@ -295,6 +303,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
final ValueNotifier<MediaItem?> _tvDetailFocusedEpisode = ValueNotifier(null);
|
final ValueNotifier<MediaItem?> _tvDetailFocusedEpisode = ValueNotifier(null);
|
||||||
bool _tvDetailActionRowHasFocus = false;
|
bool _tvDetailActionRowHasFocus = false;
|
||||||
|
|
||||||
|
// Watchlist action (external catalog sources: Trakt, MAL). External ids
|
||||||
|
// resolve once via the owning server, then per capable source; membership
|
||||||
|
// comes from each source's session snapshot, so opening details never
|
||||||
|
// costs a provider call. Multiple candidates → the toggle opens a chooser.
|
||||||
|
List<WatchlistCandidate> _watchlistCandidates = const [];
|
||||||
|
List<CatalogSource> _watchlistListenedSources = const [];
|
||||||
|
final GlobalKey _watchlistButtonKey = GlobalKey();
|
||||||
|
bool _watchlistMutationInFlight = false;
|
||||||
|
|
||||||
// Inline season tabs
|
// Inline season tabs
|
||||||
int _selectedSeasonIndex = 0;
|
int _selectedSeasonIndex = 0;
|
||||||
final _seasonEpisodePager = _SeasonEpisodePager();
|
final _seasonEpisodePager = _SeasonEpisodePager();
|
||||||
@@ -670,6 +687,47 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
_castFocusNode = FocusNode(debugLabel: 'cast_row');
|
_castFocusNode = FocusNode(debugLabel: 'cast_row');
|
||||||
_infoRowsFocusNode = FocusNode(debugLabel: 'info_rows');
|
_infoRowsFocusNode = FocusNode(debugLabel: 'info_rows');
|
||||||
_loadFullMetadata();
|
_loadFullMetadata();
|
||||||
|
_initWatchlistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook up the watchlist action's data: every watchlist-capable catalog
|
||||||
|
/// source this item resolves in, with its per-source ids. No-ops offline
|
||||||
|
/// and for non-movie/show kinds.
|
||||||
|
void _initWatchlistState() {
|
||||||
|
if (widget.isOffline || (!_metadata.isMovie && !_metadata.isShow)) return;
|
||||||
|
final sources = Provider.of<CatalogSourcesProvider?>(context, listen: false)?.watchlistCapableSources;
|
||||||
|
if (sources == null || sources.isEmpty) return;
|
||||||
|
_watchlistListenedSources = sources;
|
||||||
|
for (final source in sources) {
|
||||||
|
source.watchlistChanges.addListener(_onWatchlistSourceChanged);
|
||||||
|
unawaited(source.ensureWatchlistLoaded());
|
||||||
|
}
|
||||||
|
unawaited(_resolveWatchlistIds(sources));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveWatchlistIds(List<CatalogSource> sources) async {
|
||||||
|
try {
|
||||||
|
final ids = await _getMediaClientForMetadata(context)?.fetchExternalIds(_metadata.id);
|
||||||
|
if (!mounted || ids == null || !ids.hasAny) return;
|
||||||
|
// Sources can require their own id forms (MAL maps external ids to an
|
||||||
|
// anime id via Fribb); null means the item is outside that source's
|
||||||
|
// domain. The action shows for the sources that resolved; with more
|
||||||
|
// than one, the toggle opens a source chooser.
|
||||||
|
final candidates = <WatchlistCandidate>[];
|
||||||
|
for (final source in sources) {
|
||||||
|
final resolved = await source.resolveItemIds(_metadata.kind, ids);
|
||||||
|
if (resolved != null) candidates.add((source: source, ids: resolved));
|
||||||
|
}
|
||||||
|
if (!mounted || candidates.isEmpty) return;
|
||||||
|
setState(() => _watchlistCandidates = candidates);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('Watchlist external-id resolution failed', error: e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onWatchlistSourceChanged() {
|
||||||
|
// ignore: no-empty-block - membership state lives in the source
|
||||||
|
setStateIfMounted(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
@@ -794,6 +852,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
for (final source in _watchlistListenedSources) {
|
||||||
|
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
|
||||||
|
}
|
||||||
_routeObserver?.unsubscribe(this);
|
_routeObserver?.unsubscribe(this);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_scrollOffset.dispose();
|
_scrollOffset.dispose();
|
||||||
@@ -2143,12 +2204,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the responsive card width used by seasons/extras/cast rows.
|
/// Get the responsive card width used by seasons/extras/cast rows.
|
||||||
/// Uses the shared grid size calculator for consistency with library grids.
|
/// Delegates to the cast strip's calculator so the dpad scroll math and
|
||||||
double _getResponsiveCardWidth() {
|
/// the rendered cards can never disagree.
|
||||||
final density = SettingsService.instance.read(SettingsService.libraryDensity);
|
double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context);
|
||||||
final availableWidth = MediaQuery.sizeOf(context).width;
|
|
||||||
return GridSizeCalculator.getCellWidth(availableWidth, context, density);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle key events for the overview section
|
/// Handle key events for the overview section
|
||||||
KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) {
|
KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) {
|
||||||
@@ -2464,7 +2522,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
scrollListToIndex(
|
scrollListToIndex(
|
||||||
_castScrollController,
|
_castScrollController,
|
||||||
_focusedCastIndex,
|
_focusedCastIndex,
|
||||||
itemExtent: _getResponsiveCardWidth() + 6 + 4,
|
itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()),
|
||||||
leadingPadding: 0,
|
leadingPadding: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2478,7 +2536,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
scrollListToIndex(
|
scrollListToIndex(
|
||||||
_castScrollController,
|
_castScrollController,
|
||||||
_focusedCastIndex,
|
_focusedCastIndex,
|
||||||
itemExtent: _getResponsiveCardWidth() + 6 + 4,
|
itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()),
|
||||||
leadingPadding: 0,
|
leadingPadding: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3104,10 +3162,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
|
|
||||||
// Show loading state while fetching full metadata
|
// Show loading state while fetching full metadata
|
||||||
if (_isLoadingMetadata) {
|
if (_isLoadingMetadata) {
|
||||||
|
// A bare AppBar's auto-implied back button ignores the macOS traffic
|
||||||
|
// lights; route the leading through the shared desktop padding logic.
|
||||||
|
final backButton = AppBarBackButton(
|
||||||
|
style: BackButtonStyle.plain,
|
||||||
|
onPressed: () => Navigator.pop(context, _watchStateChanged),
|
||||||
|
);
|
||||||
final loading = Focus(
|
final loading = Focus(
|
||||||
onKeyEvent: _handleMediaDetailBackKey,
|
onKeyEvent: _handleMediaDetailBackKey,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(
|
||||||
|
leading: DesktopAppBarSections.buildLeadingSection(leading: backButton, context: context),
|
||||||
|
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(leading: backButton, context: context),
|
||||||
|
),
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -4374,94 +4441,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCastSectionContent(MediaItem metadata) {
|
Widget _buildCastSectionContent(MediaItem metadata) {
|
||||||
final cardWidth = _getResponsiveCardWidth();
|
final roles = metadata.roles!;
|
||||||
const innerPadding = 3.0;
|
|
||||||
final imageSize = cardWidth;
|
|
||||||
// image + inner padding + text area + outer list padding + focus scale headroom
|
|
||||||
final containerHeight = imageSize + innerPadding * 2 + 58 + 10;
|
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final actorNameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: .w600);
|
|
||||||
final actorRoleStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant);
|
|
||||||
|
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _castFocusNode,
|
focusNode: _castFocusNode,
|
||||||
onKeyEvent: _handleCastKeyEvent,
|
onKeyEvent: _handleCastKeyEvent,
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: _castFocusNode,
|
listenable: _castFocusNode,
|
||||||
builder: (context, _) {
|
builder: (context, _) => CastMemberStrip(
|
||||||
final hasFocus = _castFocusNode.hasFocus;
|
members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)],
|
||||||
|
imageClient: getServerBoundMediaClient(context),
|
||||||
return SizedBox(
|
controller: _castScrollController,
|
||||||
height: containerHeight,
|
focusedIndex: _castFocusNode.hasFocus ? _focusedCastIndex : null,
|
||||||
child: HorizontalScrollWithArrows(
|
onMemberTap: (index) => _navigateToActorMedia(roles[index]),
|
||||||
controller: _castScrollController,
|
),
|
||||||
builder: (scrollController) => ListView.builder(
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
controller: scrollController,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
||||||
itemCount: metadata.roles!.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final actor = metadata.roles![index];
|
|
||||||
final isFocused = hasFocus && index == _focusedCastIndex;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: FocusBuilders.buildLockedFocusWrapper(
|
|
||||||
context: context,
|
|
||||||
isFocused: isFocused,
|
|
||||||
borderRadius: tokens(context).radiusSm,
|
|
||||||
onTap: () => _navigateToActorMedia(actor),
|
|
||||||
delegateFocusBorder: true,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(innerPadding),
|
|
||||||
child: SizedBox(
|
|
||||||
width: cardWidth,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: .start,
|
|
||||||
children: [
|
|
||||||
CardFocusBorder(
|
|
||||||
borderRadius: tokens(context).radiusSm,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
||||||
child: OptimizedMediaImage(
|
|
||||||
client: getServerBoundMediaClient(context),
|
|
||||||
imagePath: actor.thumbPath,
|
|
||||||
width: imageSize,
|
|
||||||
height: imageSize,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
imageType: ImageType.avatar,
|
|
||||||
fallbackIcon: Symbols.person_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: .start,
|
|
||||||
children: [
|
|
||||||
Text(actor.tag, style: actorNameStyle, maxLines: 2, overflow: .ellipsis),
|
|
||||||
if (actor.role != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(actor.role!, style: actorRoleStyle, maxLines: 1, overflow: .ellipsis),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-148
@@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:rate_limiter/rate_limiter.dart';
|
|
||||||
|
|
||||||
import '../focus/focusable_text_field.dart';
|
import '../focus/focusable_text_field.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../mixins/controller_disposer_mixin.dart';
|
import '../mixins/debounced_media_search.dart';
|
||||||
import '../mixins/mounted_set_state_mixin.dart';
|
import '../mixins/mounted_set_state_mixin.dart';
|
||||||
import '../mixins/refreshable.dart';
|
import '../mixins/refreshable.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
@@ -30,163 +29,83 @@ class SearchScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SearchScreenState extends State<SearchScreen>
|
class _SearchScreenState extends State<SearchScreen>
|
||||||
with
|
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
||||||
Refreshable,
|
|
||||||
FullRefreshable,
|
|
||||||
SearchInputFocusable,
|
|
||||||
FocusableTab,
|
|
||||||
ControllerDisposerMixin,
|
|
||||||
MountedSetStateMixin {
|
|
||||||
late final _searchController = createTextEditingController();
|
|
||||||
final _searchFocusNode = FocusNode(debugLabel: 'SearchInput');
|
|
||||||
final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult');
|
|
||||||
List<MediaItem> _searchResults = [];
|
|
||||||
bool _isSearching = false;
|
|
||||||
bool _hasSearched = false;
|
|
||||||
late final Debounce _searchDebounce;
|
|
||||||
String _lastSearchedQuery = '';
|
|
||||||
String? _focusResultsForQuery;
|
String? _focusResultsForQuery;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchDebounce = debounce(_performSearch, const Duration(milliseconds: 500));
|
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
String get searchDebugLabel => 'Search';
|
||||||
_searchDebounce.cancel();
|
|
||||||
_searchController.removeListener(_onSearchChanged);
|
@override
|
||||||
_searchFocusNode.dispose();
|
Future<List<MediaItem>> performSearchQuery(String query) async {
|
||||||
_firstResultFocusNode.dispose();
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||||
super.dispose();
|
if (!multiServerProvider.hasConnectedServers) {
|
||||||
|
throw Exception('No servers available');
|
||||||
|
}
|
||||||
|
return multiServerProvider.aggregationService.searchAcrossServers(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged() {
|
@override
|
||||||
if (!mounted) return;
|
void onSearchError(Object error) {
|
||||||
|
_focusResultsForQuery = null;
|
||||||
final query = _searchController.text;
|
showErrorSnackBar(context, t.errors.searchFailed(error: error));
|
||||||
|
|
||||||
if (query.trim().isEmpty) {
|
|
||||||
_searchDebounce.cancel();
|
|
||||||
_focusResultsForQuery = null;
|
|
||||||
setStateIfMounted(() {
|
|
||||||
_searchResults = [];
|
|
||||||
_hasSearched = false;
|
|
||||||
_isSearching = false;
|
|
||||||
_lastSearchedQuery = '';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only search if the query has actually changed
|
|
||||||
if (query.trim() == _lastSearchedQuery.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_searchDebounce([query]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSearch(String query) async {
|
@override
|
||||||
if (!mounted) return;
|
void onSearchCleared() {
|
||||||
|
_focusResultsForQuery = null;
|
||||||
if (query.trim().isEmpty) {
|
|
||||||
setStateIfMounted(() {
|
|
||||||
_searchResults = [];
|
|
||||||
_hasSearched = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStateIfMounted(() {
|
|
||||||
_isSearching = true;
|
|
||||||
_hasSearched = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!mounted) return;
|
|
||||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
||||||
|
|
||||||
if (!multiServerProvider.hasConnectedServers) {
|
|
||||||
throw Exception('No servers available');
|
|
||||||
}
|
|
||||||
|
|
||||||
final neutral = await multiServerProvider.aggregationService.searchAcrossServers(query);
|
|
||||||
if (mounted) {
|
|
||||||
setStateIfMounted(() {
|
|
||||||
_searchResults = neutral;
|
|
||||||
_isSearching = false;
|
|
||||||
_lastSearchedQuery = query.trim();
|
|
||||||
});
|
|
||||||
_maybeFocusResultsAfterSubmit(query, neutral);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_focusResultsForQuery = null;
|
|
||||||
if (mounted) {
|
|
||||||
setStateIfMounted(() {
|
|
||||||
_isSearching = false;
|
|
||||||
});
|
|
||||||
showErrorSnackBar(context, t.errors.searchFailed(error: e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OSK "Search" / hardware Enter on TV: jump to results, or force the
|
@override
|
||||||
/// search to run now and focus results when it lands.
|
void onSearchCompleted(String query, List<MediaItem> results) {
|
||||||
void _handleSearchSubmit() {
|
if (_focusResultsForQuery == null || _focusResultsForQuery != query) return;
|
||||||
final query = _searchController.text.trim();
|
|
||||||
if (query.isEmpty) return;
|
|
||||||
|
|
||||||
if (_searchResults.isNotEmpty && !_isSearching && query == _lastSearchedQuery.trim()) {
|
|
||||||
_firstResultFocusNode.requestFocus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_focusResultsForQuery = query;
|
|
||||||
if (_searchDebounce.isPending || !_isSearching) {
|
|
||||||
_searchDebounce.cancel();
|
|
||||||
_performSearch(query);
|
|
||||||
}
|
|
||||||
// else: the in-flight search already covers the current text; its
|
|
||||||
// completion focuses the results.
|
|
||||||
}
|
|
||||||
|
|
||||||
void _maybeFocusResultsAfterSubmit(String query, List<MediaItem> results) {
|
|
||||||
if (_focusResultsForQuery == null || _focusResultsForQuery != query.trim()) return;
|
|
||||||
_focusResultsForQuery = null;
|
_focusResultsForQuery = null;
|
||||||
if (results.isEmpty) return;
|
if (results.isEmpty) return;
|
||||||
if (_searchController.text.trim() != query.trim()) return; // user kept editing
|
if (searchController.text.trim() != query) return; // user kept editing
|
||||||
FocusUtils.requestFocusAfterBuild(this, _firstResultFocusNode);
|
FocusUtils.requestFocusAfterBuild(this, firstResultFocusNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSK "Search" / hardware Enter on TV additionally focuses the results
|
||||||
|
/// when the forced search lands.
|
||||||
|
@override
|
||||||
|
void handleSearchSubmit() {
|
||||||
|
final query = searchController.text.trim();
|
||||||
|
if (query.isEmpty) return;
|
||||||
|
if (searchResults.isEmpty || isSearching || query != lastSearchedQuery) {
|
||||||
|
_focusResultsForQuery = query;
|
||||||
|
}
|
||||||
|
super.handleSearchSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void refresh() {
|
void refresh() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_searchController.text.isNotEmpty) {
|
runSearch(searchController.text.trim());
|
||||||
_performSearch(_searchController.text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Focus the search input field
|
/// Focus the search input field
|
||||||
@override
|
@override
|
||||||
void focusSearchInput() {
|
void focusSearchInput() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_searchFocusNode.requestFocus();
|
searchFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void focusActiveTabIfReady() {
|
void focusActiveTabIfReady() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_searchFocusNode.requestFocus();
|
searchFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the search query externally (e.g. from companion remote)
|
/// Set the search query externally (e.g. from companion remote)
|
||||||
@override
|
@override
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_searchController.text = query;
|
searchController.text = query;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public method to fully reload all content (for profile switches)
|
// Public method to fully reload all content (for profile switches)
|
||||||
@@ -194,23 +113,15 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
void fullRefresh() {
|
void fullRefresh() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading');
|
appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading');
|
||||||
// Clear search results and search text for new profile
|
// Clearing the field resets the search state through the text listener.
|
||||||
_searchController.clear();
|
|
||||||
_focusResultsForQuery = null;
|
_focusResultsForQuery = null;
|
||||||
setStateIfMounted(() {
|
searchController.clear();
|
||||||
_searchResults.clear();
|
|
||||||
_isSearching = false;
|
|
||||||
_hasSearched = false;
|
|
||||||
_lastSearchedQuery = '';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateItem(String _) {
|
void updateItem(String _) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Trigger a refresh of the search to get updated metadata
|
// Trigger a refresh of the search to get updated metadata
|
||||||
if (_searchController.text.isNotEmpty) {
|
runSearch(searchController.text.trim());
|
||||||
_performSearch(_searchController.text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate focus to the sidebar
|
/// Navigate focus to the sidebar
|
||||||
@@ -226,13 +137,13 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final item = _searchResults[index];
|
final item = searchResults[index];
|
||||||
return FocusableMediaCard(
|
return FocusableMediaCard(
|
||||||
key: Key(item.globalKey),
|
key: Key(item.globalKey),
|
||||||
item: item,
|
item: item,
|
||||||
forceListMode: true,
|
forceListMode: true,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
focusNode: index == 0 ? _firstResultFocusNode : null,
|
focusNode: index == 0 ? firstResultFocusNode : null,
|
||||||
onRefresh: updateItem,
|
onRefresh: updateItem,
|
||||||
onListRefresh: () => updateItem(item.id),
|
onListRefresh: () => updateItem(item.id),
|
||||||
onNavigateLeft: _navigateToSidebar,
|
onNavigateLeft: _navigateToSidebar,
|
||||||
@@ -240,7 +151,7 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
showServerName: showServerName,
|
showServerName: showServerName,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _searchResults.length,
|
childCount: searchResults.length,
|
||||||
addAutomaticKeepAlives: false,
|
addAutomaticKeepAlives: false,
|
||||||
addSemanticIndexes: false,
|
addSemanticIndexes: false,
|
||||||
),
|
),
|
||||||
@@ -260,17 +171,15 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||||
child: FocusableTextField(
|
child: FocusableTextField(
|
||||||
controller: _searchController,
|
controller: searchController,
|
||||||
focusNode: _searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onNavigateLeft: _navigateToSidebar,
|
onNavigateLeft: _navigateToSidebar,
|
||||||
onNavigateDown: _searchResults.isNotEmpty && !_isSearching
|
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||||
? _firstResultFocusNode.requestFocus
|
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||||
: null,
|
|
||||||
onEditingComplete: PlatformDetector.isTV() ? _handleSearchSubmit : null,
|
|
||||||
onBack: () {
|
onBack: () {
|
||||||
if (_searchController.text.isNotEmpty) {
|
if (searchController.text.isNotEmpty) {
|
||||||
_searchController.clear();
|
searchController.clear();
|
||||||
} else {
|
} else {
|
||||||
_navigateToSidebar();
|
_navigateToSidebar();
|
||||||
}
|
}
|
||||||
@@ -279,11 +188,11 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
context,
|
context,
|
||||||
hintText: t.search.hint,
|
hintText: t.search.hint,
|
||||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
|
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
searchController.clear();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -291,9 +200,9 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isSearching)
|
if (isSearching)
|
||||||
LoadingIndicatorBox.sliver
|
LoadingIndicatorBox.sliver
|
||||||
else if (!_hasSearched)
|
else if (!hasSearched)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: StateMessageWidget(
|
child: StateMessageWidget(
|
||||||
message: t.search.searchYourMedia,
|
message: t.search.searchYourMedia,
|
||||||
@@ -302,7 +211,11 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
iconSize: 80,
|
iconSize: 80,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_searchResults.isEmpty)
|
else if (lastSearchFailed)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: StateMessageWidget(message: t.explore.searchFailed, icon: Symbols.error_rounded, iconSize: 80),
|
||||||
|
)
|
||||||
|
else if (searchResults.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: StateMessageWidget(
|
child: StateMessageWidget(
|
||||||
message: t.messages.noResultsFound,
|
message: t.messages.noResultsFound,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../screens/catalog_item_detail_screen.dart';
|
||||||
|
|
||||||
|
/// Open a catalog item (Explore tab tap sink, routed here by the catalog
|
||||||
|
/// branches in `navigateToMediaItem` / `navigateToMediaItemDetails`).
|
||||||
|
///
|
||||||
|
/// Always lands on [CatalogItemDetailScreen]; the screen resolves library
|
||||||
|
/// availability itself and lists the matching libraries in place ("In these
|
||||||
|
/// libraries"), rather than redirecting matched items to a different screen.
|
||||||
|
Future<void> navigateToCatalogItem(BuildContext context, CatalogItem item) async {
|
||||||
|
await Navigator.push(context, MaterialPageRoute(builder: (_) => CatalogItemDetailScreen(item: item)));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/catalog_item_ref.dart';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_item_types.dart';
|
import '../media/media_item_types.dart';
|
||||||
@@ -11,6 +12,7 @@ import '../screens/media_detail_screen.dart';
|
|||||||
import '../screens/playlist/playlist_detail_screen.dart';
|
import '../screens/playlist/playlist_detail_screen.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../utils/global_key_utils.dart';
|
import '../utils/global_key_utils.dart';
|
||||||
|
import 'catalog_navigation_helper.dart';
|
||||||
import 'music_navigation.dart';
|
import 'music_navigation.dart';
|
||||||
import 'plex_library_section_helpers.dart';
|
import 'plex_library_section_helpers.dart';
|
||||||
import 'video_player_navigation.dart';
|
import 'video_player_navigation.dart';
|
||||||
@@ -167,6 +169,15 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
|||||||
return MediaNavigationResult.unsupported;
|
return MediaNavigationResult.unsupported;
|
||||||
}
|
}
|
||||||
final mi = item;
|
final mi = item;
|
||||||
|
|
||||||
|
// Catalog stand-ins (Explore tab) have no server id — every server-backed
|
||||||
|
// route below would break on them. Route to the catalog flow instead.
|
||||||
|
if (mi.isCatalogItem) {
|
||||||
|
final catalogItem = mi.catalogItem;
|
||||||
|
if (catalogItem == null) return MediaNavigationResult.unsupported;
|
||||||
|
await navigateToCatalogItem(context, catalogItem);
|
||||||
|
return MediaNavigationResult.navigated;
|
||||||
|
}
|
||||||
final settings = SettingsService.instanceOrNull;
|
final settings = SettingsService.instanceOrNull;
|
||||||
final continueWatchingAction = settings?.read(SettingsService.continueWatchingAction) ?? ContinueWatchingAction.play;
|
final continueWatchingAction = settings?.read(SettingsService.continueWatchingAction) ?? ContinueWatchingAction.play;
|
||||||
final episodeAction = settings?.read(SettingsService.episodeAction) ?? EpisodeAction.play;
|
final episodeAction = settings?.read(SettingsService.episodeAction) ?? EpisodeAction.play;
|
||||||
@@ -251,6 +262,16 @@ Future<MediaNavigationResult> navigateToMediaItemDetails(
|
|||||||
void Function(String)? onRefresh,
|
void Function(String)? onRefresh,
|
||||||
MediaItem? metadataOverride,
|
MediaItem? metadataOverride,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Catalog stand-ins (Explore tab) must never reach MediaDetailScreen — it
|
||||||
|
// hard-requires a server id. Guarded here (not only in navigateToMediaItem)
|
||||||
|
// so secondary entry points like card-title clicks are covered too.
|
||||||
|
if (mi.isCatalogItem) {
|
||||||
|
final catalogItem = mi.catalogItem;
|
||||||
|
if (catalogItem == null) return MediaNavigationResult.unsupported;
|
||||||
|
await navigateToCatalogItem(context, catalogItem);
|
||||||
|
return MediaNavigationResult.navigated;
|
||||||
|
}
|
||||||
|
|
||||||
final target = mediaDetailNavigationTargetFor(mi, metadataOverride: metadataOverride);
|
final target = mediaDetailNavigationTargetFor(mi, metadataOverride: metadataOverride);
|
||||||
final result = await Navigator.push<bool>(
|
final result = await Navigator.push<bool>(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
import '../focus/card_focus_scope.dart';
|
||||||
|
import '../media/media_server_client.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../theme/mono_tokens.dart';
|
||||||
|
import '../utils/grid_size_calculator.dart';
|
||||||
|
import '../utils/media_image_helper.dart';
|
||||||
|
import 'focus_builders.dart';
|
||||||
|
import 'horizontal_scroll_with_arrows.dart';
|
||||||
|
import 'optimized_media_image.dart';
|
||||||
|
|
||||||
|
/// One person in a [CastMemberStrip]: display name, secondary line
|
||||||
|
/// (character/role), and an image path — server-relative (resolved through
|
||||||
|
/// [CastMemberStrip.imageClient]) or an absolute URL.
|
||||||
|
typedef CastStripMember = ({String name, String? secondary, String? imagePath});
|
||||||
|
|
||||||
|
/// Horizontal cast/character strip shared by the media detail screen (server
|
||||||
|
/// items, actor navigation, dpad locked-focus) and the catalog detail screen
|
||||||
|
/// (provider items, display-only).
|
||||||
|
class CastMemberStrip extends StatelessWidget {
|
||||||
|
static const double _innerPadding = 3;
|
||||||
|
|
||||||
|
final List<CastStripMember> members;
|
||||||
|
|
||||||
|
/// Resolves server-relative image paths; null when [members] carry
|
||||||
|
/// absolute URLs.
|
||||||
|
final MediaServerClient? imageClient;
|
||||||
|
final ScrollController? controller;
|
||||||
|
|
||||||
|
/// Index highlighted by the owner's locked-focus dpad model; null when no
|
||||||
|
/// member is focused (or the owner has no focus model).
|
||||||
|
final int? focusedIndex;
|
||||||
|
final void Function(int index)? onMemberTap;
|
||||||
|
|
||||||
|
const CastMemberStrip({
|
||||||
|
super.key,
|
||||||
|
required this.members,
|
||||||
|
this.imageClient,
|
||||||
|
this.controller,
|
||||||
|
this.focusedIndex,
|
||||||
|
this.onMemberTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Card width matching the poster grids' cell width for the user's
|
||||||
|
/// density setting.
|
||||||
|
static double responsiveCardWidth(BuildContext context) {
|
||||||
|
final density = SettingsService.instance.read(SettingsService.libraryDensity);
|
||||||
|
final availableWidth = MediaQuery.sizeOf(context).width;
|
||||||
|
return GridSizeCalculator.getCellWidth(availableWidth, context, density);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strip's fixed height for a given card width:
|
||||||
|
/// image + inner padding + text area + list padding + focus scale headroom.
|
||||||
|
static double heightForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 58 + 10;
|
||||||
|
|
||||||
|
/// One item's horizontal extent (card + inner padding + trailing gap) for
|
||||||
|
/// owners doing their own ensure-visible scroll math (media detail dpad).
|
||||||
|
static double itemExtentForCardWidth(double cardWidth) => cardWidth + _innerPadding * 2 + 4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final nameStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600);
|
||||||
|
final secondaryStyle = theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant);
|
||||||
|
final cardWidth = responsiveCardWidth(context);
|
||||||
|
final imageSize = cardWidth;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: heightForCardWidth(cardWidth),
|
||||||
|
child: HorizontalScrollWithArrows(
|
||||||
|
controller: controller,
|
||||||
|
builder: (scrollController) => ListView.builder(
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addSemanticIndexes: false,
|
||||||
|
controller: scrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
itemCount: members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final member = members[index];
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: FocusBuilders.buildLockedFocusWrapper(
|
||||||
|
context: context,
|
||||||
|
isFocused: index == focusedIndex,
|
||||||
|
borderRadius: tokens(context).radiusSm,
|
||||||
|
onTap: onMemberTap == null ? null : () => onMemberTap!(index),
|
||||||
|
delegateFocusBorder: true,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(_innerPadding),
|
||||||
|
child: SizedBox(
|
||||||
|
width: cardWidth,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CardFocusBorder(
|
||||||
|
borderRadius: tokens(context).radiusSm,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||||
|
child: OptimizedMediaImage(
|
||||||
|
client: imageClient,
|
||||||
|
imagePath: member.imagePath,
|
||||||
|
width: imageSize,
|
||||||
|
height: imageSize,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
imageType: ImageType.avatar,
|
||||||
|
fallbackIcon: Symbols.person_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(member.name, style: nameStyle, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
if (member.secondary != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
member.secondary!,
|
||||||
|
style: secondaryStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
|
import '../utils/catalog_navigation_helper.dart';
|
||||||
|
import '../utils/snackbar_helper.dart';
|
||||||
|
import 'app_menu.dart';
|
||||||
|
|
||||||
|
enum _CatalogMenuAction { viewDetails, toggleWatchlist }
|
||||||
|
|
||||||
|
/// Watchlist mutations keyed by source+item so a re-opened menu can't
|
||||||
|
/// double-fire while one is still in flight (the detail screens keep their
|
||||||
|
/// own per-screen guards).
|
||||||
|
final Set<String> _watchlistMutationsInFlight = {};
|
||||||
|
|
||||||
|
/// Context menu for catalog stand-in cards (Explore tab). Replaces
|
||||||
|
/// [MediaContextMenu], whose entries are all server-backed and would break on
|
||||||
|
/// items with no server id.
|
||||||
|
Future<void> showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset? position}) async {
|
||||||
|
final source = Provider.of<CatalogSourcesProvider?>(context, listen: false)?.watchlistSourceFor(item);
|
||||||
|
final onWatchlist = source?.isOnWatchlist(item.kind, item.ids);
|
||||||
|
if (source != null && onWatchlist == null) {
|
||||||
|
// Load in the background so the row is actionable next open.
|
||||||
|
unawaited(source.ensureWatchlistLoaded());
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect anchorRect;
|
||||||
|
if (position != null) {
|
||||||
|
anchorRect = position & Size.zero;
|
||||||
|
} else {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox == null) return;
|
||||||
|
anchorRect = renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = await showAppMenu<_CatalogMenuAction>(
|
||||||
|
context,
|
||||||
|
anchorRect: anchorRect,
|
||||||
|
focusFirstItem: position == null,
|
||||||
|
entries: [
|
||||||
|
AppMenuItem(value: _CatalogMenuAction.viewDetails, label: t.mediaMenu.viewDetails, icon: Symbols.info_rounded),
|
||||||
|
if (onWatchlist != null)
|
||||||
|
AppMenuItem(
|
||||||
|
value: _CatalogMenuAction.toggleWatchlist,
|
||||||
|
label: onWatchlist ? t.explore.removeFromWatchlist : t.explore.addToWatchlist,
|
||||||
|
icon: onWatchlist ? Symbols.bookmark_remove_rounded : Symbols.bookmark_add_rounded,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (action == null || !context.mounted) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case _CatalogMenuAction.viewDetails:
|
||||||
|
await navigateToCatalogItem(context, item);
|
||||||
|
case _CatalogMenuAction.toggleWatchlist:
|
||||||
|
// Re-read membership: it can have changed while the menu was open
|
||||||
|
// (snapshot load, another surface's toggle).
|
||||||
|
final current = source!.isOnWatchlist(item.kind, item.ids) ?? onWatchlist ?? false;
|
||||||
|
final mutationKey = '${source.id.name}/${item.kind.id}/${item.ids.canonicalKey ?? item.title}';
|
||||||
|
if (!_watchlistMutationsInFlight.add(mutationKey)) return;
|
||||||
|
try {
|
||||||
|
if (current) {
|
||||||
|
await source.removeFromWatchlist(item.kind, item.ids);
|
||||||
|
} else {
|
||||||
|
await source.addToWatchlist(item.kind, item.ids);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
||||||
|
} finally {
|
||||||
|
_watchlistMutationsInFlight.remove(mutationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
|
|
||||||
@@ -7,15 +8,18 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../focus/card_focus_scope.dart';
|
import '../focus/card_focus_scope.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
|
import '../media/catalog_item_ref.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_item_types.dart';
|
import '../media/media_item_types.dart';
|
||||||
import '../media/media_kind.dart';
|
import '../media/media_kind.dart';
|
||||||
import '../media/media_playlist.dart';
|
import '../media/media_playlist.dart';
|
||||||
import '../mixins/context_menu_tap_mixin.dart';
|
import '../mixins/context_menu_tap_mixin.dart';
|
||||||
|
import '../models/catalog/catalog_item.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/watch_state_store.dart';
|
import '../providers/watch_state_store.dart';
|
||||||
import '../services/download_storage_service.dart';
|
import '../services/download_storage_service.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import 'catalog_context_menu.dart';
|
||||||
import 'settings_builder.dart';
|
import 'settings_builder.dart';
|
||||||
import 'watched_indicator.dart';
|
import 'watched_indicator.dart';
|
||||||
import '../utils/content_utils.dart';
|
import '../utils/content_utils.dart';
|
||||||
@@ -96,6 +100,34 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
|||||||
_handleTap(context, _effectiveItemForAction(context));
|
_handleTap(context, _effectiveItemForAction(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CatalogItem? get _catalogItem {
|
||||||
|
final item = widget.item;
|
||||||
|
return item is MediaItem && item.isCatalogItem ? item.catalogItem : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog stand-ins get the catalog menu at the same seams (long-press,
|
||||||
|
// right-click, TV context-menu key) instead of the server-backed
|
||||||
|
// MediaContextMenu, which is not in their tree.
|
||||||
|
@override
|
||||||
|
void showContextMenuFromTap() {
|
||||||
|
final catalogItem = _catalogItem;
|
||||||
|
if (catalogItem != null) {
|
||||||
|
unawaited(showCatalogItemMenu(context, catalogItem, position: lastTapPosition));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.showContextMenuFromTap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void showContextMenu() {
|
||||||
|
final catalogItem = _catalogItem;
|
||||||
|
if (catalogItem != null) {
|
||||||
|
unawaited(showCatalogItemMenu(context, catalogItem));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.showContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
Object _effectiveItem(BuildContext context) {
|
Object _effectiveItem(BuildContext context) {
|
||||||
final item = widget.item;
|
final item = widget.item;
|
||||||
return item is MediaItem ? context.withFreshWatchState(item) : item;
|
return item is MediaItem ? context.withFreshWatchState(item) : item;
|
||||||
@@ -261,6 +293,12 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
|||||||
episodePosterModeOverride: widget.episodePosterModeOverride,
|
episodePosterModeOverride: widget.episodePosterModeOverride,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Catalog stand-ins (Explore tab) have no server-backed actions — every
|
||||||
|
// entry in the context menu would break on serverId == null. Long-press
|
||||||
|
// no-ops on them; taps route through the catalog branch in
|
||||||
|
// navigateToMediaItem.
|
||||||
|
if (item is MediaItem && item.isCatalogItem) return cardWidget;
|
||||||
|
|
||||||
// MediaContextMenu as a non-widget helper — only wrap with its key for
|
// MediaContextMenu as a non-widget helper — only wrap with its key for
|
||||||
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
||||||
return MediaContextMenu(
|
return MediaContextMenu(
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../media/media_kind.dart';
|
||||||
|
import '../models/seerr/seerr_details.dart';
|
||||||
|
import '../models/seerr/seerr_media.dart';
|
||||||
|
import '../models/seerr/seerr_public_settings.dart';
|
||||||
|
import '../models/seerr/seerr_request.dart';
|
||||||
|
import '../models/seerr/seerr_service.dart';
|
||||||
|
import '../services/catalog/seerr_catalog_source.dart';
|
||||||
|
import '../services/seerr/seerr_constants.dart';
|
||||||
|
import '../services/seerr/seerr_exceptions.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/snackbar_helper.dart';
|
||||||
|
import 'app_icon.dart';
|
||||||
|
import 'app_menu.dart';
|
||||||
|
import 'loading_indicator_box.dart';
|
||||||
|
import 'overlay_sheet.dart';
|
||||||
|
import 'stat_chip.dart';
|
||||||
|
|
||||||
|
/// Open the Seerr request sheet for a title. Pops with a success snackbar
|
||||||
|
/// once the request is submitted.
|
||||||
|
Future<void> showSeerrRequestSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required SeerrCatalogSource source,
|
||||||
|
required MediaKind kind,
|
||||||
|
required int tmdbId,
|
||||||
|
required String title,
|
||||||
|
}) {
|
||||||
|
return OverlaySheetController.showAdaptive<void>(
|
||||||
|
context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => SeerrRequestSheet(source: source, kind: kind, tmdbId: tmdbId, title: title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full Seerr request flow, mirroring the web UI: per-season selection
|
||||||
|
/// with availability states, a 4K toggle, and the advanced destination
|
||||||
|
/// pickers — every section gated by the same instance settings and user
|
||||||
|
/// permissions Seerr itself checks.
|
||||||
|
class SeerrRequestSheet extends StatefulWidget {
|
||||||
|
final SeerrCatalogSource source;
|
||||||
|
final MediaKind kind;
|
||||||
|
final int tmdbId;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const SeerrRequestSheet({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
required this.kind,
|
||||||
|
required this.tmdbId,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SeerrRequestSheet> createState() => _SeerrRequestSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeerrRequestSheetState extends State<SeerrRequestSheet> {
|
||||||
|
bool _loading = true;
|
||||||
|
bool _loadFailed = false;
|
||||||
|
|
||||||
|
SeerrPublicSettings? _settings;
|
||||||
|
SeerrMediaInfo? _mediaInfo;
|
||||||
|
|
||||||
|
/// TV only: requestable seasons (specials and empty seasons dropped).
|
||||||
|
List<SeerrSeason> _seasons = const [];
|
||||||
|
final Set<int> _selectedSeasons = {};
|
||||||
|
|
||||||
|
bool _is4k = false;
|
||||||
|
|
||||||
|
/// Advanced options (REQUEST_ADVANCED): all configured instances of the
|
||||||
|
/// matching service; the pickers filter by the 4K toggle.
|
||||||
|
List<SeerrServiceInstance> _allServers = const [];
|
||||||
|
SeerrServiceInstance? _server;
|
||||||
|
SeerrServiceDetail? _serverDetail;
|
||||||
|
bool _serverDetailLoading = false;
|
||||||
|
int? _profileId;
|
||||||
|
String? _rootFolder;
|
||||||
|
int? _languageProfileId;
|
||||||
|
|
||||||
|
bool _submitting = false;
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
|
bool get _isMovie => widget.kind == MediaKind.movie;
|
||||||
|
|
||||||
|
int get _permissions => widget.source.client.session.permissions;
|
||||||
|
|
||||||
|
bool get _advancedAllowed => seerrHasPermission(_permissions, [SeerrPermission.requestAdvanced]);
|
||||||
|
|
||||||
|
bool get _can4k {
|
||||||
|
final settings = _settings;
|
||||||
|
if (settings == null) return false;
|
||||||
|
final enabled = _isMovie ? settings.movie4kEnabled : settings.series4kEnabled;
|
||||||
|
return enabled &&
|
||||||
|
seerrHasPermission(_permissions, [
|
||||||
|
SeerrPermission.request4k,
|
||||||
|
_isMovie ? SeerrPermission.request4kMovie : SeerrPermission.request4kTv,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _partialSeasons => _settings?.partialRequestsEnabled ?? true;
|
||||||
|
|
||||||
|
List<SeerrServiceInstance> get _serversForVariant => [..._allServers.where((s) => s.is4k == _is4k)];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_load());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_loadFailed = false;
|
||||||
|
});
|
||||||
|
final client = widget.source.client;
|
||||||
|
try {
|
||||||
|
final (settings, servers) = await (
|
||||||
|
client.getPublicSettings(),
|
||||||
|
_advancedAllowed
|
||||||
|
? (_isMovie ? client.getRadarrServices() : client.getSonarrServices())
|
||||||
|
: Future.value(const <SeerrServiceInstance>[]),
|
||||||
|
).wait;
|
||||||
|
|
||||||
|
SeerrMediaInfo? mediaInfo;
|
||||||
|
var seasons = const <SeerrSeason>[];
|
||||||
|
if (_isMovie) {
|
||||||
|
mediaInfo = (await client.getMovie(widget.tmdbId)).mediaInfo;
|
||||||
|
} else {
|
||||||
|
final tv = await client.getTv(widget.tmdbId);
|
||||||
|
mediaInfo = tv.mediaInfo;
|
||||||
|
seasons = [
|
||||||
|
for (final season in tv.seasons ?? const <SeerrSeason>[])
|
||||||
|
if (season.seasonNumber > 0 && (season.episodeCount ?? 0) > 0) season,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_settings = settings;
|
||||||
|
_mediaInfo = mediaInfo;
|
||||||
|
_seasons = seasons;
|
||||||
|
_allServers = servers;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
_selectDefaultServer();
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Seerr: request sheet load failed for tmdb ${widget.tmdbId}', error: e);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_loadFailed = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectDefaultServer() {
|
||||||
|
final candidates = _serversForVariant;
|
||||||
|
final next = candidates.firstWhereOrNull((s) => s.isDefault) ?? candidates.firstOrNull;
|
||||||
|
_adoptServer(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _adoptServer(SeerrServiceInstance? server) {
|
||||||
|
setState(() {
|
||||||
|
_server = server;
|
||||||
|
_serverDetail = null;
|
||||||
|
_profileId = server?.activeProfileId;
|
||||||
|
_rootFolder = server?.activeDirectory;
|
||||||
|
_languageProfileId = server?.activeLanguageProfileId;
|
||||||
|
});
|
||||||
|
if (server != null && _advancedAllowed) unawaited(_loadServerDetail(server));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadServerDetail(SeerrServiceInstance server) async {
|
||||||
|
setState(() => _serverDetailLoading = true);
|
||||||
|
final client = widget.source.client;
|
||||||
|
try {
|
||||||
|
final detail = _isMovie ? await client.getRadarrService(server.id) : await client.getSonarrService(server.id);
|
||||||
|
if (!mounted || _server?.id != server.id) return;
|
||||||
|
setState(() {
|
||||||
|
_serverDetail = detail;
|
||||||
|
_serverDetailLoading = false;
|
||||||
|
_profileId ??= detail.server?.activeProfileId;
|
||||||
|
_rootFolder ??= detail.server?.activeDirectory;
|
||||||
|
_languageProfileId ??= detail.server?.activeLanguageProfileId;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Advanced pickers degrade to server defaults; the request still works.
|
||||||
|
appLogger.w('Seerr: service detail load failed', error: e);
|
||||||
|
if (!mounted || _server?.id != server.id) return;
|
||||||
|
setState(() => _serverDetailLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle4k(bool value) {
|
||||||
|
setState(() {
|
||||||
|
_is4k = value;
|
||||||
|
_selectedSeasons.removeWhere(_seasonBlocked);
|
||||||
|
});
|
||||||
|
_selectDefaultServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Availability ----------
|
||||||
|
|
||||||
|
/// Non-declined requests matching the current 4K variant.
|
||||||
|
Iterable<SeerrRequest> get _activeRequests => (_mediaInfo?.requests ?? const <SeerrRequest>[]).where(
|
||||||
|
(r) => (r.is4k ?? false) == _is4k && r.status != SeerrRequestStatus.declined,
|
||||||
|
);
|
||||||
|
|
||||||
|
SeerrMediaStatus _variantStatus(SeerrMediaStatus status, SeerrMediaStatus status4k) => _is4k ? status4k : status;
|
||||||
|
|
||||||
|
/// Why this title/season can't be requested, or null when it can.
|
||||||
|
String? _blockedLabel(SeerrMediaStatus status, {required bool coveredByRequest}) {
|
||||||
|
return switch (status) {
|
||||||
|
SeerrMediaStatus.available => t.seerr.statusAvailable,
|
||||||
|
SeerrMediaStatus.partiallyAvailable => t.seerr.statusPartiallyAvailable,
|
||||||
|
SeerrMediaStatus.processing => t.seerr.statusProcessing,
|
||||||
|
SeerrMediaStatus.pending => t.seerr.statusRequested,
|
||||||
|
SeerrMediaStatus.unknown || SeerrMediaStatus.deleted when coveredByRequest => t.seerr.statusRequested,
|
||||||
|
SeerrMediaStatus.unknown || SeerrMediaStatus.deleted => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Movies block at the title level; shows block per season.
|
||||||
|
String? get _movieBlockedLabel {
|
||||||
|
final info = _mediaInfo;
|
||||||
|
if (info == null) return null;
|
||||||
|
return _blockedLabel(_variantStatus(info.status, info.status4k), coveredByRequest: _activeRequests.isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _seasonBlockedLabel(int seasonNumber) {
|
||||||
|
final info = _mediaInfo;
|
||||||
|
if (info == null) return null;
|
||||||
|
final season = info.seasons?.firstWhereOrNull((s) => s.seasonNumber == seasonNumber);
|
||||||
|
final covered = _activeRequests.any((r) => r.seasons?.any((s) => s.seasonNumber == seasonNumber) ?? false);
|
||||||
|
return _blockedLabel(
|
||||||
|
_variantStatus(season?.status ?? SeerrMediaStatus.unknown, season?.status4k ?? SeerrMediaStatus.unknown),
|
||||||
|
coveredByRequest: covered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _seasonBlocked(int seasonNumber) => _seasonBlockedLabel(seasonNumber) != null;
|
||||||
|
|
||||||
|
List<int> get _requestableSeasons => [
|
||||||
|
for (final season in _seasons)
|
||||||
|
if (!_seasonBlocked(season.seasonNumber)) season.seasonNumber,
|
||||||
|
];
|
||||||
|
|
||||||
|
bool get _nothingToRequest => _isMovie ? _movieBlockedLabel != null : _requestableSeasons.isEmpty;
|
||||||
|
|
||||||
|
bool get _canSubmit {
|
||||||
|
if (_submitting || _loading || _loadFailed || _nothingToRequest) return false;
|
||||||
|
if (!_isMovie && _partialSeasons && _selectedSeasons.isEmpty) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Submit ----------
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_canSubmit) return;
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
final advanced = _advancedAllowed && _server != null;
|
||||||
|
final payload = SeerrRequestPayload(
|
||||||
|
mediaType: _isMovie ? 'movie' : 'tv',
|
||||||
|
mediaId: widget.tmdbId,
|
||||||
|
seasons: _isMovie ? null : (_partialSeasons ? (_selectedSeasons.toList()..sort()) : null),
|
||||||
|
is4k: _is4k,
|
||||||
|
serverId: advanced ? _server?.id : null,
|
||||||
|
profileId: advanced ? _profileId : null,
|
||||||
|
rootFolder: advanced ? _rootFolder : null,
|
||||||
|
languageProfileId: advanced ? _languageProfileId : null,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await widget.source.client.createRequest(payload);
|
||||||
|
if (!mounted) return;
|
||||||
|
// The sheet may be hosted by an OverlaySheetHost (no route of its own),
|
||||||
|
// so a bare Navigator.pop would pop the screen underneath instead.
|
||||||
|
OverlaySheetController.closeAdaptive(context);
|
||||||
|
showSuccessSnackBar(context, t.seerr.requestSubmitted);
|
||||||
|
} on SeerrApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
_errorText = e.message;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.w('Seerr: request submit failed', error: e);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
_errorText = t.seerr.requestFailed(error: '$e');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI ----------
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(t.seerr.request, style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_loading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 32),
|
||||||
|
child: Center(child: LoadingIndicatorBox()),
|
||||||
|
)
|
||||||
|
else if (_loadFailed)
|
||||||
|
_buildLoadError(theme)
|
||||||
|
else ...[
|
||||||
|
if (_nothingToRequest)
|
||||||
|
_buildNothingToRequest(theme)
|
||||||
|
else ...[
|
||||||
|
if (!_isMovie && _partialSeasons) ..._buildSeasonSection(theme),
|
||||||
|
if (_can4k)
|
||||||
|
SwitchListTile(
|
||||||
|
value: _is4k,
|
||||||
|
onChanged: _submitting ? null : _toggle4k,
|
||||||
|
title: Text(t.seerr.request4k),
|
||||||
|
secondary: const AppIcon(Symbols.four_k, fill: 1),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_advancedAllowed && _serversForVariant.isNotEmpty) ..._buildAdvancedSection(theme),
|
||||||
|
if (_errorText case final String error) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(error, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _canSubmit ? _submit : null,
|
||||||
|
icon: _submitting ? const LoadingIndicatorBox() : const AppIcon(Symbols.download_rounded, fill: 1),
|
||||||
|
label: Text(t.seerr.request),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadError(ThemeData theme) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(t.seerr.requestsLoadFailed, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton(onPressed: () => unawaited(_load()), child: Text(t.common.retry)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildNothingToRequest(ThemeData theme) {
|
||||||
|
final label = _isMovie ? _movieBlockedLabel : null;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AppIcon(Symbols.check_circle_rounded, fill: 1, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(label ?? t.seerr.nothingToRequest, style: theme.textTheme.bodyMedium)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildSeasonSection(ThemeData theme) {
|
||||||
|
final requestable = _requestableSeasons;
|
||||||
|
final allSelected = requestable.isNotEmpty && requestable.every(_selectedSeasons.contains);
|
||||||
|
return [
|
||||||
|
Text(t.seerr.seasons, style: theme.textTheme.titleSmall),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: allSelected,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (checked) => setState(() {
|
||||||
|
if (checked ?? false) {
|
||||||
|
_selectedSeasons.addAll(requestable);
|
||||||
|
} else {
|
||||||
|
_selectedSeasons.clear();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
title: Text(t.seerr.allSeasons),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
for (final season in _seasons) _buildSeasonTile(theme, season),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSeasonTile(ThemeData theme, SeerrSeason season) {
|
||||||
|
final number = season.seasonNumber;
|
||||||
|
final blockedLabel = _seasonBlockedLabel(number);
|
||||||
|
final episodeCount = season.episodeCount;
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: blockedLabel != null || _selectedSeasons.contains(number),
|
||||||
|
onChanged: blockedLabel != null || _submitting
|
||||||
|
? null
|
||||||
|
: (checked) => setState(() {
|
||||||
|
if (checked ?? false) {
|
||||||
|
_selectedSeasons.add(number);
|
||||||
|
} else {
|
||||||
|
_selectedSeasons.remove(number);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
title: Text(season.name ?? t.common.seasonNumber(number: number)),
|
||||||
|
subtitle: episodeCount == null ? null : Text(t.explore.episodeCount(n: episodeCount)),
|
||||||
|
secondary: blockedLabel == null ? null : StatChip(label: blockedLabel),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAdvancedSection(ThemeData theme) {
|
||||||
|
final servers = _serversForVariant;
|
||||||
|
final detail = _serverDetail;
|
||||||
|
final profiles = detail?.profiles ?? const <SeerrServiceProfile>[];
|
||||||
|
final folders = detail?.rootFolders ?? const <SeerrRootFolder>[];
|
||||||
|
final languages = detail?.languageProfiles ?? const <SeerrServiceProfile>[];
|
||||||
|
return [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(t.seerr.advancedOptions, style: theme.textTheme.titleSmall),
|
||||||
|
if (_serverDetailLoading) ...[const SizedBox(width: 10), const LoadingIndicatorBox(size: 14)],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (servers.length > 1)
|
||||||
|
_PickerTile<SeerrServiceInstance>(
|
||||||
|
icon: Symbols.dns_rounded,
|
||||||
|
label: t.seerr.destinationServer,
|
||||||
|
value: _server?.name ?? '',
|
||||||
|
options: servers,
|
||||||
|
describe: (s) => s.name ?? '#${s.id}',
|
||||||
|
isSelected: (s) => s.id == _server?.id,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onSelected: _adoptServer,
|
||||||
|
),
|
||||||
|
if (profiles.isNotEmpty)
|
||||||
|
_PickerTile<SeerrServiceProfile>(
|
||||||
|
icon: Symbols.high_quality_rounded,
|
||||||
|
label: t.seerr.qualityProfile,
|
||||||
|
value: profiles.firstWhereOrNull((p) => p.id == _profileId)?.name ?? '',
|
||||||
|
options: profiles,
|
||||||
|
describe: (p) => p.name ?? '#${p.id}',
|
||||||
|
isSelected: (p) => p.id == _profileId,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onSelected: (p) => setState(() => _profileId = p.id),
|
||||||
|
),
|
||||||
|
if (folders.isNotEmpty)
|
||||||
|
_PickerTile<SeerrRootFolder>(
|
||||||
|
icon: Symbols.folder_rounded,
|
||||||
|
label: t.seerr.rootFolder,
|
||||||
|
value: _rootFolder ?? '',
|
||||||
|
options: folders,
|
||||||
|
describe: (f) => f.path ?? '#${f.id}',
|
||||||
|
isSelected: (f) => f.path == _rootFolder,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onSelected: (f) => setState(() => _rootFolder = f.path),
|
||||||
|
),
|
||||||
|
if (languages.isNotEmpty)
|
||||||
|
_PickerTile<SeerrServiceProfile>(
|
||||||
|
icon: Symbols.language_rounded,
|
||||||
|
label: t.seerr.languageProfile,
|
||||||
|
value: languages.firstWhereOrNull((p) => p.id == _languageProfileId)?.name ?? '',
|
||||||
|
options: languages,
|
||||||
|
describe: (p) => p.name ?? '#${p.id}',
|
||||||
|
isSelected: (p) => p.id == _languageProfileId,
|
||||||
|
enabled: !_submitting,
|
||||||
|
onSelected: (p) => setState(() => _languageProfileId = p.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A "current value" row that opens an [showAppMenu] of options, anchored to
|
||||||
|
/// itself — the same dpad-safe pattern as the watchlist source chooser.
|
||||||
|
class _PickerTile<T> extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final List<T> options;
|
||||||
|
final String Function(T) describe;
|
||||||
|
final bool Function(T) isSelected;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<T> onSelected;
|
||||||
|
|
||||||
|
const _PickerTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.options,
|
||||||
|
required this.describe,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _open(BuildContext context) async {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null) return;
|
||||||
|
final anchorRect = box.localToGlobal(Offset.zero) & box.size;
|
||||||
|
final picked = await showAppMenu<T>(
|
||||||
|
context,
|
||||||
|
anchorRect: anchorRect,
|
||||||
|
focusFirstItem: true,
|
||||||
|
entries: [
|
||||||
|
for (final option in options)
|
||||||
|
AppMenuItem<T>(value: option, label: describe(option), selected: isSelected(option)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (picked != null) onSelected(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: AppIcon(icon, fill: 1),
|
||||||
|
title: Text(label),
|
||||||
|
subtitle: value.isEmpty ? null : Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: const AppIcon(Symbols.unfold_more_rounded, fill: 1),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
enabled: enabled,
|
||||||
|
onTap: () => unawaited(_open(context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import '../media/media_item.dart';
|
|||||||
import '../media/media_library.dart';
|
import '../media/media_library.dart';
|
||||||
import '../mixins/mounted_set_state_mixin.dart';
|
import '../mixins/mounted_set_state_mixin.dart';
|
||||||
import '../navigation/navigation_tabs.dart';
|
import '../navigation/navigation_tabs.dart';
|
||||||
|
import '../providers/catalog_sources_provider.dart';
|
||||||
import '../providers/hidden_libraries_provider.dart';
|
import '../providers/hidden_libraries_provider.dart';
|
||||||
import '../providers/libraries_provider.dart';
|
import '../providers/libraries_provider.dart';
|
||||||
import '../services/music/music_playback_service.dart';
|
import '../services/music/music_playback_service.dart';
|
||||||
@@ -241,6 +242,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
}
|
}
|
||||||
|
|
||||||
static const _kHome = 'home';
|
static const _kHome = 'home';
|
||||||
|
static const _kExplore = 'explore';
|
||||||
static const _kNowPlaying = 'nowPlaying';
|
static const _kNowPlaying = 'nowPlaying';
|
||||||
static const _kLibraries = 'libraries';
|
static const _kLibraries = 'libraries';
|
||||||
static const _kSearch = 'search';
|
static const _kSearch = 'search';
|
||||||
@@ -382,6 +384,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
switch (widget.selectedTab) {
|
switch (widget.selectedTab) {
|
||||||
case NavigationTabId.discover:
|
case NavigationTabId.discover:
|
||||||
return _kHome;
|
return _kHome;
|
||||||
|
case NavigationTabId.explore:
|
||||||
|
return _kExplore;
|
||||||
case NavigationTabId.libraries:
|
case NavigationTabId.libraries:
|
||||||
final libKey = widget.selectedLibraryKey;
|
final libKey = widget.selectedLibraryKey;
|
||||||
if (libKey != null && _librariesExpanded) {
|
if (libKey != null && _librariesExpanded) {
|
||||||
@@ -432,11 +436,13 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
required bool hasHiddenLibraries,
|
required bool hasHiddenLibraries,
|
||||||
required bool hasLiveTv,
|
required bool hasLiveTv,
|
||||||
required bool hasNowPlaying,
|
required bool hasNowPlaying,
|
||||||
|
required bool hasExplore,
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
_kHome,
|
_kHome,
|
||||||
if (hasNowPlaying) _kNowPlaying,
|
if (hasNowPlaying) _kNowPlaying,
|
||||||
_kLibraries,
|
_kLibraries,
|
||||||
|
if (hasExplore) _kExplore,
|
||||||
_kSearch,
|
_kSearch,
|
||||||
if (_showDownloads) _kDownloads,
|
if (_showDownloads) _kDownloads,
|
||||||
_kSettings,
|
_kSettings,
|
||||||
@@ -511,6 +517,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
required bool hasHiddenLibraries,
|
required bool hasHiddenLibraries,
|
||||||
required bool hasLiveTv,
|
required bool hasLiveTv,
|
||||||
required bool hasNowPlaying,
|
required bool hasNowPlaying,
|
||||||
|
required bool hasExplore,
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
|
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
|
||||||
@@ -526,6 +533,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
if (hasLiveTv) 'liveTv',
|
if (hasLiveTv) 'liveTv',
|
||||||
|
if (hasExplore) _kExplore,
|
||||||
_kSearch,
|
_kSearch,
|
||||||
],
|
],
|
||||||
if (_showDownloads) _kDownloads,
|
if (_showDownloads) _kDownloads,
|
||||||
@@ -643,6 +651,9 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
|
||||||
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
|
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
|
||||||
// Nullable watch: rail tests (and any host without the profile session
|
// Nullable watch: rail tests (and any host without the profile session
|
||||||
|
// scope) simply never show the Explore item.
|
||||||
|
final hasExplore = context.watch<CatalogSourcesProvider?>()?.hasAnySource ?? false;
|
||||||
|
// Nullable watch: rail tests (and any host without the profile session
|
||||||
// scope) simply never show the Now Playing item. TV-only — it is the
|
// scope) simply never show the Now Playing item. TV-only — it is the
|
||||||
// way back into the now-playing screen there; desktop already has the
|
// way back into the now-playing screen there; desktop already has the
|
||||||
// mini-player for that.
|
// mini-player for that.
|
||||||
@@ -680,6 +691,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
||||||
hasLiveTv: hasLiveTv,
|
hasLiveTv: hasLiveTv,
|
||||||
hasNowPlaying: nowPlayingTrack != null,
|
hasNowPlaying: nowPlayingTrack != null,
|
||||||
|
hasExplore: hasExplore,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final focusOrder = _buildFocusOrder(
|
final focusOrder = _buildFocusOrder(
|
||||||
@@ -688,6 +700,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
hasHiddenLibraries: hiddenLibraries.isNotEmpty,
|
||||||
hasLiveTv: hasLiveTv,
|
hasLiveTv: hasLiveTv,
|
||||||
hasNowPlaying: nowPlayingTrack != null,
|
hasNowPlaying: nowPlayingTrack != null,
|
||||||
|
hasExplore: hasExplore,
|
||||||
);
|
);
|
||||||
_debugAssertUniqueFocusOrder(focusOrder);
|
_debugAssertUniqueFocusOrder(focusOrder);
|
||||||
return TapRegion(
|
return TapRegion(
|
||||||
@@ -777,6 +790,19 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
if (hasExplore) ...[
|
||||||
|
_buildNavItem(
|
||||||
|
icon: Symbols.explore_rounded,
|
||||||
|
selectedIcon: Symbols.explore_rounded,
|
||||||
|
label: Translations.of(context).navigation.explore,
|
||||||
|
isSelected: widget.selectedTab == NavigationTabId.explore,
|
||||||
|
isFocused: _focusTracker.isFocused(_kExplore),
|
||||||
|
onTap: () => widget.onDestinationSelected(NavigationTabId.explore),
|
||||||
|
focusNode: _focusTracker.get(_kExplore),
|
||||||
|
isCollapsed: isCollapsed,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
_buildNavItem(
|
_buildNavItem(
|
||||||
icon: Symbols.search_rounded,
|
icon: Symbols.search_rounded,
|
||||||
selectedIcon: Symbols.search_rounded,
|
selectedIcon: Symbols.search_rounded,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_icon.dart';
|
||||||
|
|
||||||
|
/// Small labeled pill (optionally with a leading icon): detail-screen stat
|
||||||
|
/// chips, request-sheet season status labels.
|
||||||
|
class StatChip extends StatelessWidget {
|
||||||
|
final IconData? icon;
|
||||||
|
final Color? iconColor;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const StatChip({super.key, this.icon, this.iconColor, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[AppIcon(icon!, size: 14, fill: 1, color: iconColor), const SizedBox(width: 4)],
|
||||||
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,5 +41,40 @@ void main() {
|
|||||||
NavigationTabId.discover,
|
NavigationTabId.discover,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('online falls back to Home when preferred Explore is unavailable', () {
|
||||||
|
expect(
|
||||||
|
NavigationTab.resolveDefaultTab(isOffline: false, hasLiveTv: false, preferredStartup: NavigationTabId.explore),
|
||||||
|
NavigationTabId.discover,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
NavigationTab.resolveDefaultTab(
|
||||||
|
isOffline: false,
|
||||||
|
hasLiveTv: false,
|
||||||
|
hasExplore: true,
|
||||||
|
preferredStartup: NavigationTabId.explore,
|
||||||
|
),
|
||||||
|
NavigationTabId.explore,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('NavigationTab.getVisibleTabs', () {
|
||||||
|
test('hides Explore until a catalog source is connected', () {
|
||||||
|
final without = NavigationTab.getVisibleTabs(isOffline: false);
|
||||||
|
expect(without.map((tab) => tab.id), isNot(contains(NavigationTabId.explore)));
|
||||||
|
|
||||||
|
final with_ = NavigationTab.getVisibleTabs(isOffline: false, hasExplore: true, hasLiveTv: true);
|
||||||
|
final ids = with_.map((tab) => tab.id).toList();
|
||||||
|
expect(ids, contains(NavigationTabId.explore));
|
||||||
|
// Explore sits after Live TV, directly before Search.
|
||||||
|
expect(ids.indexOf(NavigationTabId.explore), ids.indexOf(NavigationTabId.liveTv) + 1);
|
||||||
|
expect(ids.indexOf(NavigationTabId.explore), ids.indexOf(NavigationTabId.search) - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Explore is online-only', () {
|
||||||
|
final offline = NavigationTab.getVisibleTabs(isOffline: true, hasExplore: true);
|
||||||
|
expect(offline.map((tab) => tab.id), isNot(contains(NavigationTabId.explore)));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/models/catalog/catalog_cast_member.dart';
|
||||||
|
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||||
|
import 'package:plezy/providers/catalog_sources_provider.dart';
|
||||||
|
import 'package:plezy/providers/explore_provider.dart';
|
||||||
|
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||||
|
import 'package:plezy/utils/external_ids.dart';
|
||||||
|
|
||||||
|
/// Minimal controllable source: rows resolve immediately unless [gate] is
|
||||||
|
/// set, in which case fetches park on completers the test releases.
|
||||||
|
class _FakeSource implements CatalogSource {
|
||||||
|
_FakeSource(this.id, {this.rows = const [CatalogRowId.watchlist, CatalogRowId.trendingMovies]});
|
||||||
|
|
||||||
|
@override
|
||||||
|
final CatalogSourceId id;
|
||||||
|
final List<CatalogRowId> rows;
|
||||||
|
final watchlist = WatchlistChangeNotifier();
|
||||||
|
|
||||||
|
bool gate = false;
|
||||||
|
final pending = <Completer<CatalogPage>>[];
|
||||||
|
final fetches = <CatalogRowId, int>{};
|
||||||
|
|
||||||
|
CatalogPage _page(CatalogRowId row) => CatalogPage(
|
||||||
|
items: [
|
||||||
|
CatalogItem(
|
||||||
|
source: id,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
title: '${id.name}:${row.name}',
|
||||||
|
ids: const CatalogItemIds(tmdb: 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CatalogPage> fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) {
|
||||||
|
fetches[row] = (fetches[row] ?? 0) + 1;
|
||||||
|
if (gate) {
|
||||||
|
final completer = Completer<CatalogPage>();
|
||||||
|
pending.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
return Future.value(_page(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
void releaseAll() {
|
||||||
|
for (final completer in pending) {
|
||||||
|
completer.complete(const CatalogPage(items: []));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get displayName => id.name;
|
||||||
|
@override
|
||||||
|
List<CatalogRowId> get supportedRows => rows;
|
||||||
|
@override
|
||||||
|
bool get supportsWatchlist => true;
|
||||||
|
@override
|
||||||
|
Listenable get watchlistChanges => watchlist;
|
||||||
|
@override
|
||||||
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async => const [];
|
||||||
|
@override
|
||||||
|
Future<List<CatalogCastMember>> fetchCast(CatalogItem item, {int limit = 20}) async => const [];
|
||||||
|
@override
|
||||||
|
Future<List<CatalogItem>> fetchRelated(CatalogItem item, {int limit = 20}) async => const [];
|
||||||
|
@override
|
||||||
|
Future<void> ensureWatchlistLoaded() async {}
|
||||||
|
@override
|
||||||
|
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null;
|
||||||
|
@override
|
||||||
|
Future<CatalogItemIds?> resolveItemIds(MediaKind kind, ExternalIds external) async => null;
|
||||||
|
@override
|
||||||
|
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) async {}
|
||||||
|
@override
|
||||||
|
Future<void> removeFromWatchlist(MediaKind kind, CatalogItemIds ids) async {}
|
||||||
|
@override
|
||||||
|
void dispose() => watchlist.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives [activeSource] directly; the real provider derives it from the
|
||||||
|
/// account providers, which is irrelevant to ExploreProvider's contract.
|
||||||
|
class _FakeSourcesProvider extends CatalogSourcesProvider {
|
||||||
|
CatalogSource? _current;
|
||||||
|
|
||||||
|
void setActive(CatalogSource? source) {
|
||||||
|
_current = source;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CatalogSource? get activeSource => _current;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpMicrotasks() async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ExploreProvider', () {
|
||||||
|
late _FakeSourcesProvider sources;
|
||||||
|
late ExploreProvider explore;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
sources = _FakeSourcesProvider();
|
||||||
|
explore = ExploreProvider(sources);
|
||||||
|
addTearDown(() {
|
||||||
|
explore.dispose();
|
||||||
|
sources.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('source switch during an in-flight load starts the new load instead of coalescing', () async {
|
||||||
|
final slow = _FakeSource(CatalogSourceId.trakt)..gate = true;
|
||||||
|
final fast = _FakeSource(CatalogSourceId.mal, rows: const [CatalogRowId.popularAnime]);
|
||||||
|
addTearDown(() {
|
||||||
|
slow.dispose();
|
||||||
|
fast.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
sources.setActive(slow);
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
expect(explore.isLoading, isTrue);
|
||||||
|
expect(slow.pending, isNotEmpty);
|
||||||
|
|
||||||
|
// Switch while the old source's rows are still parked.
|
||||||
|
sources.setActive(fast);
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
|
||||||
|
expect(explore.state, ExploreLoadState.loaded);
|
||||||
|
expect(explore.rowHubs.single.row, CatalogRowId.popularAnime);
|
||||||
|
expect(explore.rowHubs.single.hub.items.single.title, 'mal:popularAnime');
|
||||||
|
|
||||||
|
// The stale pass completing must not clobber the new source's state.
|
||||||
|
slow.releaseAll();
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
expect(explore.state, ExploreLoadState.loaded);
|
||||||
|
expect(explore.rowHubs.single.row, CatalogRowId.popularAnime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mutation during the initial load is caught up by ensureFresh', () async {
|
||||||
|
final source = _FakeSource(CatalogSourceId.trakt)..gate = true;
|
||||||
|
addTearDown(source.dispose);
|
||||||
|
|
||||||
|
sources.setActive(source);
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
expect(source.pending, hasLength(2));
|
||||||
|
|
||||||
|
// A watchlist mutation lands while the full load is still in flight:
|
||||||
|
// the pages about to land were fetched pre-mutation.
|
||||||
|
source.watchlist.notify();
|
||||||
|
source.gate = false;
|
||||||
|
source.releaseAll();
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
expect(explore.state, ExploreLoadState.loaded);
|
||||||
|
|
||||||
|
final refetchesBefore = source.fetches[CatalogRowId.watchlist] ?? 0;
|
||||||
|
explore.ensureFresh();
|
||||||
|
await _pumpMicrotasks();
|
||||||
|
expect(source.fetches[CatalogRowId.watchlist], refetchesBefore + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/models/catalog/catalog_item.dart';
|
||||||
|
import 'package:plezy/screens/catalog_search_screen.dart';
|
||||||
|
import 'package:plezy/services/catalog/catalog_source.dart';
|
||||||
|
import 'package:plezy/services/settings_service.dart';
|
||||||
|
import 'package:plezy/theme/mono_theme.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
/// Only the members the search screen touches; everything else throws.
|
||||||
|
class _FakeSearchSource implements CatalogSource {
|
||||||
|
final queries = <String>[];
|
||||||
|
bool failNext = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
CatalogSourceId get id => CatalogSourceId.trakt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get displayName => 'Trakt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CatalogItem>> search(String query, {int limit = 30}) async {
|
||||||
|
queries.add(query);
|
||||||
|
if (failNext) {
|
||||||
|
failNext = false;
|
||||||
|
throw Exception('boom');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
CatalogItem(source: id, kind: MediaKind.movie, title: 'result: $query', ids: const CatalogItemIds(tmdb: 1)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, _FakeSearchSource source) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: monoTheme(dark: true),
|
||||||
|
home: CatalogSearchScreen(source: source),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
resetSharedPreferencesForTest();
|
||||||
|
SettingsService.resetForTesting();
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reverting to the last-searched query cancels the pending debounce', (tester) async {
|
||||||
|
final source = _FakeSearchSource();
|
||||||
|
await _pump(tester, source);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'abc');
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(source.queries, ['abc']);
|
||||||
|
expect(_state(tester).searchResults.single.title, 'result: abc');
|
||||||
|
|
||||||
|
// Type ahead, then revert to the shown query before the debounce fires:
|
||||||
|
// the armed 'abcd' search must never run.
|
||||||
|
await tester.enterText(find.byType(TextField), 'abcd');
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.enterText(find.byType(TextField), 'abc');
|
||||||
|
await tester.pump(const Duration(milliseconds: 700));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(source.queries, ['abc']);
|
||||||
|
expect(_state(tester).searchResults.single.title, 'result: abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('failed search shows the failure state and recovers on retry', (tester) async {
|
||||||
|
final source = _FakeSearchSource()..failNext = true;
|
||||||
|
await _pump(tester, source);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'abc');
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text(t.explore.searchFailed), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'abcd');
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(_state(tester).searchResults.single.title, 'result: abcd');
|
||||||
|
expect(find.text(t.explore.searchFailed), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _state(WidgetTester tester) => tester.state<State<CatalogSearchScreen>>(find.byType(CatalogSearchScreen));
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/models/seerr/seerr_session.dart';
|
||||||
|
import 'package:plezy/services/catalog/seerr_catalog_source.dart';
|
||||||
|
import 'package:plezy/services/seerr/seerr_client.dart';
|
||||||
|
import 'package:plezy/services/seerr/seerr_constants.dart';
|
||||||
|
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||||
|
import 'package:plezy/widgets/seerr_request_sheet.dart';
|
||||||
|
|
||||||
|
http.Response _json(Object body, {int status = 200}) =>
|
||||||
|
http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json'});
|
||||||
|
|
||||||
|
SeerrCatalogSource _source(MockClient mock, {int permissions = SeerrPermission.request}) {
|
||||||
|
final client = SeerrClient(
|
||||||
|
SeerrSession(
|
||||||
|
baseUrl: 'https://seerr.example.com',
|
||||||
|
method: SeerrAuthMethod.local,
|
||||||
|
identifier: 'a@b.c',
|
||||||
|
secret: 'pw',
|
||||||
|
cookie: 'cookie',
|
||||||
|
userId: 1,
|
||||||
|
permissions: permissions,
|
||||||
|
displayName: 'Alice',
|
||||||
|
instanceLabel: 'Seerr',
|
||||||
|
createdAt: 0,
|
||||||
|
),
|
||||||
|
onSessionInvalidated: () {},
|
||||||
|
httpClient: mock,
|
||||||
|
);
|
||||||
|
final source = SeerrCatalogSource(client);
|
||||||
|
addTearDown(() {
|
||||||
|
source.dispose();
|
||||||
|
client.dispose();
|
||||||
|
});
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _publicSettings() => {
|
||||||
|
'initialized': true,
|
||||||
|
'localLogin': true,
|
||||||
|
'mediaServerLogin': true,
|
||||||
|
'movie4kEnabled': false,
|
||||||
|
'series4kEnabled': false,
|
||||||
|
'partialRequestsEnabled': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Mirrors production: the sheet is opened via [showSeerrRequestSheet] on a
|
||||||
|
/// pushed route that hosts its own [OverlaySheetHost] (like
|
||||||
|
/// CatalogItemDetailScreen), so the sheet renders in the host's stack rather
|
||||||
|
/// than as a route of its own.
|
||||||
|
Future<void> _pumpSheet(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required SeerrCatalogSource source,
|
||||||
|
required MediaKind kind,
|
||||||
|
required int tmdbId,
|
||||||
|
required String title,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => OverlaySheetHost(
|
||||||
|
canPop: true,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => showSeerrRequestSheet(
|
||||||
|
context,
|
||||||
|
source: source,
|
||||||
|
kind: kind,
|
||||||
|
tmdbId: tmdbId,
|
||||||
|
title: title,
|
||||||
|
),
|
||||||
|
child: const Text('request'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('request'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('TV: disables unavailable seasons, drops specials, posts selected seasons', (tester) async {
|
||||||
|
Map<String, dynamic>? postedBody;
|
||||||
|
final mock = MockClient((request) async {
|
||||||
|
switch (request.url.path) {
|
||||||
|
case '/api/v1/settings/public':
|
||||||
|
return _json(_publicSettings());
|
||||||
|
case '/api/v1/tv/1396':
|
||||||
|
return _json({
|
||||||
|
'id': 1396,
|
||||||
|
'name': 'Breaking Bad',
|
||||||
|
'seasons': [
|
||||||
|
{'seasonNumber': 0, 'episodeCount': 5, 'name': 'Specials'},
|
||||||
|
{'seasonNumber': 1, 'episodeCount': 7, 'name': 'Season 1'},
|
||||||
|
{'seasonNumber': 2, 'episodeCount': 13, 'name': 'Season 2'},
|
||||||
|
],
|
||||||
|
'mediaInfo': {
|
||||||
|
'status': 4,
|
||||||
|
'status4k': 1,
|
||||||
|
'seasons': [
|
||||||
|
{'seasonNumber': 1, 'status': 5, 'status4k': 1},
|
||||||
|
],
|
||||||
|
'requests': [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
case '/api/v1/request':
|
||||||
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
return _json({'id': 10, 'status': 1}, status: 201);
|
||||||
|
}
|
||||||
|
fail('unexpected request ${request.url.path}');
|
||||||
|
});
|
||||||
|
final source = _source(mock);
|
||||||
|
|
||||||
|
await _pumpSheet(tester, source: source, kind: MediaKind.show, tmdbId: 1396, title: 'Breaking Bad');
|
||||||
|
|
||||||
|
expect(find.text('Specials'), findsNothing);
|
||||||
|
expect(find.text('Season 1'), findsOneWidget);
|
||||||
|
expect(find.text('Season 2'), findsOneWidget);
|
||||||
|
// Season 1 is available on the server: checked, disabled, labeled.
|
||||||
|
expect(find.text('Available'), findsOneWidget);
|
||||||
|
final season1 = tester.widget<CheckboxListTile>(
|
||||||
|
find.ancestor(of: find.text('Season 1'), matching: find.byType(CheckboxListTile)),
|
||||||
|
);
|
||||||
|
expect(season1.onChanged, isNull);
|
||||||
|
expect(season1.value, isTrue);
|
||||||
|
|
||||||
|
// Nothing selected yet: submit disabled.
|
||||||
|
final submitFinder = find.widgetWithText(FilledButton, 'Request');
|
||||||
|
expect(tester.widget<FilledButton>(submitFinder).onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Season 2'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.widget<FilledButton>(submitFinder).onPressed, isNotNull);
|
||||||
|
|
||||||
|
await tester.tap(submitFinder);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(postedBody, {
|
||||||
|
'mediaType': 'tv',
|
||||||
|
'mediaId': 1396,
|
||||||
|
'seasons': [2],
|
||||||
|
'is4k': false,
|
||||||
|
});
|
||||||
|
// The sheet closed but the hosting screen must survive the submit —
|
||||||
|
// a bare Navigator.pop here would pop the whole detail route.
|
||||||
|
expect(find.text('Season 2'), findsNothing);
|
||||||
|
expect(find.text('request'), findsOneWidget);
|
||||||
|
expect(find.text('Request submitted'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('movie that is already available offers nothing to request', (tester) async {
|
||||||
|
final mock = MockClient((request) async {
|
||||||
|
switch (request.url.path) {
|
||||||
|
case '/api/v1/settings/public':
|
||||||
|
return _json(_publicSettings());
|
||||||
|
case '/api/v1/movie/603':
|
||||||
|
return _json({
|
||||||
|
'id': 603,
|
||||||
|
'title': 'The Matrix',
|
||||||
|
'mediaInfo': {'status': 5, 'status4k': 1},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fail('unexpected request ${request.url.path}');
|
||||||
|
});
|
||||||
|
final source = _source(mock);
|
||||||
|
|
||||||
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 603, title: 'The Matrix');
|
||||||
|
|
||||||
|
expect(find.text('Available'), findsOneWidget);
|
||||||
|
expect(find.byType(FilledButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('advanced permission loads servers and sends destination overrides', (tester) async {
|
||||||
|
Map<String, dynamic>? postedBody;
|
||||||
|
final mock = MockClient((request) async {
|
||||||
|
switch (request.url.path) {
|
||||||
|
case '/api/v1/settings/public':
|
||||||
|
return _json(_publicSettings());
|
||||||
|
case '/api/v1/movie/550':
|
||||||
|
return _json({'id': 550, 'title': 'Fight Club'});
|
||||||
|
case '/api/v1/service/radarr':
|
||||||
|
return _json([
|
||||||
|
{
|
||||||
|
'id': 0,
|
||||||
|
'name': 'Radarr Main',
|
||||||
|
'is4k': false,
|
||||||
|
'isDefault': true,
|
||||||
|
'activeProfileId': 6,
|
||||||
|
'activeDirectory': '/movies',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
case '/api/v1/service/radarr/0':
|
||||||
|
return _json({
|
||||||
|
'server': {
|
||||||
|
'id': 0,
|
||||||
|
'name': 'Radarr Main',
|
||||||
|
'is4k': false,
|
||||||
|
'isDefault': true,
|
||||||
|
'activeProfileId': 6,
|
||||||
|
'activeDirectory': '/movies',
|
||||||
|
},
|
||||||
|
'profiles': [
|
||||||
|
{'id': 6, 'name': '1080p'},
|
||||||
|
{'id': 7, 'name': '4K Remux'},
|
||||||
|
],
|
||||||
|
'rootFolders': [
|
||||||
|
{'id': 1, 'path': '/movies'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case '/api/v1/request':
|
||||||
|
postedBody = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
return _json({'id': 11, 'status': 2}, status: 201);
|
||||||
|
}
|
||||||
|
fail('unexpected request ${request.url.path}');
|
||||||
|
});
|
||||||
|
final source = _source(mock, permissions: SeerrPermission.admin);
|
||||||
|
|
||||||
|
await _pumpSheet(tester, source: source, kind: MediaKind.movie, tmdbId: 550, title: 'Fight Club');
|
||||||
|
|
||||||
|
// Single server: no server picker, but profile/folder pickers show
|
||||||
|
// the instance defaults.
|
||||||
|
expect(find.text('Destination server'), findsNothing);
|
||||||
|
expect(find.text('Quality profile'), findsOneWidget);
|
||||||
|
expect(find.text('1080p'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Request'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(postedBody, {
|
||||||
|
'mediaType': 'movie',
|
||||||
|
'mediaId': 550,
|
||||||
|
'is4k': false,
|
||||||
|
'serverId': 0,
|
||||||
|
'profileId': 6,
|
||||||
|
'rootFolder': '/movies',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user