diff --git a/lib/mixins/debounced_media_search.dart b/lib/mixins/debounced_media_search.dart new file mode 100644 index 00000000..8114d323 --- /dev/null +++ b/lib/mixins/debounced_media_search.dart @@ -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 on State { + 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 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> 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 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 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. + } +} diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index f51131de..e4a70954 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -6,7 +6,7 @@ import '../i18n/strings.g.dart'; import '../utils/platform_detector.dart'; /// 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 class NavigationTab { @@ -22,16 +22,21 @@ class NavigationTab { } /// Get the index for a tab ID in the visible tabs list - static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false}) { - final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv); + static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) { + final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore); return tabs.indexWhere((tab) => tab.id == id); } /// Get tabs filtered by offline mode and feature availability - static List getVisibleTabs({required bool isOffline, bool hasLiveTv = false}) { + static List getVisibleTabs({ + required bool isOffline, + bool hasLiveTv = false, + bool hasExplore = false, + }) { return allNavigationTabs.where((tab) { if (isOffline && tab.onlineOnly) 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; return true; }).toList(); @@ -45,9 +50,10 @@ class NavigationTab { static NavigationTabId resolveDefaultTab({ required bool isOffline, required bool hasLiveTv, + bool hasExplore = false, 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)) { return NavigationTabId.downloads; } @@ -60,6 +66,7 @@ class NavigationTab { // Label getters (must be top-level for const constructor) String _getHomeLabel() => t.common.home; +String _getExploreLabel() => t.navigation.explore; String _getLibrariesLabel() => t.navigation.libraries; String _getLiveTvLabel() => t.navigation.liveTv; String _getSearchLabel() => t.common.search; @@ -76,6 +83,12 @@ const allNavigationTabs = [ getLabel: _getLibrariesLabel, ), 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.downloads, diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 2a7d6b04..7d9ab706 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -10,18 +10,22 @@ import '../media/media_server_client.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../profiles/profile_connection_registry.dart'; +import '../providers/catalog_sources_provider.dart'; import '../providers/companion_remote_provider.dart'; import '../providers/discover_provider.dart'; +import '../providers/explore_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; import '../providers/trakt_account_provider.dart'; +import '../providers/seerr_account_provider.dart'; import '../providers/trackers_provider.dart'; import '../providers/watch_state_store.dart'; import '../database/app_database.dart'; import '../screens/main_screen.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_impl.dart'; import '../services/offline_watch_sync_service.dart'; @@ -143,6 +147,50 @@ class _ProfileSessionScreenState extends State { return provider; }, ), + ChangeNotifierProvider( + create: (context) { + final provider = SeerrAccountProvider(); + provider.bindPlexTokenSupplier( + buildSeerrPlexTokenSupplier( + activeProfile: context.read(), + connections: context.read(), + profileConnections: context.read(), + ), + ); + 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()), + lazy: true, + ), + Provider(create: (context) => CatalogLibraryMatcher(context.read()), lazy: true), ChangeNotifierProvider( create: (context) => HiddenLibrariesProvider(storageService: context.read(), profileId: activeId), diff --git a/lib/providers/catalog_sources_provider.dart b/lib/providers/catalog_sources_provider.dart new file mode 100644 index 00000000..fc77f6c1 --- /dev/null +++ b/lib/providers/catalog_sources_provider.dart @@ -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 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 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 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 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(); + } +} diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart new file mode 100644 index 00000000..3698c34c --- /dev/null +++ b/lib/providers/explore_provider.dart @@ -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 _rows = {}; + ExploreLoadState _state = ExploreLoadState.initial; + String? _errorMessage; + DateTime? _loadedAt; + final FutureCoalescer _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 _watchlistRefreshCoalescer = FutureCoalescer(); + + List? _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 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 = [ + 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 load() => _loadCoalescer.run(_loadOnce); + + Future _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((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 = { + 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> loadAllForRow(CatalogRowId row) async { + final source = _source; + if (source == null) return const []; + final items = []; + 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 _refreshWatchlistRow() => _watchlistRefreshCoalescer.run(_refreshWatchlistRowOnce); + + Future _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(); + } +} diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart new file mode 100644 index 00000000..58152bc0 --- /dev/null +++ b/lib/screens/catalog_item_detail_screen.dart @@ -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 createState() => _CatalogItemDetailScreenState(); +} + +class _CatalogItemDetailScreenState extends State { + final _actionBarKey = GlobalKey(); + CatalogSource? _watchlistSource; + SeerrCatalogSource? _requestSource; + bool _mutatingWatchlist = false; + + /// Library items matching this catalog item; null while resolving. + List? _matches; + + /// Cast/characters from the item's own source; null while loading (the + /// section only renders once loaded non-empty). + List? _cast; + + /// "More like this" from the item's own source; null while loading (the + /// row only renders once loaded non-empty). + List? _related; + + @override + void initState() { + super.initState(); + unawaited(_resolveMatches()); + unawaited(_loadCast()); + unawaited(_loadRelated()); + final sources = context.read(); + _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 _resolveMatches() async { + try { + final matches = await context.read().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().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source); + + /// One lazy request against the item's own source; failures just leave the + /// section hidden. + Future _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 _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 _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 = [ + 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 = [ + 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 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 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 cast when cast.isNotEmpty) ...[ + const SizedBox(height: 28), + _buildCastSection(theme, cast), + ], + if (_related case final List 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, + )!, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart new file mode 100644 index 00000000..61ff69d4 --- /dev/null +++ b/lib/screens/catalog_search_screen.dart @@ -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 createState() => _CatalogSearchScreenState(); +} + +class _CatalogSearchScreenState extends State with DebouncedMediaSearch { + @override + String get searchDebugLabel => 'CatalogSearch'; + + @override + Future> 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, + ), + ), + ); + } +} diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart new file mode 100644 index 00000000..72d637a1 --- /dev/null +++ b/lib/screens/explore_screen.dart @@ -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 createState() => ExploreScreenState(); +} + +class ExploreScreenState extends State + with Refreshable, FullRefreshable, TabVisibilityAware, FocusableTab { + late ExploreProvider _explore; + + /// Per-row focus keys, keyed by hub id so focus memory survives reloads. + final Map> _hubKeysById = {}; + List> _orderedHubKeys = const []; + final _actionBarKey = GlobalKey(); + + // TV spotlight layout (mirrors LibraryRecommendedTab's rail + backdrop). + final _tvBrowseRailKey = GlobalKey(); + final ValueNotifier _spotlightItem = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _explore = context.read(); + _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 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 rowHubs) { + final liveIds = {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.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( + tooltip: t.explore.selectSource, + onSelected: (id) => unawaited(sources.setActiveSource(id)), + entriesBuilder: (context) => [ + for (final source in sources.connectedSources) + AppMenuItem( + 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(); + final sources = context.watch(); + 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(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 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( + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 4f966528..42299fc1 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../media/catalog_item_ref.dart'; import '../media/ids.dart'; import 'package:flutter/material.dart'; @@ -169,11 +170,17 @@ class _HubDetailScreenState extends State } } + /// 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 _getDefaultSortOptions() { return [ MediaSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'), 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'), ]; } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 67c4236c..78f4d0a3 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -33,6 +33,7 @@ import '../profiles/active_profile_binder.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; +import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_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/key_event_utils.dart'; import 'discover_screen.dart'; +import 'explore_screen.dart'; import 'libraries/library_quick_picker_sheet.dart'; import 'libraries/libraries_screen.dart'; import 'livetv/live_tv_screen.dart'; @@ -202,8 +204,10 @@ class _MainScreenState extends State OfflineModeProvider? _offlineModeProvider; MultiServerProvider? _multiServerProvider; + CatalogSourcesProvider? _catalogSourcesProvider; RouteObserver>? _profileRouteObserver; bool _lastHasLiveTv = false; + bool _lastHasExplore = false; /// Whether a reconnection attempt is in progress bool _isReconnecting = false; @@ -218,6 +222,7 @@ class _MainScreenState extends State late List _screens; final GlobalKey> _discoverKey = GlobalKey(); + final GlobalKey> _exploreKey = GlobalKey(); final GlobalKey> _librariesKey = GlobalKey(); final GlobalKey> _liveTvKey = GlobalKey(); final GlobalKey> _searchKey = GlobalKey(); @@ -299,6 +304,11 @@ class _MainScreenState extends State } catch (_) { _lastHasLiveTv = false; } + try { + _lastHasExplore = context.read().hasAnySource; + } catch (_) { + _lastHasExplore = false; + } _currentTab = _defaultTabForMode(_isOffline); _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; _autoSwitchedToDownloads = _isOffline && _currentTab == NavigationTabId.downloads; @@ -745,6 +755,15 @@ class _MainScreenState extends State _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(); + if (catalogSources != _catalogSourcesProvider) { + _catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged); + _catalogSourcesProvider = catalogSources; + _catalogSourcesProvider!.addListener(_handleCatalogSourcesChanged); + } + // Wire up Companion Remote command routing (host devices only, once) if (!_companionRemoteSetup && PlatformDetector.shouldActAsRemoteHost(context)) { _companionRemoteSetup = true; @@ -823,6 +842,7 @@ class _MainScreenState extends State } _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _multiServerProvider?.removeListener(_handleLiveTvChanged); + _catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged); if (_bindingSettleListener != null) { _activeProfileForListener?.removeListener(_bindingSettleListener!); } @@ -912,6 +932,7 @@ class _MainScreenState extends State for (final tab in _getVisibleTabs(offline)) switch (tab.id) { NavigationTabId.discover => DiscoverScreen(key: _discoverKey), + NavigationTabId.explore => ExploreScreen(key: _exploreKey), NavigationTabId.libraries => LibrariesScreen( key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged, @@ -936,6 +957,7 @@ class _MainScreenState extends State NavigationTabId _defaultTabForMode(bool isOffline) => NavigationTab.resolveDefaultTab( isOffline: isOffline, hasLiveTv: _hasLiveTv, + hasExplore: _lastHasExplore, preferredStartup: SettingsService.instanceOrNull?.read(SettingsService.startupSection), ); @@ -994,6 +1016,20 @@ class _MainScreenState extends State } } + 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() { final hasVisibleConnectedServers = context.read().hasConnectedServers; if (hasVisibleConnectedServers) _offlineUntilConnected = false; @@ -1527,7 +1563,7 @@ class _MainScreenState extends State /// Get navigation tabs filtered by offline mode List _getVisibleTabs(bool isOffline) { - return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv); + return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv, hasExplore: _lastHasExplore); } List _getBottomNavigationTabs(BuildContext context) { @@ -1543,6 +1579,7 @@ class _MainScreenState extends State GlobalKey? _screenKeyFor(NavigationTabId tab) { return switch (tab) { NavigationTabId.discover => _discoverKey, + NavigationTabId.explore => _exploreKey, NavigationTabId.libraries => _librariesKey, NavigationTabId.liveTv => _liveTvKey, NavigationTabId.search => _searchKey, diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index cdcc0b36..edfde822 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -194,6 +194,38 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { _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); final moreActionsAction = widget.isOffline @@ -216,6 +248,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ?shuffleAction, ?downloadAction, watchedAction, + ?watchlistAction, ?moreActionsAction, ]; @@ -280,6 +313,69 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ); } + Future _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( + 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 _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 _handleWatchedTogglePressed(MediaItem metadata) async { try { final isWatched = metadata.isWatched; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 683475a4..819d833b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -21,7 +21,7 @@ import '../focus/focusable_action_bar.dart'; import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.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 '../media/library_query.dart'; import '../media/media_hub.dart'; @@ -51,12 +51,13 @@ import '../utils/download_utils.dart'; import '../services/settings_service.dart'; import '../services/watch_actions.dart'; import '../widgets/settings_builder.dart'; -import '../utils/grid_size_calculator.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/offline_watch_provider.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/formatters.dart'; import '../utils/scroll_utils.dart'; @@ -64,6 +65,9 @@ import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.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 '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_context_menu.dart'; @@ -102,6 +106,10 @@ const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; 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 { final Map> _states = {}; final Set _firstPageLoadsInFlight = {}; @@ -295,6 +303,15 @@ class _MediaDetailScreenState extends State final ValueNotifier _tvDetailFocusedEpisode = ValueNotifier(null); 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 _watchlistCandidates = const []; + List _watchlistListenedSources = const []; + final GlobalKey _watchlistButtonKey = GlobalKey(); + bool _watchlistMutationInFlight = false; + // Inline season tabs int _selectedSeasonIndex = 0; final _seasonEpisodePager = _SeasonEpisodePager(); @@ -670,6 +687,47 @@ class _MediaDetailScreenState extends State _castFocusNode = FocusNode(debugLabel: 'cast_row'); _infoRowsFocusNode = FocusNode(debugLabel: 'info_rows'); _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(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 _resolveWatchlistIds(List 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 = []; + 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() { @@ -794,6 +852,9 @@ class _MediaDetailScreenState extends State @override void dispose() { + for (final source in _watchlistListenedSources) { + source.watchlistChanges.removeListener(_onWatchlistSourceChanged); + } _routeObserver?.unsubscribe(this); _scrollController.dispose(); _scrollOffset.dispose(); @@ -2143,12 +2204,9 @@ class _MediaDetailScreenState extends State } /// Get the responsive card width used by seasons/extras/cast rows. - /// Uses the shared grid size calculator for consistency with library grids. - double _getResponsiveCardWidth() { - final density = SettingsService.instance.read(SettingsService.libraryDensity); - final availableWidth = MediaQuery.sizeOf(context).width; - return GridSizeCalculator.getCellWidth(availableWidth, context, density); - } + /// Delegates to the cast strip's calculator so the dpad scroll math and + /// the rendered cards can never disagree. + double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context); /// Handle key events for the overview section KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) { @@ -2464,7 +2522,7 @@ class _MediaDetailScreenState extends State scrollListToIndex( _castScrollController, _focusedCastIndex, - itemExtent: _getResponsiveCardWidth() + 6 + 4, + itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()), leadingPadding: 0, ); } @@ -2478,7 +2536,7 @@ class _MediaDetailScreenState extends State scrollListToIndex( _castScrollController, _focusedCastIndex, - itemExtent: _getResponsiveCardWidth() + 6 + 4, + itemExtent: CastMemberStrip.itemExtentForCardWidth(_getResponsiveCardWidth()), leadingPadding: 0, ); } @@ -3104,10 +3162,19 @@ class _MediaDetailScreenState extends State // Show loading state while fetching full metadata 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( onKeyEvent: _handleMediaDetailBackKey, 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()), ), ); @@ -4374,94 +4441,19 @@ class _MediaDetailScreenState extends State } Widget _buildCastSectionContent(MediaItem metadata) { - final cardWidth = _getResponsiveCardWidth(); - 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); - + final roles = metadata.roles!; return Focus( focusNode: _castFocusNode, onKeyEvent: _handleCastKeyEvent, child: ListenableBuilder( listenable: _castFocusNode, - builder: (context, _) { - final hasFocus = _castFocusNode.hasFocus; - - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - 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), - ], - ], - ), - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ); - }, + builder: (context, _) => CastMemberStrip( + members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)], + imageClient: getServerBoundMediaClient(context), + controller: _castScrollController, + focusedIndex: _castFocusNode.hasFocus ? _focusedCastIndex : null, + onMemberTap: (index) => _navigateToActorMedia(roles[index]), + ), ), ); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 6a3e48c4..23b60d12 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -2,12 +2,11 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import 'package:rate_limiter/rate_limiter.dart'; import '../focus/focusable_text_field.dart'; import '../i18n/strings.g.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/refreshable.dart'; import '../providers/multi_server_provider.dart'; @@ -30,163 +29,83 @@ class SearchScreen extends StatefulWidget { } class _SearchScreenState extends State - with - Refreshable, - FullRefreshable, - SearchInputFocusable, - FocusableTab, - ControllerDisposerMixin, - MountedSetStateMixin { - late final _searchController = createTextEditingController(); - final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); - final _firstResultFocusNode = FocusNode(debugLabel: 'SearchFirstResult'); - List _searchResults = []; - bool _isSearching = false; - bool _hasSearched = false; - late final Debounce _searchDebounce; - String _lastSearchedQuery = ''; + with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; @override void initState() { super.initState(); - _searchDebounce = debounce(_performSearch, const Duration(milliseconds: 500)); - _searchController.addListener(_onSearchChanged); - FocusUtils.requestFocusAfterBuild(this, _searchFocusNode); + FocusUtils.requestFocusAfterBuild(this, searchFocusNode); } @override - void dispose() { - _searchDebounce.cancel(); - _searchController.removeListener(_onSearchChanged); - _searchFocusNode.dispose(); - _firstResultFocusNode.dispose(); - super.dispose(); + String get searchDebugLabel => 'Search'; + + @override + Future> performSearchQuery(String query) async { + final multiServerProvider = Provider.of(context, listen: false); + if (!multiServerProvider.hasConnectedServers) { + throw Exception('No servers available'); + } + return multiServerProvider.aggregationService.searchAcrossServers(query); } - void _onSearchChanged() { - if (!mounted) return; - - final query = _searchController.text; - - 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]); + @override + void onSearchError(Object error) { + _focusResultsForQuery = null; + showErrorSnackBar(context, t.errors.searchFailed(error: error)); } - Future _performSearch(String query) async { - if (!mounted) return; - - if (query.trim().isEmpty) { - setStateIfMounted(() { - _searchResults = []; - _hasSearched = false; - }); - return; - } - - setStateIfMounted(() { - _isSearching = true; - _hasSearched = true; - }); - - try { - if (!mounted) return; - final multiServerProvider = Provider.of(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)); - } - } + @override + void onSearchCleared() { + _focusResultsForQuery = null; } - /// OSK "Search" / hardware Enter on TV: jump to results, or force the - /// search to run now and focus results when it lands. - void _handleSearchSubmit() { - 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 results) { - if (_focusResultsForQuery == null || _focusResultsForQuery != query.trim()) return; + @override + void onSearchCompleted(String query, List results) { + if (_focusResultsForQuery == null || _focusResultsForQuery != query) return; _focusResultsForQuery = null; if (results.isEmpty) return; - if (_searchController.text.trim() != query.trim()) return; // user kept editing - FocusUtils.requestFocusAfterBuild(this, _firstResultFocusNode); + if (searchController.text.trim() != query) return; // user kept editing + 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 void refresh() { if (!mounted) return; - if (_searchController.text.isNotEmpty) { - _performSearch(_searchController.text); - } + runSearch(searchController.text.trim()); } /// Focus the search input field @override void focusSearchInput() { if (!mounted) return; - _searchFocusNode.requestFocus(); + searchFocusNode.requestFocus(); } @override void focusActiveTabIfReady() { if (!mounted) return; - _searchFocusNode.requestFocus(); + searchFocusNode.requestFocus(); } /// Set the search query externally (e.g. from companion remote) @override void setSearchQuery(String query) { if (!mounted) return; - _searchController.text = query; + searchController.text = query; } // Public method to fully reload all content (for profile switches) @@ -194,23 +113,15 @@ class _SearchScreenState extends State void fullRefresh() { if (!mounted) return; appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); - // Clear search results and search text for new profile - _searchController.clear(); + // Clearing the field resets the search state through the text listener. _focusResultsForQuery = null; - setStateIfMounted(() { - _searchResults.clear(); - _isSearching = false; - _hasSearched = false; - _lastSearchedQuery = ''; - }); + searchController.clear(); } void updateItem(String _) { if (!mounted) return; // Trigger a refresh of the search to get updated metadata - if (_searchController.text.isNotEmpty) { - _performSearch(_searchController.text); - } + runSearch(searchController.text.trim()); } /// Navigate focus to the sidebar @@ -226,13 +137,13 @@ class _SearchScreenState extends State sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final item = _searchResults[index]; + final item = searchResults[index]; return FocusableMediaCard( key: Key(item.globalKey), item: item, forceListMode: true, disableScale: true, - focusNode: index == 0 ? _firstResultFocusNode : null, + focusNode: index == 0 ? firstResultFocusNode : null, onRefresh: updateItem, onListRefresh: () => updateItem(item.id), onNavigateLeft: _navigateToSidebar, @@ -240,7 +151,7 @@ class _SearchScreenState extends State showServerName: showServerName, ); }, - childCount: _searchResults.length, + childCount: searchResults.length, addAutomaticKeepAlives: false, addSemanticIndexes: false, ), @@ -260,17 +171,15 @@ class _SearchScreenState extends State child: Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), child: FocusableTextField( - controller: _searchController, - focusNode: _searchFocusNode, + controller: searchController, + focusNode: searchFocusNode, textInputAction: TextInputAction.search, onNavigateLeft: _navigateToSidebar, - onNavigateDown: _searchResults.isNotEmpty && !_isSearching - ? _firstResultFocusNode.requestFocus - : null, - onEditingComplete: PlatformDetector.isTV() ? _handleSearchSubmit : null, + onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, onBack: () { - if (_searchController.text.isNotEmpty) { - _searchController.clear(); + if (searchController.text.isNotEmpty) { + searchController.clear(); } else { _navigateToSidebar(); } @@ -279,11 +188,11 @@ class _SearchScreenState extends State context, hintText: t.search.hint, prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: _searchController.text.isNotEmpty + suffixIcon: searchController.text.isNotEmpty ? IconButton( icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: () { - _searchController.clear(); + searchController.clear(); }, ) : null, @@ -291,9 +200,9 @@ class _SearchScreenState extends State ), ), ), - if (_isSearching) + if (isSearching) LoadingIndicatorBox.sliver - else if (!_hasSearched) + else if (!hasSearched) SliverFillRemaining( child: StateMessageWidget( message: t.search.searchYourMedia, @@ -302,7 +211,11 @@ class _SearchScreenState extends State 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( child: StateMessageWidget( message: t.messages.noResultsFound, diff --git a/lib/utils/catalog_navigation_helper.dart b/lib/utils/catalog_navigation_helper.dart new file mode 100644 index 00000000..9c879195 --- /dev/null +++ b/lib/utils/catalog_navigation_helper.dart @@ -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 navigateToCatalogItem(BuildContext context, CatalogItem item) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => CatalogItemDetailScreen(item: item))); +} diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 16ed6b57..421f08e0 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../i18n/strings.g.dart'; +import '../media/catalog_item_ref.dart'; import '../media/ids.dart'; import '../media/media_item.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 '../services/settings_service.dart'; import '../utils/global_key_utils.dart'; +import 'catalog_navigation_helper.dart'; import 'music_navigation.dart'; import 'plex_library_section_helpers.dart'; import 'video_player_navigation.dart'; @@ -167,6 +169,15 @@ Future navigateToMediaItem( return MediaNavigationResult.unsupported; } 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 continueWatchingAction = settings?.read(SettingsService.continueWatchingAction) ?? ContinueWatchingAction.play; final episodeAction = settings?.read(SettingsService.episodeAction) ?? EpisodeAction.play; @@ -251,6 +262,16 @@ Future navigateToMediaItemDetails( void Function(String)? onRefresh, MediaItem? metadataOverride, }) 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 result = await Navigator.push( context, diff --git a/lib/widgets/cast_member_strip.dart b/lib/widgets/cast_member_strip.dart new file mode 100644 index 00000000..b046e68f --- /dev/null +++ b/lib/widgets/cast_member_strip.dart @@ -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 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, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/widgets/catalog_context_menu.dart b/lib/widgets/catalog_context_menu.dart new file mode 100644 index 00000000..4463ddd2 --- /dev/null +++ b/lib/widgets/catalog_context_menu.dart @@ -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 _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 showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset? position}) async { + final source = Provider.of(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); + } + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4595484d..75413c8c 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import '../media/ids.dart'; @@ -7,15 +8,18 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/card_focus_scope.dart'; import '../focus/input_mode_tracker.dart'; +import '../media/catalog_item_ref.dart'; import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; import '../media/media_playlist.dart'; import '../mixins/context_menu_tap_mixin.dart'; +import '../models/catalog/catalog_item.dart'; import '../providers/download_provider.dart'; import '../providers/watch_state_store.dart'; import '../services/download_storage_service.dart'; import '../services/settings_service.dart'; +import 'catalog_context_menu.dart'; import 'settings_builder.dart'; import 'watched_indicator.dart'; import '../utils/content_utils.dart'; @@ -96,6 +100,34 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin showSeerrRequestSheet( + BuildContext context, { + required SeerrCatalogSource source, + required MediaKind kind, + required int tmdbId, + required String title, +}) { + return OverlaySheetController.showAdaptive( + 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 createState() => _SeerrRequestSheetState(); +} + +class _SeerrRequestSheetState extends State { + bool _loading = true; + bool _loadFailed = false; + + SeerrPublicSettings? _settings; + SeerrMediaInfo? _mediaInfo; + + /// TV only: requestable seasons (specials and empty seasons dropped). + List _seasons = const []; + final Set _selectedSeasons = {}; + + bool _is4k = false; + + /// Advanced options (REQUEST_ADVANCED): all configured instances of the + /// matching service; the pickers filter by the 4K toggle. + List _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 get _serversForVariant => [..._allServers.where((s) => s.is4k == _is4k)]; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _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 []), + ).wait; + + SeerrMediaInfo? mediaInfo; + var seasons = const []; + 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 []) + 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 _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 get _activeRequests => (_mediaInfo?.requests ?? const []).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 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 _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 _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 _buildAdvancedSection(ThemeData theme) { + final servers = _serversForVariant; + final detail = _serverDetail; + final profiles = detail?.profiles ?? const []; + final folders = detail?.rootFolders ?? const []; + final languages = detail?.languageProfiles ?? const []; + 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( + 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( + 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( + 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( + 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 extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final List options; + final String Function(T) describe; + final bool Function(T) isSelected; + final bool enabled; + final ValueChanged 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 _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( + context, + anchorRect: anchorRect, + focusFirstItem: true, + entries: [ + for (final option in options) + AppMenuItem(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)), + ); + } +} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 26d68e57..b0dec0c6 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -14,6 +14,7 @@ import '../media/media_item.dart'; import '../media/media_library.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../navigation/navigation_tabs.dart'; +import '../providers/catalog_sources_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../services/music/music_playback_service.dart'; @@ -241,6 +242,7 @@ class SideNavigationRailState extends State with MountedSetS } static const _kHome = 'home'; + static const _kExplore = 'explore'; static const _kNowPlaying = 'nowPlaying'; static const _kLibraries = 'libraries'; static const _kSearch = 'search'; @@ -382,6 +384,8 @@ class SideNavigationRailState extends State with MountedSetS switch (widget.selectedTab) { case NavigationTabId.discover: return _kHome; + case NavigationTabId.explore: + return _kExplore; case NavigationTabId.libraries: final libKey = widget.selectedLibraryKey; if (libKey != null && _librariesExpanded) { @@ -432,11 +436,13 @@ class SideNavigationRailState extends State with MountedSetS required bool hasHiddenLibraries, required bool hasLiveTv, required bool hasNowPlaying, + required bool hasExplore, }) { return { _kHome, if (hasNowPlaying) _kNowPlaying, _kLibraries, + if (hasExplore) _kExplore, _kSearch, if (_showDownloads) _kDownloads, _kSettings, @@ -511,6 +517,7 @@ class SideNavigationRailState extends State with MountedSetS required bool hasHiddenLibraries, required bool hasLiveTv, required bool hasNowPlaying, + required bool hasExplore, }) { return [ if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, @@ -526,6 +533,7 @@ class SideNavigationRailState extends State with MountedSetS ], ], if (hasLiveTv) 'liveTv', + if (hasExplore) _kExplore, _kSearch, ], if (_showDownloads) _kDownloads, @@ -643,6 +651,9 @@ class SideNavigationRailState extends State with MountedSetS final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; // Nullable watch: rail tests (and any host without the profile session + // scope) simply never show the Explore item. + final hasExplore = context.watch()?.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 // way back into the now-playing screen there; desktop already has the // mini-player for that. @@ -680,6 +691,7 @@ class SideNavigationRailState extends State with MountedSetS hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, hasNowPlaying: nowPlayingTrack != null, + hasExplore: hasExplore, ), ); final focusOrder = _buildFocusOrder( @@ -688,6 +700,7 @@ class SideNavigationRailState extends State with MountedSetS hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, hasNowPlaying: nowPlayingTrack != null, + hasExplore: hasExplore, ); _debugAssertUniqueFocusOrder(focusOrder); return TapRegion( @@ -777,6 +790,19 @@ class SideNavigationRailState extends State with MountedSetS ), 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( icon: Symbols.search_rounded, selectedIcon: Symbols.search_rounded, diff --git a/lib/widgets/stat_chip.dart b/lib/widgets/stat_chip.dart new file mode 100644 index 00000000..6d4c2ea2 --- /dev/null +++ b/lib/widgets/stat_chip.dart @@ -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), + ], + ), + ); + } +} diff --git a/test/navigation/navigation_tabs_test.dart b/test/navigation/navigation_tabs_test.dart index 52ca1c4b..2273c5d2 100644 --- a/test/navigation/navigation_tabs_test.dart +++ b/test/navigation/navigation_tabs_test.dart @@ -41,5 +41,40 @@ void main() { 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))); + }); }); } diff --git a/test/providers/explore_provider_test.dart b/test/providers/explore_provider_test.dart new file mode 100644 index 00000000..306f63f1 --- /dev/null +++ b/test/providers/explore_provider_test.dart @@ -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 rows; + final watchlist = WatchlistChangeNotifier(); + + bool gate = false; + final pending = >[]; + final fetches = {}; + + CatalogPage _page(CatalogRowId row) => CatalogPage( + items: [ + CatalogItem( + source: id, + kind: MediaKind.movie, + title: '${id.name}:${row.name}', + ids: const CatalogItemIds(tmdb: 1), + ), + ], + ); + + @override + Future fetchRow(CatalogRowId row, {int page = 1, int limit = 25}) { + fetches[row] = (fetches[row] ?? 0) + 1; + if (gate) { + final completer = Completer(); + 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 get supportedRows => rows; + @override + bool get supportsWatchlist => true; + @override + Listenable get watchlistChanges => watchlist; + @override + Future> search(String query, {int limit = 30}) async => const []; + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async => const []; + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const []; + @override + Future ensureWatchlistLoaded() async {} + @override + bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => null; + @override + Future resolveItemIds(MediaKind kind, ExternalIds external) async => null; + @override + Future addToWatchlist(MediaKind kind, CatalogItemIds ids) async {} + @override + Future 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 _pumpMicrotasks() async { + for (var i = 0; i < 5; i++) { + await Future.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); + }); + }); +} diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart new file mode 100644 index 00000000..ba779361 --- /dev/null +++ b/test/screens/catalog_search_screen_test.dart @@ -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 = []; + bool failNext = false; + + @override + CatalogSourceId get id => CatalogSourceId.trakt; + + @override + String get displayName => 'Trakt'; + + @override + Future> 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 _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>(find.byType(CatalogSearchScreen)); diff --git a/test/widgets/seerr_request_sheet_test.dart b/test/widgets/seerr_request_sheet_test.dart new file mode 100644 index 00000000..40b1b8b2 --- /dev/null +++ b/test/widgets/seerr_request_sheet_test.dart @@ -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 _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 _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( + 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? 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; + 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( + 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(submitFinder).onPressed, isNull); + + await tester.tap(find.text('Season 2')); + await tester.pump(); + expect(tester.widget(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? 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; + 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', + }); + }); +}