feat(explore): Explore tab with catalog detail, search, and Seerr requests

This commit is contained in:
edde746
2026-07-10 07:10:15 +02:00
parent f94b8c6586
commit 04daf89a20
25 changed files with 3403 additions and 250 deletions
+166
View File
@@ -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.
}
}
+18 -5
View File
@@ -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),
+137
View File
@@ -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();
}
}
+300
View File
@@ -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();
}
}
+453
View File
@@ -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,
)!,
),
],
),
),
);
}
}
+124
View File
@@ -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,
),
),
);
}
}
+388
View File
@@ -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,
),
),
],
),
),
);
}
}
+7
View File
@@ -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'),
];
+38 -1
View File
@@ -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;
+85 -93
View File
@@ -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
View File
@@ -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,
+14
View File
@@ -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)));
}
+21
View File
@@ -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,
+144
View File
@@ -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,
),
],
],
),
),
],
),
),
),
),
);
},
),
),
);
}
}
+78
View File
@@ -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);
}
}
}
+38
View File
@@ -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(
+549
View File
@@ -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)),
);
}
}
+26
View File
@@ -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,
+32
View File
@@ -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),
],
),
);
}
}
+35
View File
@@ -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)));
});
});
}
+170
View File
@@ -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));
+269
View File
@@ -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',
});
});
}