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