Files
plezy/lib/screens/media_detail_screen.dart
edde746 716fe9b51b feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the
detail screen's action row, which drops the bookmark first on narrow
screens with no fallback in the overflow menu. Add an entry to
MediaContextMenu for movies and shows whenever a connected catalog
source can hold the item, covering card long-press everywhere and the
detail screen's overflow.

External-id resolution is session-cached per item on
CatalogSourcesProvider and shared with the detail screen. A cold cache
labels the entry "Add to Watchlist" and always adds (idempotent), so a
press can never turn into a surprise removal; "Remove" is offered once
cached membership proves it. Several capable sources open the same
per-source chooser the detail screen uses.

close #1822
2026-08-09 17:14:48 +02:00

4461 lines
173 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import '../media/ids.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../navigation/profile_navigation_scope.dart';
import '../services/device_performance.dart';
import '../services/image_cache_service.dart';
import '../services/fullscreen_state_manager.dart';
import 'package:flutter/services.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/background_download_warning_banner.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/rating_bottom_sheet.dart';
import '../focus/dpad_navigator.dart';
import '../focus/dpad_select_long_press_controller.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/hub_vertical_navigation.dart';
import '../focus/locked_hub_controller.dart';
import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart';
import '../widgets/cast_member_strip.dart';
import '../widgets/focus_builders.dart';
import '../media/library_query.dart';
import '../media/media_hub.dart';
import '../utils/provider_extensions.dart';
import '../utils/plex_season_display.dart';
import '../media/media_item.dart';
import '../media/episode_collection.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_role.dart';
import '../media/paged_media_list_state.dart';
import '../widgets/media_card.dart';
import '../widgets/media_rating_badge.dart';
import '../i18n/strings.g.dart';
import '../theme/mono_tokens.dart';
import '../widgets/cycling_media_backdrop.dart';
import '../widgets/optimized_media_image.dart';
import '../utils/media_image_helper.dart';
import '../utils/media_quality_labels.dart';
import '../services/plex_client.dart';
import '../media/media_server_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../utils/content_utils.dart';
import '../models/download_models.dart';
import '../services/download_storage_service.dart';
import '../utils/download_version_utils.dart';
import '../utils/download_utils.dart';
import '../services/settings_service.dart';
import '../services/watch_actions.dart';
import '../widgets/settings_builder.dart';
import '../utils/layout_constants.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 '../services/catalog/catalog_source.dart';
import '../services/catalog/library_watchlist_candidates.dart';
import '../utils/app_logger.dart';
import '../utils/formatters.dart';
import '../utils/scroll_utils.dart';
import '../utils/dialogs.dart';
import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/app_bar_back_button.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';
import '../widgets/watchlist_source_chooser.dart';
import 'libraries/state_messages.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/placeholder_container.dart';
import '../mixins/watch_state_aware.dart';
import '../mixins/deletion_aware.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../mixins/server_bound_media_mixin.dart';
import '../utils/watch_state_notifier.dart';
import '../utils/deletion_notifier.dart';
import '../utils/global_key_utils.dart';
import '../widgets/episode_card.dart';
import '../widgets/fitting_title_text.dart';
import 'actor_media_screen.dart';
import '../widgets/focusable_tab_chip.dart';
import '../widgets/hub_section.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/rasterized_gradient.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
part 'media_detail/action_buttons.dart';
/// Ceiling for the detail hero's backdrop box, as a fraction of the window
/// height. Roughly the natural height of a 16:9 backdrop on a 16:10 desktop
/// window, so it does not bite there; it only kicks in on short/wide windows,
/// where an unbounded box would leave artwork showing under the overview.
const double _maxHeroArtViewportFraction = 0.86;
const double _tvDetailTallPosterScale = TvBrowseRailLayout.compactTallPosterScale;
const double _tvDetailEpisodeThumbnailScale = TvBrowseRailLayout.compactEpisodeThumbnailScale;
const double _tvDetailActionSize = 46;
const double _tvDetailActionRailGap = 4;
const String _tvDetailSeasonsErrorHubId = 'detail_seasons_error';
const String _tvDetailSeasonHubIdPrefix = 'detail_season_';
const String _tvDetailExtrasHubId = 'detail_extras';
const String _tvDetailActorsHubId = 'detail_actors';
const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId';
enum _SyncRuleAction { edit, remove, delete }
class _SeasonEpisodePager {
final Map<String, PagedMediaListState<MediaItem>> _states = {};
final Set<String> _firstPageLoadsInFlight = {};
final Set<String> _moreLoadsInFlight = {};
PagedMediaListState<MediaItem> stateFor(String seasonId) {
return _states[seasonId] ?? const PagedMediaListState<MediaItem>();
}
bool hasState(String seasonId) => _states.containsKey(seasonId);
bool beginFirstPageLoad(String seasonId) => _firstPageLoadsInFlight.add(seasonId);
void endFirstPageLoad(String seasonId) => _firstPageLoadsInFlight.remove(seasonId);
bool beginMoreLoad(String seasonId) => _moreLoadsInFlight.add(seasonId);
void endMoreLoad(String seasonId) => _moreLoadsInFlight.remove(seasonId);
void markFirstPageLoading(String seasonId) {
_states[seasonId] = stateFor(seasonId).startInitialLoad();
}
void completeFirstPage(String seasonId, List<MediaItem> episodes, int total) {
_states[seasonId] = stateFor(seasonId).completeInitialLoad(episodes, total);
}
void failFirstPage(String seasonId) {
_states[seasonId] = stateFor(seasonId).failInitialLoad();
}
void markMoreLoading(String seasonId) {
_states[seasonId] = stateFor(seasonId).startLoadMore();
}
void completeMoreLoad(
String seasonId, {
required int expectedOffset,
required List<MediaItem> episodes,
required int total,
}) {
_states[seasonId] = stateFor(
seasonId,
).completeLoadMore(expectedOffset: expectedOffset, pageItems: episodes, total: total);
}
void failMoreLoad(String seasonId) {
_states[seasonId] = stateFor(seasonId).failLoadMore();
}
void resetSeason(String seasonId) {
_states.remove(seasonId);
_firstPageLoadsInFlight.remove(seasonId);
_moreLoadsInFlight.remove(seasonId);
}
/// Drops cached episode pages for seasons outside [keepSeasonIds].
/// In-flight sets are left alone — a completing prefetch just re-adds one
/// bounded page. Evicted seasons transparently refetch through the normal
/// unloaded-hub path when refocused.
void retainOnly(Set<String> keepSeasonIds) {
_states.removeWhere((seasonId, _) => !keepSeasonIds.contains(seasonId));
}
void removeEpisode(String episodeId) {
for (final entry in _states.entries.toList()) {
_states[entry.key] = entry.value.removeWhere((episode) => episode.id == episodeId);
}
}
void patchEpisode(String episodeId, MediaItem Function(MediaItem existing) patch) {
for (final entry in _states.entries.toList()) {
var changed = false;
final next = <MediaItem>[];
for (final episode in entry.value.items) {
if (episode.id == episodeId) {
changed = true;
next.add(patch(episode));
} else {
next.add(episode);
}
}
if (changed) _states[entry.key] = entry.value.replaceItems(next);
}
}
}
/// Identifies the TV reveal gate's opacity wrapper. The detail tree builds
/// other [AnimatedOpacity] widgets (the scroll-linked app-bar scrim is 0 at
/// rest), so tests must target this one specifically.
@visibleForTesting
const tvDetailRevealGateKey = ValueKey<String>('tvDetailRevealGate');
class MediaDetailScreen extends StatefulWidget {
final MediaItem metadata;
final bool isOffline;
/// If provided, auto-selects this season index when the screen loads.
/// Used when navigating to a show from a season context.
final int? initialSeasonIndex;
final String? initialSeasonId;
final String? initialEpisodeId;
const MediaDetailScreen({
super.key,
required this.metadata,
this.isOffline = false,
this.initialSeasonIndex,
this.initialSeasonId,
this.initialEpisodeId,
});
@override
State<MediaDetailScreen> createState() => _MediaDetailScreenState();
}
PageRoute<bool> mediaDetailRoute({
required MediaItem metadata,
bool isOffline = false,
int? initialSeasonIndex,
String? initialSeasonId,
String? initialEpisodeId,
}) {
final page = MediaDetailScreen(
metadata: metadata,
isOffline: isOffline,
initialSeasonIndex: initialSeasonIndex,
initialSeasonId: initialSeasonId,
initialEpisodeId: initialEpisodeId,
);
if (!PlatformDetector.isTV()) return MaterialPageRoute<bool>(builder: (_) => page);
return PageRouteBuilder<bool>(
// Opaque so the covered route stops painting/building once the fade
// completes (the framework only offstages routes below after the
// transition settles, so push/pop fades still composite over live
// content). The detail screen paints a full-screen opaque background
// immediately, and the video player route is itself opaque and never
// sits below a detail route, so nothing can leak through.
opaque: true,
pageBuilder: (_, _, _) => page,
transitionsBuilder: (_, animation, _, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic),
child: child,
);
},
transitionDuration: AppDurations.animMedium,
reverseTransitionDuration: AppDurations.animMedium,
);
}
class _MediaDetailScreenState extends State<MediaDetailScreen>
with
WatchStateAware,
DeletionAware,
DeletionMirrorsWatchState,
MountedSetStateMixin,
ServerBoundMediaMixin,
RouteAware {
/// Public input alias — used as the live source of truth until the detail
/// fetch returns. Holds backend-neutral [MediaItem] data.
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
List<MediaItem> _seasons = [];
bool _isLoadingSeasons = false;
bool _seasonsLoadFailed = false; // the seasons fetch threw (vs. a genuinely empty show)
Completer<void>? _seasonsCompleter;
List<MediaItem> _episodes = [];
PagedMediaListState<MediaItem> _allEpisodes = const PagedMediaListState<MediaItem>();
int _episodesLoadGeneration = 0;
bool _showEpisodesDirectly = false;
MediaItem? _fullMetadata;
MediaItem? _onDeckEpisode;
bool _isLoadingMetadata = true;
List<MediaItem>? _extras;
List<MediaHub> _relatedHubs = [];
List<GlobalKey<HubSectionState>> _relatedHubKeys = [];
bool _hasLoadedExtras = false;
bool _hasLoadedRelatedHubs = false;
final _tvDetailRailKey = GlobalKey<TvBrowseRailState>();
final _hubFocusMemory = HubFocusMemory();
PageRoute<dynamic>? _route;
RouteObserver<PageRoute<dynamic>>? _routeObserver;
late final ScrollController _scrollController;
final ScrollController _extrasScrollController = ScrollController();
bool _watchStateChanged = false;
final ValueNotifier<double> _scrollOffset = ValueNotifier<double>(0);
bool _suppressBackAfterPop = false;
bool _tvDetailRevealed = false;
bool _tvDetailRevealScheduled = false;
bool _hasLoadedSeasons = false;
bool _hasLoadedEpisodes = false;
double? _tvDetailPendingRailHeight;
double? _tvDetailStableRailHeight;
// ValueNotifier (not setState) so d-pad scrubbing across episodes rebuilds
// only the foreground info panel, never the whole screen with its rail
// (same isolation pattern as DiscoverScreen._spotlightItem).
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();
List<FocusNode> _seasonTabFocusNodes = [];
PagedMediaListState<MediaItem> get _selectedSeasonEpisodeState {
if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) {
return const PagedMediaListState<MediaItem>();
}
return _seasonEpisodePager.stateFor(_seasons[_selectedSeasonIndex].id);
}
bool get _isLoadingEpisodes => _allEpisodes.isInitialLoading;
bool get _isLoadingAllEpisodes => _allEpisodes.isLoadingMore;
int get _allEpisodesTotal => _allEpisodes.totalCount;
bool get _allEpisodesPageError => _allEpisodes.initialLoadFailed || _allEpisodes.pageLoadFailed;
bool get _isLoadingSeasonEpisodes => _selectedSeasonEpisodeState.isInitialLoading;
bool get _isLoadingMoreSeasonEpisodes => _selectedSeasonEpisodeState.isLoadingMore;
bool get _seasonEpisodesPageError => _selectedSeasonEpisodeState.pageLoadFailed;
bool get _seasonEpisodesFirstPageError => _selectedSeasonEpisodeState.initialLoadFailed;
MediaItem _withFallbackLibrary(MediaItem item, MediaItem fallback) {
return item.copyWith(
libraryId: item.libraryId ?? fallback.libraryId,
libraryTitle: item.libraryTitle ?? fallback.libraryTitle,
);
}
final Map<int, GlobalKey<MediaContextMenuState>> _seasonContextMenuKeys = {};
final ScrollController _seasonTabsScrollController = ScrollController();
final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode');
final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode');
final FocusNode _initialEpisodeFocusNode = FocusNode(debugLabel: 'initial_episode');
String? _lastEpisodeFocusPinnedKey;
bool _suppressNextLastEpisodeFocusLoad = false;
bool _initialDetailFocusApplied = false;
bool _initialEpisodePagingInFlight = false;
bool _initialEpisodePagingDone = false;
static const int _episodesPageSize = 200;
// Sentinel at the end of the inline episode list; its viewport position drives
// lazy paging (the list is shrink-wrapped, so it can't self-detect near-end).
final GlobalKey _episodeTailKey = GlobalKey();
late final FocusNode _playButtonFocusNode;
late final FocusNode _ratingChipFocusNode;
late final FocusNode _backButtonFocusNode;
final _extrasSelectLongPress = DpadSelectLongPressController();
// Context menu key for the three-dots button
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
// Locked focus pattern for extras
int _focusedExtraIndex = 0;
final ValueNotifier<int> _focusedExtraIndexNotifier = ValueNotifier<int>(0);
late final FocusNode _extrasFocusNode;
final Map<int, GlobalKey<MediaCardState>> _extraCardKeys = {};
final _extrasSectionKey = GlobalKey();
// Locked focus pattern for overview
late final FocusNode _overviewFocusNode;
final _overviewSectionKey = GlobalKey();
final _castStripKey = GlobalKey<CastMemberStripState>();
final _castSectionKey = GlobalKey();
final _seasonsSectionKey = GlobalKey();
// Focus target for the trailing info rows (studio / contentRating)
late final FocusNode _infoRowsFocusNode;
final _infoRowsSectionKey = GlobalKey();
@override
MediaItem get serverBoundMetadata => _metadata;
@override
bool get isServerBoundOffline => widget.isOffline;
// WatchStateAware: watch the show/movie and all season/episode ratingKeys.
// DeletionMirrorsWatchState reuses these three getters for deletion events —
// the same items are on screen either way.
@override
Set<String>? get watchedIds {
final keys = <String>{_metadata.id};
for (final season in _seasons) {
keys.add(season.id);
}
for (final ep in _episodes) {
keys.add(ep.id);
}
return keys;
}
@override
String? get watchStateServerId => serverBoundServerId;
@override
Set<String>? get watchedGlobalKeys {
final serverId = serverBoundServerId;
if (serverId == null) return null;
final keys = <String>{toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))};
for (final season in _seasons) {
keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId)));
}
for (final ep in _episodes) {
keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId)));
}
return keys;
}
@override
void onWatchStateChanged(WatchStateEvent event) {
_watchStateChanged = true;
if (event.changeType == WatchStateChangeType.removedFromContinueWatching) return;
// Lists keep their server snapshots untouched; cards and the hero resolve
// them against [WatchStateStore] at build, so a rebuild is all the
// visuals need.
setStateIfMounted(() {});
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) return;
if (widget.isOffline) {
if (_metadata.isShow) {
unawaited(_loadOfflineOnDeckEpisode());
}
return;
}
// Online: refresh server-derived counters and on-deck state. A watched
// episode can change the hero play target even when the episode row itself
// was already visible.
unawaited(_refreshWatchState());
}
/// Session-fresh view of [item]: server snapshot + newest watch-state patch.
MediaItem _fresh(MediaItem item) => context.readFreshWatchState(item);
List<MediaItem> _freshAll(List<MediaItem> items) => context.readFreshWatchStateAll(items);
MediaItem _normalizeRefreshedItem(MediaItem item, MediaItem fallback) {
return _withFallbackLibrary(
item.copyWith(
serverId: item.serverId ?? fallback.serverId ?? _metadata.serverId,
serverName: item.serverName ?? fallback.serverName ?? _metadata.serverName,
),
fallback,
);
}
void _patchItemEverywhere(String sourceGlobalKey, MediaItem item) {
final base = _fullMetadata ?? widget.metadata;
if (base.globalKey == sourceGlobalKey) {
_fullMetadata = _normalizeRefreshedItem(item, base);
}
final onDeckEpisode = _onDeckEpisode;
if (onDeckEpisode != null && onDeckEpisode.globalKey == sourceGlobalKey) {
_onDeckEpisode = _normalizeRefreshedItem(item, onDeckEpisode);
}
_patchItemInList(_seasons, sourceGlobalKey, item);
_patchItemInList(_episodes, sourceGlobalKey, item);
_syncFlattenEpisodeState();
_seasonEpisodePager.patchEpisode(
item.id,
(existing) => existing.globalKey == sourceGlobalKey ? _normalizeRefreshedItem(item, existing) : existing,
);
final extras = _extras;
if (extras != null) {
_patchItemInList(extras, sourceGlobalKey, item);
}
_relatedHubs = _patchItemInHubs(_relatedHubs, sourceGlobalKey, item);
}
void _patchItemInList(List<MediaItem> items, String sourceGlobalKey, MediaItem item) {
for (var i = 0; i < items.length; i++) {
if (items[i].globalKey == sourceGlobalKey) {
items[i] = _normalizeRefreshedItem(item, items[i]);
}
}
}
List<MediaHub> _patchItemInHubs(List<MediaHub> hubs, String sourceGlobalKey, MediaItem item) {
var changed = false;
final updatedHubs = <MediaHub>[];
for (final hub in hubs) {
var hubChanged = false;
final items = List<MediaItem>.of(hub.items);
for (var i = 0; i < items.length; i++) {
if (items[i].globalKey == sourceGlobalKey) {
items[i] = _normalizeRefreshedItem(item, items[i]);
hubChanged = true;
}
}
changed = changed || hubChanged;
updatedHubs.add(hubChanged ? hub.copyWith(items: items) : hub);
}
return changed ? updatedHubs : hubs;
}
Future<void> _refreshItemInPlace(MediaItem source) async {
final serverId = source.serverId;
if (serverId == null) return;
final client = context.tryGetMediaClientForServer(ServerId(serverId));
if (client == null) return;
try {
final refreshed = await client.fetchItem(source.id);
if (refreshed == null || !mounted) return;
setStateIfMounted(() {
_patchItemEverywhere(source.globalKey, refreshed);
});
} catch (e) {
appLogger.d('Item refresh failed for ${source.globalKey}', error: e);
}
}
@override
void onDeletionEvent(DeletionEvent event) {
// Download-only deletions should only remove items when viewing offline content
if (event.isDownloadOnly && !widget.isOffline) return;
if (!event.isDownloadOnly && widget.isOffline) return;
// Drop the episode from any visible/cached list. This fires whether we're
// showing a flattened episode list or a season-tabs view of a show.
final epIndex = _episodes.indexWhere((e) => e.id == event.itemId);
if (epIndex != -1) {
setState(() {
if (_isFlattenEpisodeList) {
_allEpisodes = _allEpisodes.removeWhere((episode) => episode.id == event.itemId);
_episodes = _allEpisodes.items;
} else {
_episodes.removeAt(epIndex);
}
});
}
_seasonEpisodePager.removeEpisode(event.itemId);
if (epIndex != -1 && _showEpisodesDirectly) {
if (_episodes.isEmpty && (_metadata.isSeason || _metadata.isShow) && mounted) {
Navigator.of(context).pop();
}
return;
}
// If we have a season that matches the rating key exactly, then remove it from our list
final seasonIndex = _seasons.indexWhere((s) => s.id == event.itemId);
if (seasonIndex != -1) {
setState(() {
_seasons.removeAt(seasonIndex);
});
// If the show has no more seasons, navigate back up to the library
if (_seasons.isEmpty && mounted) {
Navigator.of(context).pop();
return;
}
_refreshWatchState();
return;
}
// If a child item was delete, then update our list to reflect that.
// If all children were deleted, remove our item.
// Otherwise, just update the counts.
for (final parentKey in event.parentChain) {
final idx = _seasons.indexWhere((s) => s.id == parentKey);
if (idx != -1) {
final season = _seasons[idx];
final newLeafCount = (season.leafCount ?? 1) - 1;
if (newLeafCount <= 0) {
// Season is now empty, remove it
setState(() {
_seasons.removeAt(idx);
});
// Otherwise we have no more seasons, so navigate up
if (_seasons.isEmpty && mounted) {
Navigator.of(context).pop();
return;
}
} else {
setState(() {
// Otherwise just update the counts
_seasons[idx] = season.copyWith(leafCount: newLeafCount);
});
}
_refreshWatchState();
return;
}
}
}
/// Lightweight refresh for watch state changes - no loader, preserves scroll
Future<void> _refreshWatchState() async {
// Backend-neutral. Plex bundles metadata + on-deck in one round-trip
// (`?includeOnDeck=1`); Jellyfin's [fetchItemWithOnDeck] returns
// onDeckEpisode=null and on-deck repopulates from cached lists on
// the next navigation.
final mediaClient = _getMediaClientForMetadata(context);
if (mediaClient == null) return;
final serverId = _metadata.serverId;
if (serverId == null) return;
final serverName = _metadata.serverName;
try {
final result = await mediaClient.fetchItemWithOnDeck(_metadata.id);
final metadata = result.item;
final onDeckEpisode = result.onDeckEpisode;
if (metadata != null) {
final refreshedMetadata = _withFallbackLibrary(
metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName),
_metadata,
);
final refreshedOnDeck = onDeckEpisode == null
? null
: _withFallbackLibrary(
onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName),
refreshedMetadata,
);
setStateIfMounted(() {
_fullMetadata = refreshedMetadata;
_onDeckEpisode = refreshedOnDeck;
});
}
// Do not refresh seasons/episodes here. The watch event has already
// patched loaded rows, and rebuilding those lists causes visible rail
// churn on TV/detail layouts.
} catch (e) {
appLogger.d('Watch-state refresh failed', error: e);
}
}
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
_lastEpisodeFocusNode.addListener(_onLastEpisodeFocusChanged);
_extrasFocusNode = FocusNode(debugLabel: 'extras_row');
_extrasFocusNode.addListener(_handleExtrasFocusChange);
_playButtonFocusNode = FocusNode(debugLabel: 'play_button');
_ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip');
_backButtonFocusNode = FocusNode(debugLabel: 'media_detail_back');
_overviewFocusNode = FocusNode(debugLabel: 'overview');
_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 catalogSources = Provider.of<CatalogSourcesProvider?>(context, listen: false);
final sources = catalogSources?.watchlistCapableSources ?? const <CatalogSource>[];
if (catalogSources == null || sources.isEmpty) return;
_watchlistListenedSources = sources;
for (final source in sources) {
source.watchlistChanges.addListener(_onWatchlistSourceChanged);
unawaited(source.ensureWatchlistLoaded());
}
unawaited(_resolveWatchlistIds(catalogSources));
}
Future<void> _resolveWatchlistIds(CatalogSourcesProvider catalogSources) async {
try {
// Session-cached on the provider and shared with the card context
// menus; null means the item is outside a source's domain and the
// action shows for the sources that resolved.
final candidates = await catalogSources.watchlistCandidatesFor(
_metadata,
client: _getMediaClientForMetadata(context),
);
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() {
final positions = _scrollController.positions;
if (positions.length != 1) return;
_scrollOffset.value = positions.first.pixels;
_maybeTriggerEpisodePaging();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final routeObserver = ProfileNavigationScope.of(context).routeObserver;
final route = ModalRoute.of(context);
if (route is! PageRoute<dynamic>) return;
if (route == _route && routeObserver == _routeObserver) return;
_routeObserver?.unsubscribe(this);
_route = route;
_routeObserver = routeObserver;
routeObserver.subscribe(this, route);
}
@override
void didPopNext() {
_suppressBackAfterPop = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _suppressBackAfterPop = false;
});
});
}
bool _consumeBackAfterChildPop(KeyEvent event) {
if (!_suppressBackAfterPop || !event.logicalKey.isBackKey) return false;
if (event is KeyUpEvent) _suppressBackAfterPop = false;
return true;
}
KeyEventResult _handleMediaDetailBackKey(FocusNode _, KeyEvent event) {
if (_consumeBackAfterChildPop(event)) return KeyEventResult.handled;
return handleBackKeyNavigation(context, event, result: _watchStateChanged);
}
void _popMediaDetailIfBackNotSuppressed() {
if (_suppressBackAfterPop) {
_suppressBackAfterPop = false;
return;
}
Navigator.pop(context, _watchStateChanged);
}
void _handleMediaDetailSystemBack() {
if (BackKeyCoordinator.consumeIfHandled()) return;
_popMediaDetailIfBackNotSuppressed();
}
bool _isTvDetailReadyToReveal(MediaItem metadata) {
if (_isLoadingMetadata) return false;
if (!_hasLoadedTvDetailSupplementalSections(metadata)) return false;
if (metadata.isShow) {
if (_isLoadingSeasons || (!_hasLoadedSeasons && _seasons.isEmpty)) return false;
if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes;
if (_seasons.isEmpty) return true;
if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false;
if (_seasonEpisodesFirstPageError) return true;
return !_isLoadingSeasonEpisodes && _seasonEpisodePager.hasState(_seasons[_selectedSeasonIndex].id);
}
if (metadata.isSeason) {
return _hasLoadedEpisodes && !_isLoadingEpisodes;
}
return true;
}
bool _hasLoadedTvDetailSupplementalSections(MediaItem metadata) {
if (widget.isOffline || (!metadata.isMovie && !metadata.isShow)) return true;
return _hasLoadedExtras && _hasLoadedRelatedHubs;
}
void _scheduleTvDetailReveal(double railHeight, {required bool focusPrimaryAction}) {
final pendingRailHeight = _tvDetailPendingRailHeight;
if (pendingRailHeight == null || railHeight > pendingRailHeight) {
_tvDetailPendingRailHeight = railHeight;
}
if (_tvDetailRevealed || _tvDetailRevealScheduled) return;
_tvDetailRevealScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_tvDetailStableRailHeight = _tvDetailPendingRailHeight ?? railHeight;
_tvDetailRevealScheduled = false;
_tvDetailRevealed = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (focusPrimaryAction) {
_playButtonFocusNode.requestFocus();
} else {
_tvDetailRailKey.currentState?.requestFocus();
}
});
});
}
Widget _buildTvDetailRevealGate(Widget child, KeyEventResult Function(FocusNode, KeyEvent) handleBack) {
final revealed = _tvDetailRevealed;
return Focus(
canRequestFocus: !revealed,
onKeyEvent: revealed ? null : handleBack,
child: ExcludeFocus(
excluding: !revealed,
child: IgnorePointer(
ignoring: !revealed,
child: AnimatedOpacity(
// Keyed so tests can assert this specific gate rather than
// whichever AnimatedOpacity happens to be lowest on screen.
key: tvDetailRevealGateKey,
opacity: revealed ? 1 : 0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
child: child,
),
),
),
);
}
@override
void dispose() {
for (final source in _watchlistListenedSources) {
source.watchlistChanges.removeListener(_onWatchlistSourceChanged);
}
_routeObserver?.unsubscribe(this);
_scrollController.dispose();
_scrollOffset.dispose();
_tvDetailFocusedEpisode.dispose();
_extrasScrollController.dispose();
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
_extrasFocusNode.dispose();
_focusedExtraIndexNotifier.dispose();
_playButtonFocusNode.dispose();
_ratingChipFocusNode.dispose();
_backButtonFocusNode.dispose();
_overviewFocusNode.dispose();
_infoRowsFocusNode.dispose();
_extrasSelectLongPress.dispose();
for (final node in _seasonTabFocusNodes) {
node.dispose();
}
_seasonTabsScrollController.dispose();
_firstEpisodeFocusNode.dispose();
_lastEpisodeFocusNode.removeListener(_onLastEpisodeFocusChanged);
_lastEpisodeFocusNode.dispose();
_initialEpisodeFocusNode.dispose();
super.dispose();
}
/// Build title text widget for clear logo fallback.
Widget _buildDetailTitle(
BuildContext context,
String title, {
double? fontSize,
FontWeight fontWeight = FontWeight.bold,
double shadowBlur = 8,
Color? color,
Color? shadowColor,
}) {
final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith(
color: color ?? Colors.white,
fontWeight: fontWeight,
fontSize: fontSize,
shadows: [Shadow(color: shadowColor ?? Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)],
);
return FittingTitleText(title, style: baseStyle);
}
/// Build radial progress indicator for download button
/// If progressPercent is null or 0, shows indeterminate spinner
Widget _buildRadialProgress(double? progressPercent) {
final colorScheme = Theme.of(context).colorScheme;
final size = PlatformDetector.isTV() ? 26.0 : 20.0;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: .center,
children: [
// Background circle (only show if we have determinate progress)
if (progressPercent != null && progressPercent > 0)
CircularProgressIndicator(
value: 1.0,
strokeWidth: 2.0,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary.withValues(alpha: 0.2)),
),
// Progress circle (indeterminate if no progress, determinate otherwise)
CircularProgressIndicator(
value: (progressPercent != null && progressPercent > 0) ? progressPercent : null, // null = indeterminate
strokeWidth: 2.0,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
),
],
),
);
}
/// Build action buttons row (play, shuffle, download, mark watched)
/// Build a metadata chip with optional leading icon or widget
Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) {
final colorScheme = Theme.of(context).colorScheme;
final isTv = PlatformDetector.isTV();
final textWidget = Text(
text,
style: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
);
final hasLeading = leading != null || icon != null;
return Container(
padding: .symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer.withValues(alpha: 0.8),
borderRadius: const BorderRadius.all(Radius.circular(100)),
),
child: hasLeading
? Row(
mainAxisSize: .min,
children: [
if (leading != null)
leading
else
AppIcon(icon!, fill: 1, color: colorScheme.onSecondaryContainer, size: isTv ? 20 : 16),
SizedBox(width: isTv ? 6 : 4),
textWidget,
],
)
: textWidget,
);
}
/// Every attributed score in one pill, then the tappable user-rating chip.
///
/// The scores share a single pill rather than taking a chip each: this row
/// is a height-clipped [Wrap], so a per-source chip would push the year,
/// certification, and runtime out of the visible band on short heroes.
List<Widget> _buildRatingChips(MediaItem metadata) {
final colorScheme = Theme.of(context).colorScheme;
final isTv = PlatformDetector.isTV();
return [
if (mediaRatingsFor(metadata).isNotEmpty)
MediaRatingBadgeGroup.chip(
item: metadata,
foregroundColor: colorScheme.onSecondaryContainer,
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
iconSize: isTv ? 20 : 16,
spacing: isTv ? 6 : 4,
entrySpacing: isTv ? 12 : 10,
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
),
if (!widget.isOffline) _buildUserRatingChip(metadata),
];
}
Widget _buildUserRatingChip(MediaItem metadata) {
final mediaClient = _getMediaClientForMetadata(context);
final isNumeric = mediaClient?.capabilities.numericUserRating ?? true;
final hasRating = metadata.userRating != null && metadata.userRating! > 0;
final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0;
final active = isNumeric ? hasRating : metadata.isFavorite == true;
final iconData = isNumeric ? Symbols.star_rounded : Symbols.favorite_rounded;
final activeIconColor = isNumeric ? Colors.amber : Colors.redAccent;
// Numeric backends show the formatted rating when set; favorite backends
// rely on the filled heart to communicate the favorite state and keep the
// "Rate" label as the action prompt either way.
final label = isNumeric && hasRating ? formatRating(starValue) : t.mediaMenu.rate;
return ListenableBuilder(
listenable: _ratingChipFocusNode,
builder: (context, _) {
void activate() => _showRatingDialog(context, metadata);
final colorScheme = Theme.of(context).colorScheme;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode;
final bgColor = showFocus ? colorScheme.inverseSurface : colorScheme.secondaryContainer.withValues(alpha: 0.8);
final fgColor = showFocus ? colorScheme.onInverseSurface : colorScheme.onSecondaryContainer;
return FocusableWrapper(
focusNode: _ratingChipFocusNode,
onSelect: activate,
borderRadius: 100,
disableScale: true,
useBackgroundFocus: true,
onKeyEvent: (_, event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isDownKey) {
_playButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled; // consume — nothing above
}
if (key.isLeftKey || key.isRightKey) {
return KeyEventResult.handled; // consume — single chip, nothing beside it (#1181)
}
return KeyEventResult.ignored;
},
child: GestureDetector(
onTap: activate,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: bgColor, borderRadius: const BorderRadius.all(Radius.circular(100))),
child: Row(
mainAxisSize: .min,
children: [
AppIcon(
iconData,
fill: active ? 1 : 0,
color: showFocus ? fgColor : (active ? activeIconColor : fgColor),
size: 16,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(color: fgColor, fontSize: 13, fontWeight: .w500),
),
],
),
),
),
);
},
);
}
void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) {
OverlaySheetController.of(sheetContext).show(
showDragHandle: true,
builder: (context) => RatingBottomSheet(
item: metadata,
serverClient: _getMediaClientForMetadata(this.context),
onServerRatingChanged: (rating) {
setStateIfMounted(() {
_fullMetadata = (_fullMetadata ?? widget.metadata).copyWith(userRating: rating);
});
},
onServerFavoriteChanged: (favorite) {
setStateIfMounted(() {
_fullMetadata = (_fullMetadata ?? widget.metadata).copyWith(isFavorite: favorite);
});
},
),
);
}
/// Backend-neutral counterpart of [getServerBoundPlexClient]. Returns a
/// [MediaServerClient] for Jellyfin items too, so image URLs use the
/// right server's transcoder.
MediaServerClient? _getMediaClientForMetadata(BuildContext context) {
return getServerBoundMediaClient(context);
}
MediaServerClient? _getArtworkMediaClient(BuildContext context) {
if (!widget.isOffline) return _getMediaClientForMetadata(context);
return context.tryGetMediaClientForServer(serverIdOrNull(_metadata.serverId));
}
Widget? _buildOfflineArtworkIfAvailable(
BuildContext context, {
required Iterable<String?> artworkPaths,
required BoxFit fit,
required ImageType imageType,
Alignment alignment = Alignment.center,
Widget Function(BuildContext, String, dynamic)? errorWidget,
Widget Function(BuildContext, String)? placeholder,
}) {
if (!widget.isOffline || _metadata.serverId == null) return null;
for (final artworkPath in artworkPaths) {
final localPath = _offlineArtworkCandidatePath(context, artworkPath);
if (localPath == null) continue;
return OptimizedMediaImage(
client: null,
imagePath: null,
localFilePath: localPath,
cacheMissingLocalFile: true,
fit: fit,
alignment: alignment,
imageType: imageType,
placeholder: placeholder,
errorWidget: errorWidget,
);
}
return null;
}
String? _offlineArtworkCandidatePath(BuildContext context, String? artworkPath) {
if (!widget.isOffline || _metadata.serverId == null) return null;
return context.read<DownloadProvider>().getArtworkLocalPath(ServerId(_metadata.serverId!), artworkPath);
}
String _syncRuleKeyForMetadata(BuildContext context, DownloadProvider downloadProvider, MediaItem metadata) {
final serverId = metadata.serverId;
final client = _getMediaClientForMetadata(context);
if (client == null || serverId == null) return metadata.globalKey;
return downloadProvider.syncRuleKeyForClient(client, metadata.id, serverId: ServerId(serverId));
}
void _navigateToActorMedia(MediaRole actor) {
final personId = actor.id;
if (personId == null || _metadata.serverId == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ActorMediaScreen(
actorName: actor.tag,
personId: personId,
actorThumb: actor.thumbPath,
characterName: actor.role,
serverId: _metadata.serverId!,
serverName: _metadata.serverName,
backend: _metadata.backend,
),
),
);
}
/// Resolve version selection for download using shared utility.
Future<DownloadVersionConfig?> _resolveDownloadVersion(
BuildContext context,
MediaItem metadata,
MediaServerClient client,
) {
final fallback = _fullMetadata?.mediaVersions;
return resolveDownloadVersion(context, metadata, client, fallbackVersions: fallback);
}
/// Shows actions for a synced item: edit count, remove rule, delete downloads.
Future<void> _showSyncRuleActions(
BuildContext context,
DownloadProvider downloadProvider,
MediaItem metadata, {
required String ruleKey,
required String downloadGlobalKey,
}) async {
final syncRule = downloadProvider.getSyncRule(ruleKey);
if (syncRule == null) return;
final selected = await showOptionPickerDialog<_SyncRuleAction>(
context,
title: t.downloads.manageSyncRule,
options: [
(icon: Symbols.edit_rounded, label: t.downloads.editSyncRule, value: _SyncRuleAction.edit),
(icon: Symbols.sync_disabled_rounded, label: t.downloads.removeSyncRule, value: _SyncRuleAction.remove),
(icon: Symbols.delete_rounded, label: t.downloads.deleteDownload, value: _SyncRuleAction.delete),
],
);
if (selected == null || !context.mounted) return;
switch (selected) {
case _SyncRuleAction.edit:
final updated = await editSyncRuleCount(
context,
downloadProvider: downloadProvider,
globalKey: ruleKey,
currentCount: syncRule.episodeCount,
displayTitle: metadata.displayTitle,
);
if (updated && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
}
case _SyncRuleAction.remove:
final removed = await confirmAndRemoveSyncRule(
context,
downloadProvider: downloadProvider,
globalKey: ruleKey,
displayTitle: metadata.displayTitle,
);
if (removed != null && context.mounted) {
showSuccessSnackBar(context, syncRuleRemovalMessage(removed));
}
case _SyncRuleAction.delete:
final confirmed = await showDeleteConfirmation(
context,
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
);
if (confirmed && context.mounted) {
await downloadProvider.deleteSyncRule(ruleKey);
await downloadProvider.deleteDownload(downloadGlobalKey);
if (context.mounted) {
showSuccessSnackBar(context, t.downloads.downloadDeleted);
}
}
}
}
Future<void> _loadFullMetadata() async {
setState(() {
_isLoadingMetadata = true;
_hasLoadedExtras = false;
_hasLoadedRelatedHubs = false;
});
// Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.)
if (widget.isOffline) {
final serverId = serverIdOrNull(_metadata.serverId);
final cachedMetadata = serverId == null
? null
: await context.read<DownloadProvider>().lookupOfflineMetadata(serverId, _metadata.id);
if (!mounted) return;
setState(() {
_fullMetadata = cachedMetadata ?? _metadata;
_isLoadingMetadata = false;
_hasLoadedExtras = true;
_hasLoadedRelatedHubs = true;
});
if (_metadata.isShow) {
_loadSeasonsFromDownloads();
// Get offline OnDeck episode
unawaited(_loadOfflineOnDeckEpisode());
} else if (_metadata.isSeason) {
_seasons = [_metadata];
_showEpisodesDirectly = true;
_loadEpisodesFromDownloads();
}
return;
}
try {
// Backend-neutral lookup. Plex returns the OnDeck episode bundled in
// the same response (`?includeOnDeck=1`); Jellyfin's
// [fetchItemWithOnDeck] returns onDeckEpisode=null and the UI
// populates resume separately if needed.
final client = getServerBoundMediaClient(context);
if (client == null) {
// Truly orphaned item (server gone) — fall back to widget metadata
// and let downstream loaders no-op gracefully.
setState(() {
_fullMetadata = _metadata;
_isLoadingMetadata = false;
_hasLoadedSeasons = true;
_hasLoadedEpisodes = true;
_hasLoadedExtras = true;
_hasLoadedRelatedHubs = true;
});
return;
}
// Normalises a freshly fetched item against the row we navigated from
// (which owns serverId/library) and paints it. Called once from
// [onItemReady] on backends that learn the item before on-deck, and once
// from the settled result.
//
// [onDeckSettled] separates "on-deck not looked up yet" from "on-deck
// looked up and there is none". Only the settled call may write it, so
// the early paint leaves whatever is on screen alone — this method runs
// again after playback, and clearing there would blank the play button
// for the length of the on-deck round trip — while a reload that finds
// the series finished still clears it.
MediaItem publish(MediaItem source, {MediaItem? onDeckEpisode, bool onDeckSettled = false}) {
final serverId = _metadata.serverId;
final serverName = _metadata.serverName;
final base = _withFallbackLibrary(
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
_metadata,
);
final onDeckWithServerId = onDeckEpisode == null
? null
: _withFallbackLibrary(
onDeckEpisode.copyWith(
serverId: serverId ?? onDeckEpisode.serverId,
serverName: serverName ?? onDeckEpisode.serverName,
),
base,
);
setState(() {
_fullMetadata = base;
if (onDeckSettled) _onDeckEpisode = onDeckWithServerId;
_isLoadingMetadata = false;
});
return base;
}
// Jellyfin needs a second round trip for on-deck, which the screen does
// not need in order to paint. Publishing the item as soon as it lands
// takes that round trip off the critical path (#1784).
//
// Seasons/extras deliberately still start after the whole lookup
// settles: starting them at the early paint measured *worse*, because
// they contend with the on-deck request rather than overlapping it.
final result = await client.fetchItemWithOnDeck(
_metadata.id,
onItemReady: (item) {
if (mounted) publish(item);
},
);
final metadata = result.item;
if (!mounted) return;
final base = publish(metadata ?? _metadata, onDeckEpisode: result.onDeckEpisode, onDeckSettled: true);
if (base.isShow) {
unawaited(_loadSeasons());
} else if (base.isSeason) {
_seasons = [base];
_showEpisodesDirectly = true;
unawaited(_fetchAllEpisodes());
}
// [_loadExtras] and [_loadRelatedHubs] short-circuit for non-Plex
// backends; safe to call unconditionally.
unawaited(_loadExtras());
unawaited(_loadRelatedHubs());
} catch (e) {
// Fallback to passed metadata on error
if (!mounted) return;
setState(() {
_fullMetadata = _metadata;
_isLoadingMetadata = false;
_hasLoadedExtras = true;
_hasLoadedRelatedHubs = true;
});
if (_metadata.isShow) {
unawaited(_loadSeasons());
} else if (_metadata.isSeason) {
_seasons = [_metadata];
_showEpisodesDirectly = true;
unawaited(_fetchAllEpisodes());
}
}
}
Future<void> _loadSeasons() async {
_seasonsCompleter = Completer<void>();
setStateIfMounted(() {
_isLoadingSeasons = true;
_seasonsLoadFailed = false;
_hasLoadedSeasons = false;
});
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
if (client == null) {
setStateIfMounted(() {
_isLoadingSeasons = false;
_hasLoadedSeasons = true;
});
if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete();
return;
}
try {
// Plex has a server-side "flatten seasons" preference;
// Jellyfin has no equivalent, so fetch the prefs only when we have
// a Plex client and a section id. The library section id came from
// Plex as an int but lands in [MediaItem.libraryId] as the string
// form (or null on Jellyfin items).
final sectionId = (_fullMetadata ?? _metadata).libraryId;
final seasonsFuture = client.fetchChildren(_metadata.id);
// Prefs are a per-library nicety (Plex "flatten seasons"); a failure here
// must never take down the seasons list, so degrade to defaults.
final prefsFuture = (client is PlexClient && sectionId != null)
? client.getLibrarySectionPrefs(sectionId).catchError((_) => <String, dynamic>{})
: Future.value(<String, dynamic>{});
final results = await Future.wait([seasonsFuture, prefsFuture]);
final seasons = results.first as List<MediaItem>;
final prefs = results[1] as Map<String, dynamic>;
// Preserve serverId for each season.
final seasonsWithServerId = seasons
.map(
(season) => _withFallbackLibrary(
season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName),
_metadata,
),
)
.toList();
// Plex can override the library season mode per show; Jellyfin falls
// through to "flatten when there's a single season".
bool shouldShowEpisodesDirectly;
if (client is PlexClient) {
shouldShowEpisodesDirectly = shouldShowPlexEpisodesDirectly(
show: _metadata,
seasons: seasonsWithServerId,
libraryPrefs: prefs,
);
} else {
shouldShowEpisodesDirectly = seasonsWithServerId.length <= 1;
}
// Create focus nodes for season tabs
_updateSeasonTabFocusNodes(seasonsWithServerId.length);
// Auto-select the on-deck season
final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasonsWithServerId);
setStateIfMounted(() {
_seasons = seasonsWithServerId;
_isLoadingSeasons = false;
_hasLoadedSeasons = true;
_showEpisodesDirectly = shouldShowEpisodesDirectly;
_selectedSeasonIndex = onDeckSeasonIndex;
});
if (shouldShowEpisodesDirectly) {
await _fetchAllEpisodes();
_ensureFallbackOnDeckEpisode();
} else if (seasonsWithServerId.isNotEmpty) {
// Load only the on-deck season's first page; other seasons load lazily
// when focused (strict on-demand — no whole-show pre-warm). Once the
// on-deck season is loaded, synthesize on-deck if the backend omitted it.
final fetchOnDeckSeason = _fetchSeasonEpisodes(onDeckSeasonIndex).then((_) => _ensureFallbackOnDeckEpisode());
if (PlatformDetector.isTV()) {
await fetchOnDeckSeason;
} else {
unawaited(fetchOnDeckSeason);
}
}
} catch (e, st) {
appLogger.w('Seasons load failed', error: e, stackTrace: st);
setStateIfMounted(() {
_isLoadingSeasons = false;
_seasonsLoadFailed = true;
_hasLoadedSeasons = true;
});
} finally {
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
}
/// Load seasons from downloaded episodes (offline mode)
void _loadSeasonsFromDownloads() {
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
});
final downloadProvider = context.read<DownloadProvider>();
final episodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id);
// Group episodes by season
final Map<int, List<MediaItem>> seasonMap = {};
for (final episode in episodes) {
final seasonNum = episode.parentIndex ?? 0;
seasonMap.putIfAbsent(seasonNum, () => []).add(episode);
}
// Create synthetic season MediaItems from the grouped episodes.
final seasons = seasonMap.entries.map((entry) {
final firstEp = entry.value.first;
final seasonId = firstEp.parentId ?? '';
final seasonGlobalKey = _metadata.serverId == null || seasonId.isEmpty
? null
: buildGlobalKey(ServerId(_metadata.serverId!), seasonId);
final storedSeason = seasonGlobalKey == null ? null : downloadProvider.getMetadata(seasonGlobalKey);
if (storedSeason != null && storedSeason.isSeason) {
return _withFallbackLibrary(
storedSeason.copyWith(
serverId: _metadata.serverId,
serverName: _metadata.serverName ?? storedSeason.serverName,
leafCount: storedSeason.leafCount ?? entry.value.length,
),
_metadata,
);
}
return MediaItem(
id: seasonId,
backend: _metadata.backend,
kind: MediaKind.season,
title: firstEp.parentTitle?.isNotEmpty == true ? firstEp.parentTitle : t.common.seasonNumber(number: entry.key),
index: entry.key,
leafCount: entry.value.length,
thumbPath: firstEp.parentThumbPath,
parentId: firstEp.grandparentId,
libraryId: firstEp.libraryId ?? _metadata.libraryId,
libraryTitle: firstEp.libraryTitle ?? _metadata.libraryTitle,
serverId: _metadata.serverId,
serverName: _metadata.serverName,
);
}).toList()..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
// Create focus nodes for season tabs and cache episodes per season
_updateSeasonTabFocusNodes(seasons.length);
for (final entry in seasonMap.entries) {
final seasonRatingKey = entry.value.first.parentId ?? '';
final sortedEpisodes = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
_seasonEpisodePager.completeFirstPage(seasonRatingKey, sortedEpisodes, sortedEpisodes.length);
}
final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasons);
setState(() {
_seasons = seasons;
_isLoadingSeasons = false;
_hasLoadedSeasons = true;
_selectedSeasonIndex = onDeckSeasonIndex;
});
// Load episodes for the selected season from cache
if (seasons.isNotEmpty) {
_fetchSeasonEpisodes(onDeckSeasonIndex);
}
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
/// Load episodes from downloaded content for a season
void _loadEpisodesFromDownloads() {
final downloadProvider = context.read<DownloadProvider>();
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.parentId ?? '');
final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == _metadata.index).toList()
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
setState(() {
_allEpisodes = _allEpisodes.completeInitialLoad(seasonEpisodes, seasonEpisodes.length);
_episodes = _allEpisodes.items;
_hasLoadedEpisodes = true;
});
}
/// Create or update focus nodes for season tab chips
void _updateSeasonTabFocusNodes(int count) {
if (_seasonTabFocusNodes.length != count) {
for (final node in _seasonTabFocusNodes) {
node.dispose();
}
_seasonTabFocusNodes = List.generate(count, (i) => FocusNode(debugLabel: 'season_tab_$i'));
_seasonContextMenuKeys.clear();
}
}
/// Find the season index matching the initial selection or on-deck episode,
/// then fall back to the same season the Play button would use.
int _findOnDeckSeasonIndex(List<MediaItem> seasons) {
return preferredSeasonIndex(
seasons,
initialSeasonId: widget.initialSeasonId,
initialSeasonIndex: widget.initialSeasonIndex,
onDeckEpisode: _onDeckEpisode,
);
}
/// Fetch episodes for a specific season by index, using cache when available
/// Load (or restore) the FIRST page of a season's episodes. The rest loads
/// lazily via [_loadMoreSeasonEpisodes] as the user scrolls/navigates toward
/// the end, so a 1000+ episode season never blocks on one giant request.
Future<void> _fetchSeasonEpisodes(int seasonIndex) async {
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final seasonId = season.id;
// Restore from cache (which may hold only a partial page — more loads on
// demand) and clear any prior per-season loading/error state.
final cached = _seasonEpisodePager.stateFor(seasonId);
if (_seasonEpisodePager.hasState(seasonId) && !cached.isInitialLoading && !cached.initialLoadFailed) {
_seasonEpisodePager.completeFirstPage(seasonId, cached.items, cached.totalCount);
setStateIfMounted(() {
if (_isSelectedSeason(seasonIndex, seasonId)) {
_episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items);
}
});
unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
return;
}
if (!_seasonEpisodePager.beginFirstPageLoad(seasonId)) {
setStateIfMounted(() {
if (_isSelectedSeason(seasonIndex, seasonId)) {
_seasonEpisodePager.markFirstPageLoading(seasonId);
}
});
return;
}
final generation = ++_episodesLoadGeneration;
setStateIfMounted(() {
if (_isSelectedSeason(seasonIndex, seasonId)) {
_seasonEpisodePager.markFirstPageLoading(seasonId);
}
});
try {
if (widget.isOffline) {
// Offline: load from downloads (already the complete set).
final downloadProvider = context.read<DownloadProvider>();
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id);
final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList()
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
_completeSeasonEpisodesLoad(
seasonIndex: seasonIndex,
seasonId: seasonId,
episodes: seasonEpisodes,
total: seasonEpisodes.length,
generation: generation,
);
} else {
// Resolve the right backend client so Jellyfin (where the typed
// PlexClient helper returns null) loads episodes too.
final mediaClient = _getMediaClientForMetadata(context);
if (mediaClient == null) {
_completeSeasonEpisodesLoad(
seasonIndex: seasonIndex,
seasonId: seasonId,
episodes: const <MediaItem>[],
total: 0,
generation: generation,
);
return;
}
final page = await fetchSeasonEpisodePage(
mediaClient,
show: _metadata,
season: season,
start: 0,
size: _episodesPageSize,
);
if (!mounted || generation != _episodesLoadGeneration) return;
_completeSeasonEpisodesLoad(
seasonIndex: seasonIndex,
seasonId: seasonId,
episodes: page.items,
total: page.totalCount,
generation: generation,
);
}
} catch (e, st) {
appLogger.w('Season episodes load failed', error: e, stackTrace: st);
if (mounted && generation == _episodesLoadGeneration && _isSelectedSeason(seasonIndex, seasonId)) {
setStateIfMounted(() {
_seasonEpisodePager.failFirstPage(seasonId);
});
}
} finally {
_seasonEpisodePager.endFirstPageLoad(seasonId);
}
}
/// TV-only quality-of-life prefetch: warm just the first page of the adjacent
/// seasons so horizontal season rail changes feel instant without reverting to
/// whole-show or whole-season eager loading.
Future<void> _prefetchAdjacentSeasonEpisodePages(int centerIndex) async {
if (!PlatformDetector.isTV() || widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return;
for (final index in [centerIndex - 1, centerIndex + 1]) {
if (index < 0 || index >= _seasons.length) continue;
unawaited(_prefetchSeasonEpisodeFirstPage(index));
}
}
Future<void> _prefetchSeasonEpisodeFirstPage(int seasonIndex) async {
if (seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final seasonId = season.id;
if (_seasonEpisodePager.hasState(seasonId)) return;
if (!_seasonEpisodePager.beginFirstPageLoad(seasonId)) return;
try {
final mediaClient = _getMediaClientForMetadata(context);
if (mediaClient == null) return;
final page = await fetchSeasonEpisodePage(
mediaClient,
show: _metadata,
season: season,
start: 0,
size: _episodesPageSize,
);
if (!mounted || _showEpisodesDirectly || seasonIndex >= _seasons.length || _seasons[seasonIndex].id != seasonId) {
return;
}
setStateIfMounted(() {
final current = _seasonEpisodePager.stateFor(seasonId);
if (_seasonEpisodePager.hasState(seasonId) && !(current.isInitialLoading && !current.hasItems)) return;
_seasonEpisodePager.completeFirstPage(seasonId, page.items, page.totalCount);
if (_isSelectedSeason(seasonIndex, seasonId)) {
_episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items);
}
});
if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
} catch (e, st) {
appLogger.d('TV adjacent season episode prefetch failed', error: e, stackTrace: st);
if (mounted && _isSelectedSeason(seasonIndex, seasonId)) {
setStateIfMounted(() {
_seasonEpisodePager.failFirstPage(seasonId);
});
}
} finally {
_seasonEpisodePager.endFirstPageLoad(seasonId);
}
}
/// Load the next page of the selected season's episodes and append it.
/// No-op when nothing more remains, offline, or a page is already in flight.
Future<void> _loadMoreSeasonEpisodes() async {
final seasonIndex = _selectedSeasonIndex;
if (widget.isOffline || seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final seasonId = season.id;
final state = _seasonEpisodePager.stateFor(seasonId);
final loaded = state.items.length;
if (!state.hasMore) return;
if (!_seasonEpisodePager.beginMoreLoad(seasonId)) return;
final generation = _episodesLoadGeneration;
setStateIfMounted(() {
if (_isSelectedSeason(seasonIndex, seasonId)) {
_seasonEpisodePager.markMoreLoading(seasonId);
}
});
try {
final mediaClient = _getMediaClientForMetadata(context);
if (mediaClient == null) {
setStateIfMounted(() {
if (_isSelectedSeason(seasonIndex, seasonId)) {
_seasonEpisodePager.completeMoreLoad(seasonId, expectedOffset: loaded, episodes: const [], total: loaded);
}
});
return;
}
final page = await fetchSeasonEpisodePage(
mediaClient,
show: _metadata,
season: season,
start: loaded,
size: _episodesPageSize,
);
if (!mounted || generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
_seasonEpisodePager.completeMoreLoad(
seasonId,
expectedOffset: loaded,
episodes: page.items,
total: page.totalCount,
);
if (_isSelectedSeason(seasonIndex, seasonId)) _episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items);
});
} catch (e, st) {
appLogger.w('Season episodes page load failed', error: e, stackTrace: st);
if (mounted && generation == _episodesLoadGeneration && _isSelectedSeason(seasonIndex, seasonId)) {
setStateIfMounted(() {
_seasonEpisodePager.failMoreLoad(seasonId);
});
}
} finally {
_seasonEpisodePager.endMoreLoad(seasonId);
}
}
/// Whether the selected season has more episodes to page in.
bool get _selectedSeasonHasMore {
if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false;
return _selectedSeasonEpisodeState.hasMore;
}
bool _isSelectedSeason(int seasonIndex, String seasonId) {
return _selectedSeasonIndex == seasonIndex &&
seasonIndex >= 0 &&
seasonIndex < _seasons.length &&
_seasons[seasonIndex].id == seasonId;
}
void _completeSeasonEpisodesLoad({
required int seasonIndex,
required String seasonId,
required List<MediaItem> episodes,
required int total,
required int generation,
}) {
if (generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
_seasonEpisodePager.completeFirstPage(seasonId, episodes, total);
if (_isSelectedSeason(seasonIndex, seasonId)) {
_episodes = List.of(_seasonEpisodePager.stateFor(seasonId).items);
}
});
if (_isSelectedSeason(seasonIndex, seasonId)) unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
}
/// Load extras (trailers, featurettes, behind-the-scenes, etc.).
Future<void> _loadExtras() async {
void markLoaded() {
setStateIfMounted(() {
_hasLoadedExtras = true;
});
}
// Only load extras for movies and shows
if (!_metadata.isMovie && !_metadata.isShow) {
markLoaded();
return;
}
// Skip in offline mode (no server available)
if (widget.isOffline) {
markLoaded();
return;
}
try {
final client = getServerBoundMediaClient(context);
if (client == null) {
markLoaded();
return;
}
final extras = await client.fetchExtras(_metadata.id);
// Preserve serverId for each extra (needed for multi-server setups).
final extrasWithServerId = extras
.map(
(extra) => extra.copyWith(
serverId: _metadata.serverId ?? extra.serverId,
serverName: _metadata.serverName ?? extra.serverName,
),
)
.toList();
setStateIfMounted(() {
_extras = extrasWithServerId;
_hasLoadedExtras = true;
});
} catch (e) {
// Silently fail - extras section won't appear if fetch fails
markLoaded();
}
}
/// Load related hubs (collections, similar, "more from" director/actor).
/// Backend-neutral — both Plex and Jellyfin implement
/// [MediaServerClient.fetchRelatedHubs].
Future<void> _loadRelatedHubs() async {
void markLoaded() {
setStateIfMounted(() {
_hasLoadedRelatedHubs = true;
});
}
if (!_metadata.isMovie && !_metadata.isShow) {
markLoaded();
return;
}
if (widget.isOffline) {
markLoaded();
return;
}
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
if (client == null) {
markLoaded();
return;
}
try {
final relatedHubs = await client.fetchRelatedHubs(_metadata.id);
setStateIfMounted(() {
_relatedHubs = relatedHubs;
_relatedHubKeys = List.generate(relatedHubs.length, (_) => GlobalKey<HubSectionState>());
_hasLoadedRelatedHubs = true;
});
} catch (e) {
// Silently fail - related sections won't appear if fetch fails
markLoaded();
}
}
/// Focus the first visible section above cast: season tabs → overview → play button.
/// Shared by cast UP, extras UP, and related hub UP handlers.
void _focusSectionAboveCast() {
final metadata = _fullMetadata ?? _metadata;
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus();
}
}
/// Focus the first visible section above extras: cast → season tabs → overview → play button.
void _focusSectionAboveExtras() {
final metadata = _fullMetadata ?? _metadata;
if (metadata.roles != null && metadata.roles!.isNotEmpty) {
_castStripKey.currentState?.requestFocus();
_scrollSectionIntoView(_castSectionKey);
} else {
_focusSectionAboveCast();
}
}
bool get _hasInfoRows {
final metadata = _fullMetadata ?? _metadata;
return metadata.studio != null || metadata.directors?.isNotEmpty == true || metadata.contentRating != null;
}
/// Focus the trailing info rows (studio / directors / contentRating) and scroll them into view.
void _focusInfoRows() {
_infoRowsFocusNode.requestFocus();
_scrollSectionIntoView(_infoRowsSectionKey);
}
/// Focus the first visible focusable section above info rows: related hubs → extras → cast → …
void _focusSectionAboveInfoRows() {
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.last.currentState?.requestFocusFromMemory();
} else if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else {
_focusSectionAboveExtras();
}
}
/// Scroll the main scroll view so the section with the given key is centered
void _scrollSectionIntoView(GlobalKey key) {
scrollContextToCenter(key.currentContext);
}
bool get _hasInitialDetailFocusTarget =>
widget.initialEpisodeId != null || widget.initialSeasonId != null || widget.initialSeasonIndex != null;
bool get _episodesContainInitialTarget {
final targetEpisodeId = widget.initialEpisodeId;
return targetEpisodeId != null && _episodes.any((episode) => episode.id == targetEpisodeId);
}
void _scheduleInitialMobileDetailFocus(MediaItem metadata) {
if (_initialDetailFocusApplied || PlatformDetector.isTV() || !_hasInitialDetailFocusTarget) return;
if (widget.initialEpisodeId != null) {
if (_episodesContainInitialTarget) {
_applyInitialMobileFocus(_initialTargetEpisodeFocusNode(), _seasonsSectionKey);
return;
}
_maybeLoadMoreForInitialEpisode();
// Target never materialized (stale/deleted id) — fall back to the
// season anchors below instead of retrying forever.
if (!_initialEpisodePagingDone) return;
}
if (metadata.isShow &&
!_showEpisodesDirectly &&
_seasons.isNotEmpty &&
_seasonTabFocusNodes.length > _selectedSeasonIndex) {
_applyInitialMobileFocus(_seasonTabFocusNodes[_selectedSeasonIndex], _seasonsSectionKey);
return;
}
if (((metadata.isShow && _showEpisodesDirectly) || metadata.isSeason) && _episodes.isNotEmpty) {
_applyInitialMobileFocus(_firstEpisodeFocusNode, _seasonsSectionKey);
}
}
void _applyInitialMobileFocus(FocusNode node, GlobalKey sectionKey) {
_initialDetailFocusApplied = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
node.requestFocus();
_scrollSectionIntoView(sectionKey);
});
}
void _maybeLoadMoreForInitialEpisode() {
if (_initialEpisodePagingDone || _initialEpisodePagingInFlight) return;
if (_episodesContainInitialTarget) {
_initialEpisodePagingDone = true;
return;
}
final hasMore = _isFlattenEpisodeList ? _allEpisodes.hasMore : _selectedSeasonHasMore;
final pageError = _isFlattenEpisodeList ? _allEpisodesPageError : _seasonEpisodesPageError;
if (!hasMore || pageError) {
// All pages loaded (or paging failed) without the target appearing.
_initialEpisodePagingDone = true;
return;
}
if (_isFlattenEpisodeList ? _isLoadingAllEpisodes : _isLoadingMoreSeasonEpisodes) return;
_initialEpisodePagingInFlight = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _episodesContainInitialTarget) {
_initialEpisodePagingInFlight = false;
return;
}
Future<void>? load;
if (_isFlattenEpisodeList) {
if (_allEpisodes.hasMore && !_isLoadingAllEpisodes && !_allEpisodesPageError) {
load = _loadMoreAllEpisodes();
}
} else if (_selectedSeasonHasMore && !_isLoadingMoreSeasonEpisodes && !_seasonEpisodesPageError) {
load = _loadMoreSeasonEpisodes();
}
if (load == null) {
_initialEpisodePagingInFlight = false;
return;
}
// The loaders setState on success and failure, so the resulting rebuild
// re-enters this method for the next page (or marks paging done).
unawaited(load.whenComplete(() => _initialEpisodePagingInFlight = false));
});
}
/// Focus the first available section above the primary action row.
void _focusAboveActionRow() {
if (PlatformDetector.isTV()) return;
if (!widget.isOffline) _ratingChipFocusNode.requestFocus();
}
/// Focus the overview, or the first available content section when there is no overview.
void _focusBelowActionRow() {
final metadata = _fullMetadata ?? _metadata;
if (PlatformDetector.isTV()) {
_tvDetailRailKey.currentState?.requestFocus();
return;
}
if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
return;
}
_focusBelowOverview();
}
/// Focus the first available content section after the overview.
void _focusBelowOverview() {
final metadata = _fullMetadata ?? _metadata;
// DOWN order: season tabs → episodes → cast → extras → related hubs → info rows.
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
// Focus the selected season tab chip
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
return;
}
if (_episodes.isNotEmpty) {
_firstEpisodeFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
return;
}
if (metadata.roles != null && metadata.roles!.isNotEmpty) {
_castStripKey.currentState?.requestFocus();
_scrollSectionIntoView(_castSectionKey);
return;
}
if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
return;
}
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
return;
}
if (_hasInfoRows) {
_focusInfoRows();
}
}
/// Get the responsive card width used by seasons/extras/cast rows.
/// Delegates to the cast strip's calculator so the dpad scroll math and
/// the rendered cards can never disagree.
double _getResponsiveCardWidth() => CastMemberStrip.responsiveCardWidth(context);
/// Show context menu for a season tab
void _showSeasonTabContextMenu(int index, {Offset? position}) {
final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey<MediaContextMenuState>());
key.currentState?.showContextMenu(context, position: position);
}
/// Focus the currently selected season tab
void _focusSelectedSeasonTab() {
if (_seasonTabFocusNodes.length > _selectedSeasonIndex) {
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
}
}
/// Scroll a season tab into view within the horizontal scroll
void _scrollSeasonTabIntoView(int index) {
if (index < 0 || index >= _seasonTabFocusNodes.length) return;
scrollContextToCenter(_seasonTabFocusNodes[index].context);
}
/// Build inline season tab chips with LEFT/RIGHT/DOWN focus navigation
Widget _buildSeasonTabs() {
return SettingValueBuilder<bool>(
pref: SettingsService.showSeasonPostersOnTabs,
builder: (context, showPosters, _) => _buildSeasonTabsContent(context, showPosters),
);
}
Widget _buildSeasonTabsContent(BuildContext context, bool showPosters) {
final showSeasonPosters = showPosters && !PlatformDetector.isTV();
return HorizontalScrollWithArrows(
controller: _seasonTabsScrollController,
builder: (scrollController) => SingleChildScrollView(
controller: scrollController,
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_seasons.length, (index) {
final season = _seasons[index];
final contextMenuKey = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey<MediaContextMenuState>());
Offset? tapPosition;
final posterPath = season.thumbPath;
Widget? topImage;
if (showSeasonPosters && posterPath != null && posterPath.isNotEmpty) {
const posterWidth = 72.0;
const posterHeight = 108.0;
final localArtwork = _buildOfflineArtworkIfAvailable(
context,
artworkPaths: [posterPath],
fit: BoxFit.cover,
imageType: ImageType.poster,
placeholder: (context, url) => const PlaceholderContainer(),
errorWidget: (context, url, error) => const PlaceholderContainer(),
);
topImage = SizedBox(
width: posterWidth,
height: posterHeight,
child:
localArtwork ??
(widget.isOffline
? const PlaceholderContainer()
: Builder(
builder: (context) {
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final client = _getMediaClientForMetadata(context);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: posterPath,
maxWidth: posterWidth,
maxHeight: posterHeight,
devicePixelRatio: dpr,
imageType: ImageType.poster,
);
final (memWidth, _) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (posterWidth * dpr).round(),
displayHeight: (posterHeight * dpr).round(),
imageType: ImageType.poster,
);
return CachedNetworkImage(
imageUrl: imageUrl,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
memCacheWidth: memWidth,
placeholder: (context, url) => const PlaceholderContainer(),
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(),
);
},
)),
);
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: MediaContextMenu(
key: contextMenuKey,
item: season,
onRefresh: (source) {
_watchStateChanged = true;
unawaited(_refreshItemInPlace(source));
},
onListRefresh: () {
if (widget.isOffline) {
_loadSeasonsFromDownloads();
} else {
_loadSeasons();
}
},
child: GestureDetector(
onTapDown: (details) => tapPosition = details.globalPosition,
onLongPress: () => _showSeasonTabContextMenu(index, position: tapPosition),
onSecondaryTapDown: (details) => tapPosition = details.globalPosition,
onSecondaryTap: () => _showSeasonTabContextMenu(index, position: tapPosition),
child: FocusableTabChip(
label: season.title!,
isSelected: index == _selectedSeasonIndex,
topImage: topImage,
focusNode: _seasonTabFocusNodes.length > index ? _seasonTabFocusNodes[index] : null,
onSelect: () {
if (index == _selectedSeasonIndex) return;
setState(() => _selectedSeasonIndex = index);
_fetchSeasonEpisodes(index);
},
onNavigateLeft: index > 0
? () {
final newIndex = index - 1;
setState(() => _selectedSeasonIndex = newIndex);
_seasonTabFocusNodes[newIndex].requestFocus();
_scrollSeasonTabIntoView(newIndex);
_fetchSeasonEpisodes(newIndex);
}
: null,
onNavigateRight: index < _seasons.length - 1
? () {
final newIndex = index + 1;
setState(() => _selectedSeasonIndex = newIndex);
_seasonTabFocusNodes[newIndex].requestFocus();
_scrollSeasonTabIntoView(newIndex);
_fetchSeasonEpisodes(newIndex);
}
: null,
onNavigateDown: () {
if (PlatformDetector.isTV()) {
_tvDetailRailKey.currentState?.requestFocus();
return;
}
_firstEpisodeFocusNode.requestFocus();
},
onLongPress: () => _showSeasonTabContextMenu(index),
onBack: () {
Navigator.of(context).maybePop();
},
),
),
),
);
}),
),
),
);
}
/// Handle key events for the extras row (locked focus pattern)
KeyEventResult _handleExtrasKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) return KeyEventResult.ignored;
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
if (event is KeyUpEvent && key.isSelectKey) {
_extrasSelectLongPress.reset();
}
return KeyEventResult.handled;
}
final extras = _extras;
if (extras == null || extras.isEmpty) {
_extrasSelectLongPress.reset();
return KeyEventResult.ignored;
}
final selectResult = _extrasSelectLongPress.handleKeyEvent(
event,
isOwnerActive: () => mounted,
onShortPress: () {
if (_focusedExtraIndex < extras.length) {
navigateToVideoPlayer(context, metadata: extras[_focusedExtraIndex]);
}
},
onLongPress: () {
if (_focusedExtraIndex < _extraCardKeys.length) {
_extraCardKeys[_focusedExtraIndex]?.currentState?.showContextMenu();
}
},
);
if (selectResult != KeyEventResult.ignored) return selectResult;
if (!event.isActionable) return KeyEventResult.ignored;
// LEFT: previous extra
if (key.isLeftKey) {
if (_focusedExtraIndex > 0) {
_focusedExtraIndex--;
_focusedExtraIndexNotifier.value = _focusedExtraIndex;
scrollListToIndex(
_extrasScrollController,
_focusedExtraIndex,
itemExtent: _getResponsiveCardWidth() + 4,
leadingPadding: 0,
);
}
return KeyEventResult.handled;
}
// RIGHT: next extra
if (key.isRightKey) {
if (_focusedExtraIndex < _extras!.length - 1) {
_focusedExtraIndex++;
_focusedExtraIndexNotifier.value = _focusedExtraIndex;
scrollListToIndex(
_extrasScrollController,
_focusedExtraIndex,
itemExtent: _getResponsiveCardWidth() + 4,
leadingPadding: 0,
);
}
return KeyEventResult.handled;
}
if (key.isUpKey) {
_focusSectionAboveExtras();
return KeyEventResult.handled;
}
// DOWN: related hubs → info rows → consume
if (key.isDownKey) {
if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _handleExtrasFocusChange() {
if (!_extrasFocusNode.hasFocus) _resetExtrasLongPressState();
}
void _resetExtrasLongPressState() {
_extrasSelectLongPress.reset();
}
void _focusSectionDirectlyAboveCast() {
if (_episodes.isNotEmpty) {
final useLastEpisode = _episodes.length > 1;
if (useLastEpisode) _suppressNextLastEpisodeFocusLoad = true;
final target = useLastEpisode ? _lastEpisodeFocusNode : _firstEpisodeFocusNode;
target.requestFocus();
} else {
_focusSectionAboveCast();
}
}
/// Focus the first visible section below cast.
void _focusSectionBelowCast() {
if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else if (_relatedHubs.isNotEmpty) {
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
} else if (_hasInfoRows) {
_focusInfoRows();
}
}
/// Handle vertical navigation between related hub sections
bool _handleRelatedHubNavigation(int hubIndex, bool isUp) {
return navigateVerticalHubRows(
hubCount: _relatedHubKeys.length,
hubIndex: hubIndex,
isUp: isUp,
onTopBoundary: () {
if (_extras != null && _extras!.isNotEmpty) {
_extrasFocusNode.requestFocus();
_scrollSectionIntoView(_extrasSectionKey);
} else {
_focusSectionAboveExtras();
}
},
onBottomBoundary: _hasInfoRows ? _focusInfoRows : null,
requestFocus: (targetIndex) {
_relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory();
},
);
}
/// Handle key events for the trailing info rows (studio / contentRating).
/// UP returns to the previous focusable section; terminal geometry is trapped.
KeyEventResult _handleInfoRowsKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (key.isBackKey) return KeyEventResult.ignored;
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey) {
_focusSectionAboveInfoRows();
return KeyEventResult.handled;
}
if (key.isDownKey || key.isLeftKey || key.isRightKey || key.isSelectKey) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
IconData _getRelatedHubIcon(MediaHub hub) {
final lower = hub.title.toLowerCase();
if (lower.contains('collection')) return Symbols.video_library_rounded;
if (lower.contains('similar')) return Symbols.auto_awesome_rounded;
if (lower.contains('more from') || lower.contains('more with')) return Symbols.person_rounded;
if (lower.contains('genre') || lower.contains('director')) return Symbols.movie_rounded;
return Symbols.recommend_rounded;
}
static const Widget _sectionLoading = Center(
child: Padding(padding: .all(32), child: CircularProgressIndicator()),
);
Widget _sectionEmpty(BuildContext context, String message) {
return Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: tokens(context).textMuted)),
),
);
}
/// Retryable error for a section whose fetch threw (vs. [_sectionEmpty], which
/// means a successful-but-empty result). Reuses the app-wide [ErrorStateWidget]
/// so the Retry button is dpad-focusable.
Widget _sectionError(String message, VoidCallback onRetry) {
return Padding(
padding: const EdgeInsets.all(16),
child: ErrorStateWidget(
message: message,
icon: Symbols.error_outline_rounded,
onRetry: onRetry,
retryLabel: t.common.retry,
),
);
}
/// Build episode list directly when the library hides seasons for single-season shows
Widget _buildEpisodesList() {
final client = _getMediaClientForMetadata(context);
final hasPinnedLastEpisode = _hasPinnedLastEpisodeInList;
return ListView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: .zero,
itemCount: _episodes.length + (_episodeListHasMore || _episodeListLoadingMore || _episodeListPageError ? 1 : 0),
itemBuilder: (context, index) {
if (index == _episodes.length) {
return _buildEpisodeListTail();
}
final episode = _episodes[index];
String? localPosterPath;
if (widget.isOffline && episode.serverId != null) {
final artworkRef = context.read<DownloadProvider>().getArtworkPaths(episode.globalKey);
localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, ServerId(episode.serverId!));
}
return EpisodeCard(
episode: episode,
client: client,
isOffline: widget.isOffline,
autofocus: false,
focusNode: _episodeListFocusNode(episode: episode, index: index, hasPinnedLastEpisode: hasPinnedLastEpisode),
onNavigateUp: index == 0
? () {
if (!_showEpisodesDirectly) {
_focusSelectedSeasonTab();
} else if (!PlatformDetector.isTV() && (_fullMetadata ?? _metadata).summary?.isNotEmpty == true) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
_playButtonFocusNode.requestFocus();
}
}
: null,
localPosterPath: localPosterPath,
onTap: () async {
await navigateToVideoPlayerWithRefresh(
context,
metadata: episode,
isOffline: widget.isOffline,
onRefresh: () => unawaited(_refreshItemInPlace(episode)),
);
},
onRefresh: widget.isOffline ? null : _refreshItemInPlace,
onListRefresh: widget.isOffline ? null : _refreshCurrentEpisodes,
);
},
);
}
bool _shouldUseLastEpisodeFocusNode({
required MediaItem episode,
required int index,
required bool hasPinnedLastEpisode,
}) {
if (_episodes.length <= 1) return false;
if (hasPinnedLastEpisode) return episode.globalKey == _lastEpisodeFocusPinnedKey;
return index == _episodes.length - 1;
}
bool get _hasPinnedLastEpisodeInList {
final pinnedKey = _lastEpisodeFocusPinnedKey;
return pinnedKey != null && _episodes.any((episode) => episode.globalKey == pinnedKey);
}
/// Resolve the focus node for an inline episode list item. Role nodes
/// (first/last) take priority so their consumers always find them attached;
/// the initial navigation target only claims a node when no role applies.
FocusNode? _episodeListFocusNode({
required MediaItem episode,
required int index,
required bool hasPinnedLastEpisode,
}) {
if (index == 0) return _firstEpisodeFocusNode;
if (_shouldUseLastEpisodeFocusNode(episode: episode, index: index, hasPinnedLastEpisode: hasPinnedLastEpisode)) {
return _lastEpisodeFocusNode;
}
if (widget.initialEpisodeId == episode.id) return _initialEpisodeFocusNode;
return null;
}
/// The node the initial target episode's card actually holds.
FocusNode _initialTargetEpisodeFocusNode() {
final index = _episodes.indexWhere((episode) => episode.id == widget.initialEpisodeId);
if (index == -1) return _initialEpisodeFocusNode;
return _episodeListFocusNode(
episode: _episodes[index],
index: index,
hasPinnedLastEpisode: _hasPinnedLastEpisodeInList,
) ??
_initialEpisodeFocusNode;
}
/// Tail of the inline episode list: a retry tile when a page failed, otherwise
/// a spinner. Carries [_episodeTailKey] so the scroll listener can detect when
/// it nears the viewport and page the next batch in.
Widget _buildEpisodeListTail() {
if (_episodeListPageError) {
return Padding(
key: _episodeTailKey,
padding: const EdgeInsets.all(16),
child: ErrorStateWidget(
message: t.messages.episodesLoadFailed,
icon: Symbols.error_outline_rounded,
onRetry: () => unawaited(_loadMoreEpisodeList()),
retryLabel: t.common.retry,
),
);
}
return Padding(
key: _episodeTailKey,
padding: const EdgeInsets.all(24),
child: const Center(child: CircularProgressIndicator()),
);
}
void _syncFlattenEpisodeState() {
if (_isFlattenEpisodeList) _allEpisodes = _allEpisodes.replaceItems(_episodes);
}
/// Refresh episodes for the current context (inline season or all flattened)
Future<void> _refreshCurrentEpisodes() async {
if (_showEpisodesDirectly) {
await _fetchAllEpisodes();
} else if (_seasons.isNotEmpty) {
// Clear cache for current season and re-fetch
final season = _seasons[_selectedSeasonIndex];
_seasonEpisodePager.resetSeason(season.id);
await _fetchSeasonEpisodes(_selectedSeasonIndex);
}
}
Future<void> _fetchAllEpisodes() async {
final generation = ++_episodesLoadGeneration;
if (_seasons.isEmpty) {
setStateIfMounted(() {
_allEpisodes = const PagedMediaListState<MediaItem>();
_episodes = const <MediaItem>[];
_hasLoadedEpisodes = true;
});
return;
}
final serverId = _metadata.serverId;
if (serverId == null) {
setStateIfMounted(() {
_allEpisodes = const PagedMediaListState<MediaItem>();
_episodes = const <MediaItem>[];
_hasLoadedEpisodes = true;
});
return;
}
final client = context.tryGetMediaClientForServer(ServerId(serverId));
if (client == null) {
setStateIfMounted(() {
_allEpisodes = const PagedMediaListState<MediaItem>();
_episodes = const <MediaItem>[];
_hasLoadedEpisodes = true;
});
return;
}
setStateIfMounted(() {
_allEpisodes = const PagedMediaListState<MediaItem>().startInitialLoad();
_episodes = const <MediaItem>[];
_hasLoadedEpisodes = false;
});
try {
final firstPage = await _fetchFlattenedEpisodePage(client, ServerId(serverId), start: 0, size: _episodesPageSize);
if (!mounted || generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
_allEpisodes = _allEpisodes.completeInitialLoad(firstPage.items, firstPage.totalCount);
_episodes = _allEpisodes.items;
_hasLoadedEpisodes = true;
});
} catch (e, st) {
appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st);
setStateIfMounted(() {
_allEpisodes = _allEpisodes.failInitialLoad();
_hasLoadedEpisodes = true;
});
}
}
MediaItem? get _flattenedSeasonForDirectEpisodePaging {
if (_metadata.isSeason) {
if (_seasons.length == 1 && _seasons.first.isSeason) return _seasons.first;
final full = _fullMetadata;
if (full != null && full.isSeason) return full;
return _metadata;
}
if (_showEpisodesDirectly && _seasons.length == 1 && _seasons.first.isSeason) {
return _seasons.first;
}
return null;
}
String? _seriesIdForSeason(MediaItem season) {
if (_metadata.isShow) return (_fullMetadata ?? _metadata).id;
return season.grandparentId ?? season.parentId ?? _metadata.grandparentId ?? _metadata.parentId;
}
Future<LibraryPage<MediaItem>> _fetchFlattenedEpisodePage(
MediaServerClient client,
ServerId serverId, {
required int start,
required int size,
}) async {
final season = _flattenedSeasonForDirectEpisodePaging;
if (season != null) {
final seriesId = _seriesIdForSeason(season);
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seriesId != null && seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, season.id, start: start, size: size)
: await client.fetchChildrenPage(season.id, start: start, size: size);
return LibraryPage<MediaItem>(
items: _enrichDirectSeasonEpisodes(page.items, season: season, serverId: serverId),
totalCount: page.totalCount,
offset: page.offset,
);
}
final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: start, size: size);
return LibraryPage<MediaItem>(
items: _enrichPlayableEpisodes(page.items, serverId),
totalCount: page.totalCount,
offset: page.offset,
);
}
List<MediaItem> _enrichDirectSeasonEpisodes(
List<MediaItem> episodes, {
required MediaItem season,
required ServerId serverId,
}) {
if (_metadata.isShow) {
return normalizeSeasonEpisodes(episodes, show: _fullMetadata ?? _metadata, season: season);
}
return _enrichPlayableEpisodes(episodes, serverId)
.map(
(episode) => _withFallbackLibrary(
episode.copyWith(
parentId: episode.parentId ?? season.id,
parentTitle: episode.parentTitle ?? season.title,
parentIndex: episode.parentIndex ?? season.index,
grandparentId: episode.grandparentId ?? season.grandparentId ?? season.parentId,
grandparentTitle: episode.grandparentTitle ?? season.grandparentTitle ?? season.parentTitle,
),
season,
),
)
.toList();
}
List<MediaItem> _enrichPlayableEpisodes(List<MediaItem> episodes, ServerId serverId) {
// Enrich each episode with serverId/serverName/parent fields — backends
// don't always populate them on recursive queries, and hierarchy-aware
// watch-state resolution needs the parentChain intact. The copy is a no-op
// where the mapper already did.
final fallbackParentId = _metadata.isSeason ? _metadata.id : null;
final fallbackGrandparentId = _metadata.isSeason ? (_metadata.grandparentId ?? _metadata.parentId) : _metadata.id;
final fallbackGrandparentTitle = _metadata.isSeason
? (_metadata.grandparentTitle ?? _metadata.parentTitle)
: _metadata.title;
return episodes
.map(
(e) => _withFallbackLibrary(
e.copyWith(
serverId: serverId,
serverName: _metadata.serverName ?? e.serverName,
parentId: e.parentId ?? fallbackParentId,
grandparentId: e.grandparentId ?? fallbackGrandparentId,
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
),
_metadata,
),
)
.toList();
}
/// Load the next page of the flatten/all-episodes list (single-season show or
/// season detail) on demand and append it.
Future<void> _loadMoreAllEpisodes() async {
if (widget.isOffline || !_allEpisodes.hasMore || _allEpisodes.isLoadingMore) return;
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
if (serverId == null || client == null) return;
final generation = _episodesLoadGeneration;
final offset = _allEpisodes.items.length;
setStateIfMounted(() {
_allEpisodes = _allEpisodes.startLoadMore();
});
try {
final page = await _fetchFlattenedEpisodePage(client, ServerId(serverId), start: offset, size: _episodesPageSize);
if (!mounted || generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
_allEpisodes = _allEpisodes.completeLoadMore(
expectedOffset: offset,
pageItems: page.items,
total: page.totalCount,
);
_episodes = _allEpisodes.items;
});
} catch (e, st) {
appLogger.w('Failed to load more episodes', error: e, stackTrace: st);
if (mounted && generation == _episodesLoadGeneration) {
setStateIfMounted(() {
_allEpisodes = _allEpisodes.failLoadMore();
});
}
}
}
/// The inline episode list renders in two modes: the flatten/all-episodes list
/// (single-season show or season detail) and the per-season list. These
/// getters unify lazy-paging state across both so [_buildEpisodesList] and the
/// scroll/focus triggers don't have to branch on the mode.
bool get _isFlattenEpisodeList => _showEpisodesDirectly || _metadata.isSeason;
bool get _episodeListHasMore => _isFlattenEpisodeList ? _allEpisodes.hasMore : _selectedSeasonHasMore;
bool get _episodeListLoadingMore => _isFlattenEpisodeList ? _isLoadingAllEpisodes : _isLoadingMoreSeasonEpisodes;
bool get _episodeListPageError => _isFlattenEpisodeList ? _allEpisodesPageError : _seasonEpisodesPageError;
Future<void> _loadMoreEpisodeList() => _isFlattenEpisodeList ? _loadMoreAllEpisodes() : _loadMoreSeasonEpisodes();
/// Trigger lazy paging when the tail sentinel nears the viewport. Read-only —
/// must never scroll (it runs inside the scroll listener).
void _maybeTriggerEpisodePaging() {
if (!_episodeListHasMore || _episodeListLoadingMore || _episodeListPageError) return;
final tailContext = _episodeTailKey.currentContext;
if (tailContext == null) return;
final render = tailContext.findRenderObject();
if (render is! RenderBox || !render.hasSize) return;
final tailTop = render.localToGlobal(Offset.zero).dy;
final viewportHeight = MediaQuery.sizeOf(context).height;
// Prefetch once the tail is within ~one viewport of the visible bottom.
if (tailTop <= viewportHeight * 2) unawaited(_loadMoreEpisodeList());
}
void _onLastEpisodeFocusChanged() {
if (!_lastEpisodeFocusNode.hasFocus) {
_suppressNextLastEpisodeFocusLoad = false;
if (_lastEpisodeFocusPinnedKey != null) {
setStateIfMounted(() => _lastEpisodeFocusPinnedKey = null);
}
return;
}
if (_episodes.isNotEmpty) _lastEpisodeFocusPinnedKey ??= _episodes.last.globalKey;
if (_suppressNextLastEpisodeFocusLoad) {
_suppressNextLastEpisodeFocusLoad = false;
return;
}
// dpad/keyboard equivalent of scrolling to the end of the loaded episodes.
if (!_episodeListPageError) unawaited(_loadMoreEpisodeList());
}
/// Load the next unwatched episode for offline mode (offline OnDeck)
Future<void> _loadOfflineOnDeckEpisode() async {
final offlineWatchProvider = context.read<OfflineWatchProvider>();
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(_metadata.id);
setStateIfMounted(() {
_onDeckEpisode = nextEpisode;
});
if (nextEpisode != null) {
appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}');
}
}
/// Online counterpart to [_loadOfflineOnDeckEpisode]: when the backend
/// omitted an on-deck episode (e.g. the show was removed from Continue
/// Watching) synthesize one from the on-deck season's already-loaded episodes
/// so the next unwatched episode is highlighted/focused and the Play button
/// resumes it. No-op once a backend on-deck episode exists, or when every
/// loaded episode is watched (keep the default S1E1 for a rewatch).
void _ensureFallbackOnDeckEpisode() {
// Reached via unawaited fetch continuations — the screen may be gone by
// now, and _freshAll reads providers through State.context.
if (!mounted) return;
if (_onDeckEpisode != null) return;
final next = firstUnwatchedEpisode(_freshAll(_episodes));
if (next == null) return;
setStateIfMounted(() {
_onDeckEpisode = next;
});
}
Future<void> _playFirstEpisode() async {
try {
// If seasons aren't loaded yet, wait for them or load them
if (_seasons.isEmpty && !_isLoadingSeasons) {
if (widget.isOffline) {
_loadSeasonsFromDownloads();
} else {
await _loadSeasons();
}
}
// Wait for seasons to finish loading if they're currently loading
if (_isLoadingSeasons && _seasonsCompleter != null) {
await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {});
}
if (!mounted) return;
if (_seasons.isEmpty) {
if (mounted) {
showErrorSnackBar(context, t.messages.noSeasonsFound);
}
return;
}
final firstSeason = defaultPlaybackSeason(_seasons)!;
// Get the first episode of the first season.
MediaItem? firstEpisode;
if (!mounted) return;
if (widget.isOffline) {
// In offline mode, get episodes from downloads
final downloadProvider = context.read<DownloadProvider>();
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id);
// Filter to episodes of this season
final episodes = allEpisodes.where((ep) => ep.parentIndex == firstSeason.index).toList()
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
firstEpisode = episodes.isEmpty ? null : episodes.first;
} else {
final client = getServerBoundMediaClient(context);
if (client == null) return;
firstEpisode = await fetchFirstEpisodeForSeason(
client,
firstSeason.id,
seriesId: _seriesIdForSeason(firstSeason),
);
}
if (firstEpisode == null) {
if (mounted) {
showErrorSnackBar(context, t.messages.noEpisodesFound);
}
return;
}
// Play the first episode
// Preserve serverId for the episode
final episodeWithServerId = firstEpisode.copyWith(
serverId: _metadata.serverId ?? firstEpisode.serverId,
serverName: _metadata.serverName ?? firstEpisode.serverName,
libraryId: firstEpisode.libraryId ?? _metadata.libraryId,
libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle,
);
if (mounted) {
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
await navigateToVideoPlayerWithRefresh(
context,
metadata: episodeWithServerId,
isOffline: widget.isOffline,
onRefresh: _loadFullMetadata,
);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
}
/// Handle shuffle play. Routes through [MediaListPlaybackLauncher.forItem]
/// so Plex uses its server-side `/playQueues` and Jellyfin builds a local
/// shuffled queue from `fetchClientSideEpisodeQueue`.
Future<void> _handleShufflePlayWithQueue(BuildContext context, MediaItem metadata) async {
if (widget.isOffline) {
if (context.mounted) {
showErrorSnackBar(context, t.mediaMenu.shuffleNotAvailableOffline);
}
return;
}
final launcher = MediaListPlaybackLauncher.forItem(context, metadata);
final result = await launcher.launchShuffledShow(metadata: metadata);
if (result is PlayQueueSuccess && mounted) {
unawaited(_loadFullMetadata());
}
}
@override
Widget build(BuildContext context) {
// Session-fresh hero: server snapshot resolved against the watch-state
// store (onWatchStateChanged rebuilds on relevant events).
final metadata = _fresh(_fullMetadata ?? _metadata);
final isShow = metadata.isShow;
final isMobile = PlatformDetector.isMobile(context);
final isTv = PlatformDetector.isTV();
final theme = Theme.of(context);
final sectionTitleStyle = theme.textTheme.titleLarge?.copyWith(fontWeight: .bold, fontSize: isTv ? 28 : null);
// 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),
focusNode: _backButtonFocusNode,
);
final loading = ListenableBuilder(
listenable: FullscreenStateManager(),
builder: (context, _) => Focus(
onKeyEvent: _handleMediaDetailBackKey,
child: Scaffold(
appBar: AppBar(
leading: DesktopAppBarSections.buildLeadingSection(leading: backButton, context: context),
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(
leading: backButton,
context: context,
),
),
body: const Center(child: CircularProgressIndicator()),
),
),
);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
if (!blockSystemBack) {
return loading;
}
return PopScope(
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _handleMediaDetailSystemBack();
},
child: loading,
);
}
// Determine header height based on screen size
final size = MediaQuery.sizeOf(context);
final headerHeight = size.height * (isTv ? 1.0 : 0.6);
if (isTv) {
return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey);
}
_scheduleInitialMobileDetailFocus(metadata);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
final content = PrimaryScrollController(
controller: _scrollController,
child: IosStatusBarTapScrollToTop(
controller: _scrollController,
child: OverlaySheetHost(
// blockSystemBack keeps the route from double-popping on Android
// keyboard/TV (the key handler owns dpad back); elsewhere canPop:true
// keeps the iOS swipe-back. The host also closes an open sheet on back.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: _handleMediaDetailBackKey,
child: Scaffold(
body: Stack(
children: [
// Background art sits behind the scroll view so it can be
// taller than the hero sliver without displacing content.
_buildHeroBackdropLayer(context, metadata, size, headerHeight),
CustomScrollView(
primary: true,
slivers: [
// Hero header content over the background art
SliverToBoxAdapter(
child: SizedBox(height: headerHeight, child: _buildHeroHeader(context, metadata)),
),
// Main content
SliverToBoxAdapter(
child: Padding(
// Reduced top inset keeps the Overview/first section
// tight under the hero's action row (the hero already
// contributes its own bottom inset above this).
padding: .fromLTRB(
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 4,
isTv ? TvLayoutConstants.horizontalInset : 16,
isTv ? 8 : 16,
),
child: Column(
crossAxisAlignment: .start,
children: [
// Summary
if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[
Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle),
const SizedBox(height: 12),
CollapsibleText(
text: metadata.summary!,
maxLines: isMobile ? 6 : 4,
style: theme.textTheme.bodyLarge?.copyWith(height: 1.6),
focusNode: _overviewFocusNode,
skipTraversal: false,
onNavigateUp: () {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
_playButtonFocusNode.requestFocus();
},
onNavigateDown: _focusBelowOverview,
onNavigateLeft: () {},
onNavigateRight: () {},
),
const SizedBox(height: 24),
],
// Seasons / Episodes (for TV shows and seasons)
if (isShow && !_showEpisodesDirectly) ...[
// Season tabs + inline episodes
if (_isLoadingSeasons)
_sectionLoading
else if (_seasonsLoadFailed)
_sectionError(t.messages.seasonsLoadFailed, () => unawaited(_loadSeasons()))
else if (_seasons.isEmpty)
_sectionEmpty(context, t.messages.noSeasonsFound)
else ...[
Text(
key: _seasonsSectionKey,
t.libraries.groupings.episodes,
style: sectionTitleStyle,
),
const SizedBox(height: 12),
_buildSeasonTabs(),
const SizedBox(height: 16),
if (_isLoadingSeasonEpisodes)
_sectionLoading
else if (_seasonEpisodesFirstPageError && _episodes.isEmpty)
_sectionError(
t.messages.episodesLoadFailed,
() => unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex)),
)
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
],
const SizedBox(height: 24),
] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[
// Server says flatten — existing behavior unchanged
Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle),
const SizedBox(height: 12),
if (_isLoadingSeasons || _isLoadingEpisodes)
_sectionLoading
else if (_allEpisodesPageError && _episodes.isEmpty)
_sectionError(t.messages.episodesLoadFailed, () => unawaited(_fetchAllEpisodes()))
else if (_episodes.isNotEmpty)
_buildEpisodesList()
else
_sectionEmpty(context, t.messages.noEpisodesFoundGeneral),
const SizedBox(height: 24),
],
// Cast
if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[
Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildCastSection(metadata),
const SizedBox(height: 24),
],
// Trailers & Extras Section
if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[
Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle),
const SizedBox(height: 12),
_buildExtrasSection(),
const SizedBox(height: 24),
],
// Related Hubs (Collections, Similar, More From...)
for (int i = 0; i < _relatedHubs.length; i++) ...[
HubSection(
key: _relatedHubKeys[i],
hub: _relatedHubs[i],
focusMemory: _hubFocusMemory,
icon: _getRelatedHubIcon(_relatedHubs[i]),
inset: true,
onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp),
),
SizedBox(height: isTv ? 28 : 8),
],
// Additional info — wrapped in Focus so DPAD DOWN from the
// last focusable section lands here and scrolls it into view.
if (_hasInfoRows)
Focus(
focusNode: _infoRowsFocusNode,
onKeyEvent: _handleInfoRowsKeyEvent,
child: Column(
key: _infoRowsSectionKey,
crossAxisAlignment: .start,
children: [
if (metadata.studio != null) ...[
_buildInfoRow(t.discover.studio, metadata.studio!),
const SizedBox(height: 12),
],
if (metadata.directors?.isNotEmpty == true) ...[
_buildInfoRow(
metadata.directors!.length > 1 ? t.discover.directors : t.discover.director,
metadata.directors!.join(', '),
),
const SizedBox(height: 12),
],
if (metadata.contentRating != null) ...[
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)),
const SizedBox(height: 12),
],
],
),
),
],
),
),
),
SliverPadding(padding: .only(bottom: MediaQuery.paddingOf(context).bottom)),
],
),
// Sticky top bar with fading background
Positioned(
top: 0,
left: 0,
right: 0,
child: ValueListenableBuilder<double>(
valueListenable: _scrollOffset,
builder: (context, offset, child) => IgnorePointer(
ignoring: offset < 50,
child: AnimatedOpacity(
opacity: (offset / 100).clamp(0.0, 1.0),
duration: const Duration(milliseconds: 150),
child: child!,
),
),
child: SizedBox(
height: MediaQuery.paddingOf(context).top + 58,
child: RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.scaffoldBackgroundColor.withValues(alpha: 0.8),
theme.scaffoldBackgroundColor.withValues(alpha: 0.5),
theme.scaffoldBackgroundColor.withValues(alpha: 0),
],
stops: const [0.0, 0.3, 1.0],
),
),
),
),
),
// Back button (always visible)
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(
style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
focusNode: _backButtonFocusNode,
),
context: context,
)!,
),
],
),
),
),
),
),
);
return content;
}
Widget _buildTvDetailScreen(
BuildContext context,
MediaItem metadata,
KeyEventResult Function(FocusNode, KeyEvent) handleBack,
) {
final size = MediaQuery.sizeOf(context);
final detailHubs = _tvDetailHubs(metadata);
if (widget.initialEpisodeId != null && !_initialEpisodePagingDone) {
_maybeLoadMoreForInitialEpisode();
}
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
final detailScale = TvLayoutConstants.scaleForSize(size);
final spotlightTop = (size.height * 0.08).clamp(44.0 * detailScale, 110.0 * detailScale).toDouble();
final rawRailHeight = _estimateTvDetailRailHeight(size, detailHubs);
if (!_tvDetailRevealed && _isTvDetailReadyToReveal(metadata)) {
_scheduleTvDetailReveal(rawRailHeight, focusPrimaryAction: metadata.isMovie);
}
final stableRailHeight = _tvDetailStableRailHeight;
final railHeight = stableRailHeight == null || rawRailHeight > stableRailHeight ? rawRailHeight : stableRailHeight;
final railTopPadding = 12 * detailScale;
final foregroundBottom = (railHeight - railTopPadding) + (_tvDetailActionRailGap * detailScale);
final spotlightLeft = (24 * detailScale).clamp(18.0, 40.0).toDouble();
final revealContent = Stack(
fit: StackFit.expand,
children: [
Positioned(
left: spotlightLeft,
right: size.width * 0.43,
top: spotlightTop,
bottom: foregroundBottom,
child: ValueListenableBuilder<MediaItem?>(
valueListenable: _tvDetailFocusedEpisode,
builder: (context, _, _) =>
_buildTvDetailForeground(context, metadata, hideSpoilers: hideSpoilers, scale: detailScale),
),
),
Positioned(
top: 0,
left: 0,
child: DesktopAppBarHelper.buildAdjustedLeading(
AppBarBackButton(
style: BackButtonStyle.circular,
onPressed: () => Navigator.pop(context, _watchStateChanged),
focusNode: _backButtonFocusNode,
),
context: context,
)!,
),
if (detailHubs.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
key: _tvDetailRailKey,
hubs: detailHubs,
focusMemory: _hubFocusMemory,
iconForHub: _getTvDetailHubIcon,
onFocusedHubItemChanged: _handleTvDetailFocusedRailItemChanged,
onRefresh: (source) => unawaited(_refreshItemInPlace(source)),
onActiveHubChanged: _handleTvDetailHubChanged,
onActivateItem: _handleTvDetailRailItemActivated,
trailingForHub: _tvDetailTrailingState,
onRetryHub: _retryTvDetailHub,
onNavigateUp: _focusTvDetailActionRow,
onBack: _popMediaDetailIfBackNotSuppressed,
tallPosterScale: _tvDetailTallPosterScale,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
initialHubId: _tvDetailInitialHubId(metadata),
initialItemId: _tvDetailInitialItemId(metadata),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
),
),
],
);
final blockSystemBack = InputModeTracker.shouldBlockSystemBack(context);
final content = OverlaySheetHost(
// blockSystemBack keeps the route from double-popping on Android keyboard/
// TV (the key handler owns dpad back); the host also closes an open sheet.
canPop: !blockSystemBack,
onSystemBack: _handleMediaDetailSystemBack,
child: Focus(
onKeyEvent: handleBack,
child: Scaffold(
body: Stack(
children: [
TvSpotlightBackground(
item: metadata,
client: _getArtworkMediaClient(context),
showInfo: false,
localArtworkPathResolver: widget.isOffline
? (path) => _offlineArtworkCandidatePath(context, path)
: null,
allowNetwork: !widget.isOffline,
),
_buildTvDetailRevealGate(revealContent, handleBack),
],
),
),
),
);
return content;
}
Widget _buildTvDetailForeground(
BuildContext context,
MediaItem metadata, {
required bool hideSpoilers,
required double scale,
}) {
final theme = Theme.of(context);
final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers);
final foregroundColor = _tvDetailForegroundColor(context);
final mutedForegroundColor = foregroundColor.withValues(alpha: 0.78);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxHeight <= 0 || constraints.maxWidth <= 0) return const SizedBox.shrink();
final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0;
final desiredLogoHeight = 220 * scale;
final minLogoHeight = 60 * scale;
final desiredLogoWidth = 790 * scale;
final metadataLineHeight = 22 * scale;
final genreLineHeight = 22 * scale;
final genreGap = 6 * scale;
final logoMetadataGap = 10 * scale;
final summaryGap = 6 * scale;
final summaryFontSize = availableHeight < 260 * scale ? 16.2 * scale : 18 * scale;
final summaryLineHeight = summaryFontSize * 1.35;
final actionHeight = _tvDetailActionSize * scale;
final actionGap = 12 * scale;
final hasDescription = description != null && description.isNotEmpty;
// Genres come from the show/movie, not the focused episode, so the line
// stays stable as episode rows gain focus.
final genres = metadata.genres ?? const <String>[];
final genreBlockHeight = genres.isEmpty ? 0.0 : genreGap + genreLineHeight;
var summaryMaxLines = 0;
var logoHeight = 0.0;
for (var lines = hasDescription ? 3 : 0; lines >= 0; lines--) {
final descriptionHeight = lines > 0 ? summaryGap + (summaryLineHeight * lines) : 0.0;
final reservedHeight =
logoMetadataGap + metadataLineHeight + genreBlockHeight + descriptionHeight + actionGap + actionHeight;
final remainingForLogo = availableHeight - reservedHeight;
if (remainingForLogo >= minLogoHeight || lines == 0) {
summaryMaxLines = lines;
logoHeight = remainingForLogo <= 0 ? 0 : remainingForLogo.clamp(0, desiredLogoHeight).toDouble();
break;
}
}
final showLogo = logoHeight > 0;
final descriptionHeight = summaryMaxLines > 0 ? summaryGap + (summaryLineHeight * summaryMaxLines) : 0.0;
final contentHeight =
(showLogo ? logoHeight + logoMetadataGap : 0) +
metadataLineHeight +
genreBlockHeight +
descriptionHeight +
actionGap +
actionHeight;
final logoWidth = desiredLogoWidth < constraints.maxWidth ? desiredLogoWidth : constraints.maxWidth;
return ClipRect(
child: SizedBox(
height: availableHeight,
child: Align(
alignment: .bottomLeft,
child: SizedBox(
height: contentHeight <= availableHeight ? contentHeight : availableHeight,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Semantics(
key: const ValueKey('tv_detail_information_semantics'),
identifier: 'tv_detail_information',
container: true,
label: _tvDetailInformationSemanticLabel(metadata, description: description, genres: genres),
child: ExcludeSemantics(
// This is one non-interactive announcement. The action
// buttons below remain separate accessible controls.
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
if (showLogo) ...[
_buildDetailLogoOrTitle(
context,
metadata,
width: logoWidth,
height: logoHeight,
titleBuilder: (context, title) => _buildDetailTitle(
context,
title,
fontSize: 56 * scale,
fontWeight: .w800,
shadowBlur: 12,
color: foregroundColor,
shadowColor: _tvDetailTitleShadowColor(context),
),
),
SizedBox(height: logoMetadataGap),
],
SizedBox(
height: metadataLineHeight,
child: Align(
alignment: .centerLeft,
child: _buildTvDetailMetadataLine(context, metadata, scale),
),
),
if (genres.isNotEmpty) ...[
SizedBox(height: genreGap),
SizedBox(
height: genreLineHeight,
child: Align(
alignment: .centerLeft,
child: Text(
genres.join(' • '),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: mutedForegroundColor,
fontSize: 16 * scale,
fontWeight: .w600,
letterSpacing: 0.1,
),
),
),
),
],
if (hasDescription && summaryMaxLines > 0) ...[
SizedBox(height: summaryGap),
SizedBox(
height: summaryLineHeight * summaryMaxLines,
child: Text(
description,
maxLines: summaryMaxLines,
overflow: .ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color: mutedForegroundColor,
fontSize: summaryFontSize,
height: 1.35,
),
),
),
],
],
),
),
),
SizedBox(height: actionGap),
SizedBox(height: actionHeight, child: _buildActionButtons(metadata)),
],
),
),
),
),
);
},
);
}
/// The ordered metadata fields the TV detail line renders and its announcement reads,
/// built through [text] for plain fields and [rating] for the rating slot.
List<T> _tvDetailMetadataParts<T extends Object>(
MediaItem metadata, {
required T Function(String value) text,
required T? Function(MediaItem item) rating,
}) {
final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata;
final parts = <T>[];
void add(T? part) {
if (part != null) parts.add(part);
}
final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index);
if (lineMetadata.isEpisode && episodeLabel != null) add(text(episodeLabel));
if (lineMetadata.isMovie) {
add(text(t.discover.movie));
} else if (lineMetadata.isShow) {
add(text(t.discover.tvShow));
}
add(rating(lineMetadata));
if (lineMetadata.contentRating != null) add(text(formatContentRating(lineMetadata.contentRating!)));
if (lineMetadata.durationMs != null) add(text(formatDurationTextual(lineMetadata.durationMs!)));
if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) {
add(text(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)));
} else if (lineMetadata.year != null) {
add(text(lineMetadata.year.toString()));
}
for (final label in buildMediaQualityLabels(lineMetadata)) {
add(text(label));
}
return parts;
}
String _tvDetailInformationSemanticLabel(
MediaItem metadata, {
required String? description,
required List<String> genres,
}) {
final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata;
final parts = <String>[];
void add(String? value) {
if (value == null || value.isEmpty || parts.contains(value)) return;
parts.add(value);
}
add(metadata.displayTitle);
if (!identical(lineMetadata, metadata)) add(lineMetadata.displayTitle);
final fields = _tvDetailMetadataParts<String>(
metadata,
text: (value) => value,
rating: (item) => MediaRatingBadgeGroup.semanticLabelForMedia(item, fallbackItem: metadata),
);
for (final field in fields) {
add(field);
}
if (genres.isNotEmpty) add(genres.join(', '));
add(description);
return parts.join(', ');
}
Color _tvDetailForegroundColor(BuildContext context) => Theme.of(context).colorScheme.onSurface;
Color _tvDetailTitleShadowColor(BuildContext context) {
final brightness = Theme.of(context).colorScheme.brightness;
return brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.55);
}
Widget _buildDetailLogoOrTitle(
BuildContext context,
MediaItem metadata, {
required double width,
required double height,
required Widget Function(BuildContext context, String title) titleBuilder,
}) {
Widget titleFallback(BuildContext context) => titleBuilder(context, metadata.displayTitle);
if (metadata.clearLogoPath == null) {
return SizedBox(width: width, height: height, child: titleFallback(context));
}
return SizedBox(
width: width,
height: height,
child: Builder(
builder: (context) {
final localArtwork = _buildOfflineArtworkIfAvailable(
context,
artworkPaths: [metadata.clearLogoPath],
fit: BoxFit.contain,
alignment: .centerLeft,
imageType: ImageType.heroLogo,
placeholder: (context, url) => titleFallback(context),
errorWidget: (context, url, error) => titleFallback(context),
);
if (localArtwork != null) return localArtwork;
return ClearLogoImage(
client: _getArtworkMediaClient(context),
logoPath: metadata.clearLogoPath,
width: width,
height: height,
fallbackBuilder: titleFallback,
);
},
),
);
}
Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) {
final textStyle = TextStyle(
color: _tvDetailForegroundColor(context),
fontSize: 18 * scale,
fontWeight: .w700,
letterSpacing: 0.1,
);
final fields = _tvDetailMetadataParts<Widget>(
metadata,
text: (value) => Text(value, maxLines: 1, style: textStyle),
// Every score shares the one metadata slot, so the bullet separators
// don't multiply with the number of sources.
rating: (item) => MediaRatingBadgeGroup.inlineForMedia(
item: item,
fallbackItem: metadata,
foregroundColor: textStyle.color,
iconSize: textStyle.fontSize,
spacing: 4 * scale,
entrySpacing: 12 * scale,
textStyle: textStyle,
),
);
if (fields.isEmpty) return const SizedBox.shrink();
final children = <Widget>[];
for (final field in fields) {
if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle));
children.add(field);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
String? _tvDetailDescription(MediaItem metadata, {required bool hideSpoilers}) {
final focusedEpisode = _tvDetailFocusedEpisode.value;
if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
final episodeDescription = _tvDetailItemDescription(
focusedEpisode,
hideSpoilers: hideSpoilers,
showSpoilerFallback: false,
);
if (episodeDescription != null) return episodeDescription;
final season = _tvDetailSeasonForEpisode(focusedEpisode, metadata);
final seasonDescription = season == null ? null : _tvDetailItemDescription(season, hideSpoilers: hideSpoilers);
if (seasonDescription != null) return seasonDescription;
final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers);
if (showDescription != null) return showDescription;
if (hideSpoilers && focusedEpisode.shouldHideSpoiler) return focusedEpisode.title;
return null;
}
String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) {
final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler;
final summary = shouldHideSpoiler ? null : item.summary;
if (summary != null && summary.isNotEmpty) return summary;
if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return item.title;
return null;
}
MediaItem? _tvDetailSeasonForEpisode(MediaItem episode, MediaItem metadata) {
for (final season in _seasons) {
if (episode.parentId != null && season.id == episode.parentId) return season;
if (episode.parentIndex != null && season.index == episode.parentIndex) return season;
}
if (metadata.isSeason &&
((episode.parentId != null && metadata.id == episode.parentId) ||
(episode.parentIndex != null && metadata.index == episode.parentIndex))) {
return metadata;
}
return null;
}
double _estimateTvBrowseRailHeight(Size size, List<MediaHub> hubs) {
final svc = SettingsService.instance;
return TvBrowseRailLayout.estimateHeight(
size: size,
hubs: hubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
widePosterScaleForHub: _tvDetailWidePosterScaleForHub,
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: _tvDetailTallPosterScale,
);
}
double _estimateTvDetailRailHeight(Size size, List<MediaHub> hubs) {
final hubRailHeight = _estimateTvBrowseRailHeight(size, hubs);
if (hubRailHeight > 0) return hubRailHeight;
return _estimateTvDetailEmptyRailReserveHeight(size);
}
double _estimateTvDetailEmptyRailReserveHeight(Size size) {
final svc = SettingsService.instance;
final scale = TvBrowseRailLayout.scaleForSize(size);
final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
if (availableWidth <= 0) return 0;
final metrics = TvBrowseRailLayout.metricsForHub(
hub: MediaHub(
id: 'detail_empty_rail_reserve',
title: '',
type: 'movie',
items: [MediaItem(id: 'detail_empty_rail_reserve_item', backend: _metadata.backend, kind: MediaKind.movie)],
),
availableWidth: availableWidth,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
scale: scale,
tallPosterScale: _tvDetailTallPosterScale,
widePosterScale: 1.0,
);
final sectionHeight = TvBrowseRailLayout.hubSectionHeightFor(scale: scale, activeRailHeight: metrics.height);
return TvBrowseRailLayout.railTopPaddingForScale(scale) +
TvBrowseRailLayout.viewportHeightFor(hubCount: 1, scale: scale, sectionHeight: sectionHeight) +
TvBrowseRailLayout.railBottomPaddingForScale(scale);
}
bool _isTvDetailEpisodeHub(MediaHub hub) {
return hub.id.startsWith(_tvDetailSeasonHubIdPrefix) || hub.id == 'detail_episodes';
}
EpisodePosterMode _tvDetailEpisodePosterModeForHub(MediaHub hub) {
if (_isTvDetailEpisodeHub(hub)) return EpisodePosterMode.episodeThumbnail;
return SettingsService.instance.read(SettingsService.episodePosterMode);
}
double _tvDetailWidePosterScaleForHub(MediaHub hub) {
return _isTvDetailEpisodeHub(hub) || hub.id == _tvDetailExtrasHubId ? _tvDetailEpisodeThumbnailScale : 1.0;
}
List<MediaHub> _tvDetailHubs(MediaItem metadata) {
final hubs = <MediaHub>[];
if (metadata.isShow && !_showEpisodesDirectly && _seasonsLoadFailed) {
hubs.add(
MediaHub(
id: _tvDetailSeasonsErrorHubId,
title: t.libraries.groupings.seasons,
type: 'episode',
items: const [],
size: 0,
),
);
} else if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
// Emit a hub for every season so TV users can choose a season before its
// episodes are fetched. Extra pages load in-place when focus reaches the
// last loaded episode; the trailing slot is reserved for loading/retry.
for (var i = 0; i < _seasons.length; i++) {
final season = _seasons[i];
final state = _seasonEpisodePager.stateFor(season.id);
final episodes = i == _selectedSeasonIndex ? _episodes : state.items;
final total = state.totalCount > episodes.length ? state.totalCount : (season.leafCount ?? episodes.length);
hubs.add(
MediaHub(
id: '$_tvDetailSeasonHubIdPrefix$i',
title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle),
type: 'episode',
items: episodes,
size: total,
),
);
}
} else if (_episodes.isNotEmpty) {
final total = _allEpisodesTotal > _episodes.length ? _allEpisodesTotal : _episodes.length;
hubs.add(
MediaHub(
id: 'detail_episodes',
title: t.libraries.groupings.episodes,
type: 'episode',
items: _episodes,
size: total,
),
);
} else if ((metadata.isShow && _showEpisodesDirectly) || metadata.isSeason) {
if (_allEpisodesPageError || _isLoadingEpisodes) {
hubs.add(
MediaHub(
id: 'detail_episodes',
title: t.libraries.groupings.episodes,
type: 'episode',
items: const [],
size: 0,
),
);
}
}
final actors = _tvDetailActorItems(metadata);
if (actors.isNotEmpty) {
hubs.add(
MediaHub(id: _tvDetailActorsHubId, title: t.discover.cast, type: 'person', items: actors, size: actors.length),
);
}
if (_extras != null && _extras!.isNotEmpty) {
hubs.add(
MediaHub(
id: _tvDetailExtrasHubId,
title: t.discover.extras,
type: 'clip',
items: _extras!,
size: _extras!.length,
),
);
}
hubs.addAll(_relatedHubs.where((hub) => hub.items.isNotEmpty));
return hubs;
}
String? _tvDetailInitialHubId(MediaItem metadata) {
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
return '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex';
}
if ((metadata.isShow && _showEpisodesDirectly) || metadata.isSeason) {
return 'detail_episodes';
}
return null;
}
String? _tvDetailInitialItemId(MediaItem metadata) {
if (widget.initialEpisodeId != null) return widget.initialEpisodeId;
if (!metadata.isShow) return null;
return _onDeckEpisode?.id;
}
List<MediaItem> _tvDetailActorItems(MediaItem metadata) {
final roles = metadata.roles;
if (roles == null || roles.isEmpty) return const [];
return [
for (var i = 0; i < roles.length; i++)
if (roles[i].tag.trim().isNotEmpty) _tvDetailActorItem(metadata, roles[i], i),
];
}
MediaItem _tvDetailActorItem(MediaItem metadata, MediaRole actor, int index) {
final personId = actor.id?.trim();
return MediaItem(
id: personId != null && personId.isNotEmpty ? '${metadata.id}_actor_$personId' : '${metadata.id}_actor_$index',
backend: metadata.backend,
kind: MediaKind.unknown,
title: actor.tag,
parentTitle: actor.role,
thumbPath: actor.thumbPath,
serverId: metadata.serverId,
serverName: metadata.serverName,
raw: {if (personId != null && personId.isNotEmpty) _tvDetailActorPersonIdRawKey: personId},
);
}
Future<bool> _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) async {
if (_isTvDetailEpisodeHub(hub) && item.isEpisode) {
await navigateToVideoPlayerWithRefresh(
context,
metadata: item,
isOffline: widget.isOffline,
onRefresh: () => unawaited(_refreshItemInPlace(item)),
);
return true;
}
if (hub.id != _tvDetailActorsHubId) return false;
final personId = item.raw?[_tvDetailActorPersonIdRawKey];
if (personId is String && personId.isNotEmpty) {
_navigateToActorMedia(
MediaRole(id: personId, tag: item.displayTitle, role: item.parentTitle, thumbPath: item.thumbPath),
);
}
return true;
}
void _clearTvDetailFocusedEpisode() {
_tvDetailFocusedEpisode.value = null;
}
void _setTvDetailActionRowFocus(bool hasFocus) {
_tvDetailActionRowHasFocus = hasFocus;
if (hasFocus) _clearTvDetailFocusedEpisode();
}
void _focusTvDetailActionRow() {
_tvDetailActionRowHasFocus = true;
_clearTvDetailFocusedEpisode();
_playButtonFocusNode.requestFocus();
}
void _handleTvDetailFocusedRailItemChanged(MediaHub hub, MediaItem item) {
if (_tvDetailActionRowHasFocus) {
_clearTvDetailFocusedEpisode();
return;
}
if (!_isTvDetailEpisodeHub(hub) || !item.isEpisode) {
_clearTvDetailFocusedEpisode();
return;
}
if (_tvDetailFocusedEpisode.value?.id == item.id) return;
_tvDetailFocusedEpisode.value = item;
if (hub.id == 'detail_episodes') {
if (!_allEpisodesPageError && _episodes.isNotEmpty && item.id == _episodes.last.id) {
unawaited(_loadMoreAllEpisodes());
}
return;
}
if (hub.id == '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex' &&
!_seasonEpisodesPageError &&
_episodes.isNotEmpty &&
item.id == _episodes.last.id) {
unawaited(_loadMoreSeasonEpisodes());
}
}
void _handleTvDetailHubChanged(MediaHub hub, int index) {
if (!_isTvDetailEpisodeHub(hub)) {
_clearTvDetailFocusedEpisode();
return;
}
if (hub.items.isEmpty) _clearTvDetailFocusedEpisode();
if (!hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return;
final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length));
if (seasonIndex == null || seasonIndex < 0 || seasonIndex >= _seasons.length) return;
final season = _seasons[seasonIndex];
final state = _seasonEpisodePager.stateFor(season.id);
final hasLoadedState =
_seasonEpisodePager.hasState(season.id) && !state.isInitialLoading && !state.initialLoadFailed;
if (_selectedSeasonIndex == seasonIndex &&
(hasLoadedState || _episodes.isNotEmpty || _isLoadingSeasonEpisodes || _seasonEpisodesFirstPageError)) {
return;
}
if (hasLoadedState) {
_seasonEpisodePager.completeFirstPage(season.id, state.items, state.totalCount);
setStateIfMounted(() {
_selectedSeasonIndex = seasonIndex;
_episodes = List.of(_seasonEpisodePager.stateFor(season.id).items);
});
unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
_pruneDistantSeasonPages(seasonIndex);
return;
}
setStateIfMounted(() {
_selectedSeasonIndex = seasonIndex;
_episodes = const <MediaItem>[];
_seasonEpisodePager.markFirstPageLoading(season.id);
});
unawaited(_fetchSeasonEpisodes(seasonIndex));
unawaited(_prefetchAdjacentSeasonEpisodePages(seasonIndex));
_pruneDistantSeasonPages(seasonIndex);
}
/// Low-end TV only: keep episode pages for the selected season ±1 (the
/// prefetch window) and drop the rest. Visited 200-item pages otherwise
/// accumulate for the screen's lifetime — irrelevant for a 3-season show,
/// tens of MB of retained heap for a 30-season one.
void _pruneDistantSeasonPages(int seasonIndex) {
if (!DevicePerformance.isLowEndHardware || !PlatformDetector.isTV()) return;
final keep = <String>{
for (var i = seasonIndex - 1; i <= seasonIndex + 1; i++)
if (i >= 0 && i < _seasons.length) _seasons[i].id,
};
_seasonEpisodePager.retainOnly(keep);
}
/// What the rail should show in a hub's trailing slot: a spinner while the
/// selected season page loads or a retry tile if it failed. Episode hubs do
/// not use the legacy "View All" slot; they page in-place on focus.
TvRailTrailing _tvDetailTrailingState(MediaHub hub) {
if (hub.id == _tvDetailSeasonsErrorHubId) return TvRailTrailing.error;
final isFlatten = hub.id == 'detail_episodes';
final isSeason = hub.id.startsWith(_tvDetailSeasonHubIdPrefix);
if (!isFlatten && !isSeason) return hub.more ? TvRailTrailing.viewAll : TvRailTrailing.none;
if (isFlatten) {
if (_allEpisodesPageError) return TvRailTrailing.error;
if (_isLoadingEpisodes || _isLoadingAllEpisodes) return TvRailTrailing.loading;
return TvRailTrailing.none;
}
final isSelectedSeason = hub.id == '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex';
if (isSelectedSeason) {
if (_seasonEpisodesFirstPageError || _seasonEpisodesPageError) return TvRailTrailing.error;
if (_isLoadingSeasonEpisodes || _isLoadingMoreSeasonEpisodes) return TvRailTrailing.loading;
}
return TvRailTrailing.none;
}
void _retryTvDetailHub(MediaHub hub) {
if (hub.id == _tvDetailSeasonsErrorHubId) {
unawaited(_loadSeasons());
return;
}
if (hub.id == 'detail_episodes') {
unawaited(_episodes.isEmpty ? _fetchAllEpisodes() : _loadMoreAllEpisodes());
return;
}
if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) {
final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length));
if (seasonIndex != null && seasonIndex >= 0 && seasonIndex < _seasons.length) {
final state = _seasonEpisodePager.stateFor(_seasons[seasonIndex].id);
unawaited(state.items.isNotEmpty ? _loadMoreSeasonEpisodes() : _fetchSeasonEpisodes(seasonIndex));
}
}
}
IconData _getTvDetailHubIcon(MediaHub hub, int index) {
if (hub.id == _tvDetailSeasonsErrorHubId) return Symbols.error_outline_rounded;
if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return Symbols.tv_rounded;
if (hub.id == 'detail_episodes') return Symbols.tv_rounded;
if (hub.id == _tvDetailExtrasHubId) return Symbols.theaters_rounded;
if (hub.id == _tvDetailActorsHubId) return Symbols.group_rounded;
return _getRelatedHubIcon(hub);
}
/// Height of the backdrop paint box, which is deliberately taller than the
/// hero sliver on wide windows.
///
/// Cover-fitting a 16:9 backdrop into a hero that is much wider than 16:9
/// scales it to the window width and then throws away the top and bottom of
/// the frame. Painting into the artwork's own natural height instead keeps
/// the whole frame, and the extra height is spent behind the content that
/// follows the hero rather than pushing that content down — the sliver
/// layout below is unchanged.
///
/// Never shorter than the hero: portrait windows are already taller than the
/// artwork and keep cropping the sides as before. Never taller than
/// [_maxHeroArtViewportFraction] either, because the scrim ramps across this
/// box, so a box that overshoots the window would still be translucent where
/// the overview text begins.
double _heroArtHeight(Size size, double headerHeight) {
const backdropAspect = 16 / 9;
final maxArtHeight = math.max(headerHeight, size.height * _maxHeroArtViewportFraction);
return (size.width / backdropAspect).clamp(headerHeight, maxArtHeight).toDouble();
}
/// Backdrop + scrim, painted behind the scroll view and translated with it so
/// it keeps the hero's fixed, no-parallax relationship to the content.
Widget _buildHeroBackdropLayer(BuildContext context, MediaItem metadata, Size size, double headerHeight) {
final artHeight = _heroArtHeight(size, headerHeight);
return Positioned(
top: 0,
left: 0,
right: 0,
height: artHeight,
child: IgnorePointer(
child: AnimatedBuilder(
animation: _scrollController,
builder: (context, child) {
final offset = _scrollController.hasClients ? _scrollController.offset : 0.0;
return Transform.translate(offset: Offset(0, -offset), child: child);
},
child: RepaintBoundary(
child: Stack(
fit: .expand,
children: [
Builder(
builder: (context) {
final containerAspect = size.width / artHeight;
final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect);
if (heroArtPaths.isEmpty) return const PlaceholderContainer();
return blurArtwork(
CyclingMediaBackdrop(
mediaKey: metadata.globalKey,
imagePaths: metadata.heroRotationPaths(containerAspectRatio: containerAspect),
fallbackImagePaths: heroArtPaths,
client: _getArtworkMediaClient(context),
localArtworkPathResolver: widget.isOffline
? (path) => _offlineArtworkCandidatePath(context, path)
: null,
allowNetwork: !widget.isOffline,
width: size.width,
height: artHeight,
fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
},
),
// Gradient overlay
Builder(
builder: (context) {
final bgColor = Theme.of(context).scaffoldBackgroundColor;
// Full-height eased scrim. The light global dim (alpha 0.2 at
// the very top) lowers the contrast the ramp has to bridge on
// bright artwork — without it, any fade to solid compresses
// into a visible band above the content stack. The body samples
// easeInOut (continuous curvature — hand-picked stops kink at
// every boundary); the tail instead decays the remaining
// transparency geometrically (~1/8 per sample) because an eased
// zero-slope landing leaves a faint artwork glow that pure-black
// (OLED) backgrounds expose. Solid bg from 94% so nothing ghosts
// where the artwork ends, on any theme.
//
// The ramp spans the art box, not the hero, so on wide windows
// it keeps fading past the hero edge and the bottom of the frame
// dissolves behind the overview instead of being cut off.
const scrimAlphas = [0.20, 0.234, 0.325, 0.453, 0.60, 0.747, 0.875, 0.985, 0.998, 1.0];
const scrimXs = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 0.9375, 1.0];
const solidStop = 0.94;
return RasterizedGradient(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
for (final a in scrimAlphas) bgColor.withValues(alpha: a),
bgColor,
],
stops: [for (final x in scrimXs) solidStop * x, 1.0],
),
);
},
),
],
),
),
),
),
);
}
Widget _buildHeroHeader(BuildContext context, MediaItem metadata) {
// bottom: false — the hero is the top sliver, so the bottom safe-area
// inset would otherwise push the action row far up off the hero edge.
// Left/right stay enabled for the landscape notch.
return SafeArea(
top: false,
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: _buildHeroHeaderContent(context, metadata),
),
);
}
Widget _buildHeroHeaderContent(BuildContext context, MediaItem metadata) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxHeight <= 0 || constraints.maxWidth <= 0) return const SizedBox.shrink();
final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0;
const desiredLogoHeight = 120.0;
const desiredLogoWidth = 400.0;
const actionHeight = 48.0;
final chips = <Widget>[
if (metadata.year != null) _buildMetadataChip('${metadata.year}'),
if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle),
if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)),
if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)),
for (final label in buildMediaQualityLabels(metadata)) _buildMetadataChip(label),
..._buildRatingChips(metadata),
];
// Genres render on their own line below the metadata chips.
final genreChips = [for (final genre in metadata.genres ?? const <String>[]) _buildMetadataChip(genre)];
final showActions = availableHeight >= actionHeight;
final remainingAfterActions = availableHeight - (showActions ? actionHeight : 0);
final showChips = chips.isNotEmpty && remainingAfterActions >= 88;
final chipHeight = showChips ? (remainingAfterActions >= 170 ? 68.0 : 32.0) : 0.0;
final chipActionGap = showChips && showActions ? (availableHeight < 180 ? 8.0 : 16.0) : 0.0;
// Reserve a dedicated genre row, but only when the logo still keeps room
// afterwards so the title isn't crowded out on short heroes.
const genreRowHeight = 32.0;
const genreGap = 8.0;
final showGenres =
showChips &&
genreChips.isNotEmpty &&
remainingAfterActions - chipHeight - chipActionGap - (genreRowHeight + genreGap) >= 52;
final genreBlockHeight = showGenres ? genreRowHeight + genreGap : 0.0;
final remainingForLogo = remainingAfterActions - chipHeight - chipActionGap - genreBlockHeight;
final logoGap = remainingForLogo >= 52 && (showChips || showActions)
? (availableHeight < 180 ? 8.0 : 12.0)
: 0.0;
final logoHeight = (remainingForLogo - logoGap).clamp(0.0, desiredLogoHeight).toDouble();
final showLogo = logoHeight >= 24;
final effectiveLogoGap = showLogo ? logoGap : 0.0;
final logoWidth = desiredLogoWidth.clamp(0.0, constraints.maxWidth).toDouble();
final titleFontSize = (logoHeight * 0.38).clamp(24.0, 40.0).toDouble();
final contentHeight =
(showLogo ? logoHeight + effectiveLogoGap : 0.0) +
chipHeight +
genreBlockHeight +
chipActionGap +
(showActions ? actionHeight : 0.0);
return ClipRect(
child: SizedBox(
height: availableHeight,
child: Align(
alignment: .bottomLeft,
child: SizedBox(
height: contentHeight.clamp(0.0, availableHeight).toDouble(),
child: Align(
alignment: .bottomLeft,
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
if (showLogo) ...[
_buildDetailLogoOrTitle(
context,
metadata,
width: logoWidth,
height: logoHeight,
titleBuilder: (context, title) => _buildDetailTitle(
context,
title,
fontSize: titleFontSize,
fontWeight: .bold,
shadowBlur: 8,
),
),
if (effectiveLogoGap > 0) SizedBox(height: effectiveLogoGap),
],
if (showChips)
ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: chipHeight),
child: Align(
alignment: .bottomLeft,
heightFactor: 1,
child: Wrap(spacing: 8, runSpacing: 8, children: chips),
),
),
),
if (showGenres) ...[
const SizedBox(height: genreGap),
ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: genreRowHeight),
child: Align(
alignment: .bottomLeft,
heightFactor: 1,
child: Wrap(spacing: 8, runSpacing: 8, children: genreChips),
),
),
),
],
if (chipActionGap > 0) SizedBox(height: chipActionGap),
if (showActions) SizedBox(height: actionHeight, child: _buildActionButtons(metadata)),
],
),
),
),
),
),
);
},
);
}
/// Get the primary trailer from the extras list.
MediaItem? _getPrimaryTrailer() {
if (_extras == null || _extras!.isEmpty) return null;
// If there's a trailerKey (Plex `primaryExtraKey`), try to find that specific trailer
final metadata = _fullMetadata ?? _metadata;
if (metadata case PlexMediaItem(:final trailerKey?)) {
// Extract rating key from trailerKey (e.g., "/library/metadata/52601" -> "52601")
final primaryKey = trailerKey.split('/').last;
try {
return _extras!.firstWhere((extra) => extra.id == primaryKey);
} catch (_) {
// Primary key not found, fall through to find any trailer
}
}
try {
return _extras!.firstWhere(_isTrailerExtra);
} catch (_) {
// No trailer found, return null (button won't appear)
return null;
}
}
bool _isTrailerExtra(MediaItem extra) {
if (extra case PlexMediaItem(:final subtype?)) {
return subtype.toLowerCase() == 'trailer';
}
final raw = extra.raw;
final extraType = raw?['ExtraType'] as String?;
final type = raw?['Type'] as String?;
return extraType?.toLowerCase() == 'trailer' || type?.toLowerCase() == 'trailer';
}
/// Build the cast section with locked focus pattern for D-pad navigation
/// Uses same layout pattern as seasons/extras (ListView.builder + Padding(horizontal: 2))
Widget _buildCastSection(MediaItem metadata) {
return SettingValueBuilder<int>(
pref: SettingsService.libraryDensity,
builder: (context, libraryDensity, child) => _buildCastSectionContent(metadata),
);
}
Widget _buildCastSectionContent(MediaItem metadata) {
final roles = metadata.roles!;
return CastMemberStrip(
key: _castStripKey,
members: [for (final actor in roles) (name: actor.tag, secondary: actor.role, imagePath: actor.thumbPath)],
imageClient: getServerBoundMediaClient(context),
onNavigateUp: _focusSectionDirectlyAboveCast,
onNavigateDown: _focusSectionBelowCast,
onMemberTap: (index) => _navigateToActorMedia(roles[index]),
);
}
Widget _buildExtrasSection() {
return SettingValueBuilder<int>(
pref: SettingsService.libraryDensity,
builder: (context, libraryDensity, child) => _buildExtrasSectionContent(),
);
}
Widget _buildExtrasSectionContent() {
final cardWidth = _getResponsiveCardWidth();
// 16:9 aspect ratio for clip thumbnails (cardWidth includes 8px padding on each side)
final posterHeight = (cardWidth - 16) * (9 / 16);
final containerHeight = posterHeight + 52;
return Focus(
focusNode: _extrasFocusNode,
onKeyEvent: _handleExtrasKeyEvent,
child: ListenableBuilder(
listenable: Listenable.merge([_extrasFocusNode, _focusedExtraIndexNotifier]),
builder: (context, _) {
final hasFocus = _extrasFocusNode.hasFocus;
return SizedBox(
height: containerHeight,
child: HorizontalScrollWithArrows(
controller: _extrasScrollController,
builder: (scrollController) => ListView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.symmetric(vertical: 5),
itemCount: _extras!.length,
itemBuilder: (context, index) {
final extra = _extras![index];
final isFocused = hasFocus && index == _focusedExtraIndex;
final cardKey = _extraCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
return Padding(
padding: const EdgeInsets.only(right: 4),
child: FocusBuilders.buildLockedFocusWrapper(
context: context,
isFocused: isFocused,
onTap: () => navigateToVideoPlayer(context, metadata: extra),
delegateFocusBorder: true,
child: MediaCard(
key: cardKey,
item: extra,
width: cardWidth,
height: posterHeight,
forceGridMode: true,
),
),
);
},
),
),
);
},
),
);
}
Widget _buildInfoRow(String label, String value) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: .start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(fontWeight: .w600, color: theme.colorScheme.onSurfaceVariant),
),
),
Expanded(child: Text(value, style: theme.textTheme.bodyLarge)),
],
);
}
String _getPlayButtonLabel(MediaItem metadata) {
// For TV shows - use compact S1E1 format
if (metadata.isShow) {
if (_onDeckEpisode != null) {
final episode = _onDeckEpisode!;
final seasonNum = episode.parentIndex ?? 0;
final episodeNum = episode.index ?? 0;
// Use the same format for both play and resume
// (icon will indicate the difference)
return t.discover.playEpisode(season: seasonNum.toString(), episode: episodeNum.toString());
} else {
final seasonNum = defaultPlaybackSeason(_seasons)?.index ?? 1;
return t.discover.playEpisode(season: seasonNum.toString(), episode: '1');
}
}
// For movies or episodes - NO TEXT, just icon
return '';
}
IconData _getPlayButtonIcon(MediaItem metadata) {
// For TV shows
if (metadata.isShow) {
if (_onDeckEpisode != null) {
final episode = _fresh(_onDeckEpisode!);
// Check if episode has been partially watched
if (episode.viewOffsetMs != null && episode.viewOffsetMs! > 0) {
return Symbols.resume_rounded; // Resume icon
}
}
} else {
// For movies or episodes
if (metadata.viewOffsetMs != null && metadata.viewOffsetMs! > 0) {
return Symbols.resume_rounded; // Resume icon
}
}
return Symbols.play_arrow_rounded; // Default play icon
}
}