import 'dart:async'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.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/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/focus_builders.dart'; import '../widgets/media_card.dart'; import '../i18n/strings.g.dart'; import '../widgets/plex_optimized_image.dart'; import '../utils/plex_image_helper.dart'; import '../../services/plex_client.dart'; import '../services/plex_api_cache.dart'; import '../services/offline_watch_sync_service.dart'; import '../utils/plex_cache_parser.dart'; import '../models/plex_metadata.dart'; import '../models/plex_role.dart'; import '../models/plex_video_playback_data.dart'; import '../utils/content_utils.dart'; import '../utils/rating_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 '../providers/playback_state_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/grid_size_calculator.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../theme/mono_tokens.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 '../utils/desktop_window_padding.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/media_context_menu.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 '../widgets/episode_card.dart'; import 'actor_media_screen.dart'; import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; import '../models/plex_hub.dart'; enum _SyncRuleAction { edit, remove, delete } class MediaDetailScreen extends StatefulWidget { final PlexMetadata 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; const MediaDetailScreen({super.key, required this.metadata, this.isOffline = false, this.initialSeasonIndex}); @override State createState() => _MediaDetailScreenState(); } class _MediaDetailScreenState extends State with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin { List _seasons = []; bool _isLoadingSeasons = false; Completer? _seasonsCompleter; List _episodes = []; bool _isLoadingEpisodes = false; bool _showEpisodesDirectly = false; PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; PlexVideoPlaybackData? _playbackData; bool _isLoadingMetadata = true; List? _extras; List _relatedHubs = []; List> _relatedHubKeys = []; late final ScrollController _scrollController; final ScrollController _extrasScrollController = ScrollController(); bool _watchStateChanged = false; final ValueNotifier _scrollOffset = ValueNotifier(0); // Inline season tabs int _selectedSeasonIndex = 0; final Map> _episodeCache = {}; bool _isLoadingSeasonEpisodes = false; List _seasonTabFocusNodes = []; final Map> _seasonContextMenuKeys = {}; final ScrollController _seasonTabsScrollController = ScrollController(); final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode'); final FocusNode _lastEpisodeFocusNode = FocusNode(debugLabel: 'last_episode'); late final FocusNode _playButtonFocusNode; late final FocusNode _ratingChipFocusNode; Timer? _selectKeyTimer; bool _isSelectKeyDown = false; bool _longPressTriggered = false; static const _longPressDuration = Duration(milliseconds: 500); // Context menu key for the three-dots button final _contextMenuKey = GlobalKey(); // Locked focus pattern for extras int _focusedExtraIndex = 0; late final FocusNode _extrasFocusNode; final Map> _extraCardKeys = {}; final _extrasSectionKey = GlobalKey(); // Locked focus pattern for overview late final FocusNode _overviewFocusNode; final _overviewSectionKey = GlobalKey(); // Locked focus pattern for cast int _focusedCastIndex = 0; late final FocusNode _castFocusNode; final ScrollController _castScrollController = ScrollController(); final _castSectionKey = GlobalKey(); final _seasonsSectionKey = GlobalKey(); // Focus target for the trailing info rows (studio / contentRating) late final FocusNode _infoRowsFocusNode; final _infoRowsSectionKey = GlobalKey(); @override PlexMetadata get serverBoundMetadata => widget.metadata; @override bool get isServerBoundOffline => widget.isOffline; // WatchStateAware: watch the show/movie and all season/episode ratingKeys @override Set? get watchedRatingKeys { final keys = {widget.metadata.ratingKey}; for (final season in _seasons) { keys.add(season.ratingKey); } for (final ep in _episodes) { keys.add(ep.ratingKey); } return keys; } @override String? get watchStateServerId => serverBoundServerId; @override Set? get watchedGlobalKeys { final serverId = serverBoundServerId; if (serverId == null) return null; final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; for (final season in _seasons) { keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); } for (final ep in _episodes) { keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); } return keys; } @override void onWatchStateChanged(WatchStateEvent event) { if (!widget.isOffline) { // If the event matches an episode currently shown, update it directly final epIndex = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); if (epIndex != -1) { _updateEpisodeWatchState(event.ratingKey); } else { _refreshWatchState(); } } } @override Set? get deletionRatingKeys { final keys = {widget.metadata.ratingKey}; for (final season in _seasons) { keys.add(season.ratingKey); } for (final ep in _episodes) { keys.add(ep.ratingKey); } return keys; } @override String? get deletionServerId => serverBoundServerId; @override Set? get deletionGlobalKeys { final serverId = serverBoundServerId; if (serverId == null) return null; final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; for (final season in _seasons) { keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); } for (final ep in _episodes) { keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId)); } return keys; } @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; // When showing episodes directly (season view or flattened), handle episode deletion if (_showEpisodesDirectly) { final epIndex = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey); if (epIndex != -1) { setState(() { _episodes.removeAt(epIndex); }); if (_episodes.isEmpty && (widget.metadata.isSeason || widget.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.ratingKey == event.ratingKey); 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.ratingKey == 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 _refreshWatchState() async { final client = _getClientForMetadata(context); if (client == null) return; try { // Fetch updated metadata + on-deck without showing loader final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); final metadata = result['metadata'] as PlexMetadata?; final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; if (metadata != null) { setStateIfMounted(() { _fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); _onDeckEpisode = onDeckEpisode?.copyWith( serverId: widget.metadata.serverId, serverName: widget.metadata.serverName, ); }); } // Refresh seasons for updated watched counts (also without loader) if (widget.metadata.isShow) { final seasons = await client.getChildren(widget.metadata.ratingKey); // Clear episode cache so stale watch state data isn't reused _episodeCache.clear(); setStateIfMounted(() { _seasons = seasons .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); }); // Re-fetch episodes for the currently selected season if (!_showEpisodesDirectly && _seasons.isNotEmpty) { _fetchSeasonEpisodes(_selectedSeasonIndex); } } else if (widget.metadata.isSeason) { await _fetchAllEpisodes(); } } catch (e) { // Silently fail - data will refresh on next navigation } } /// Update a single episode's watch state without refetching everything Future _updateEpisodeWatchState(String ratingKey) async { final client = _getClientForMetadata(context); if (client == null) return; try { final refreshed = await client.getMetadataWithImages(ratingKey); if (refreshed != null) { setStateIfMounted(() { final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); if (i != -1) { _episodes[i] = refreshed; _syncEpisodeToCache(i, refreshed); } }); } } catch (_) { // Silently fail } } @override void initState() { super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_onScroll); _extrasFocusNode = FocusNode(debugLabel: 'extras_row'); _playButtonFocusNode = FocusNode(debugLabel: 'play_button'); _ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip'); _overviewFocusNode = FocusNode(debugLabel: 'overview'); _castFocusNode = FocusNode(debugLabel: 'cast_row'); _infoRowsFocusNode = FocusNode(debugLabel: 'info_rows'); _loadFullMetadata(); } void _onScroll() { _scrollOffset.value = _scrollController.offset; } @override void dispose() { _scrollController.dispose(); _scrollOffset.dispose(); _extrasScrollController.dispose(); _extrasFocusNode.dispose(); _playButtonFocusNode.dispose(); _ratingChipFocusNode.dispose(); _overviewFocusNode.dispose(); _castFocusNode.dispose(); _infoRowsFocusNode.dispose(); _castScrollController.dispose(); _selectKeyTimer?.cancel(); for (final node in _seasonTabFocusNodes) { node.dispose(); } _seasonTabsScrollController.dispose(); _firstEpisodeFocusNode.dispose(); _lastEpisodeFocusNode.dispose(); super.dispose(); } /// Build title text widget for clear logo fallback Widget _buildTitleText(BuildContext context, String title) { return Align( alignment: Alignment.centerLeft, child: Text( title, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ); } /// Build radial progress indicator for download button /// If progressPercent is null or 0, shows indeterminate spinner Widget _buildRadialProgress(double? progressPercent) { return SizedBox( width: 20, height: 20, child: Stack( alignment: 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(Theme.of(context).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(Theme.of(context).colorScheme.primary), ), ], ), ); } /// Build action buttons row (play, shuffle, download, mark watched) Widget _buildActionButtons(PlexMetadata metadata) { final playButtonLabel = _getPlayButtonLabel(metadata); final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20); Future onPlayPressed() async { // For TV shows, play the OnDeck episode if available // Otherwise, play the first episode of the first season if (metadata.isShow) { if (_onDeckEpisode != null) { appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}'); await navigateToVideoPlayerWithRefresh( context, metadata: _onDeckEpisode!, isOffline: widget.isOffline, onRefresh: _loadFullMetadata, ); } else { // No on deck episode, fetch first episode of first season await _playFirstEpisode(); } } else if (metadata.isSeason) { // For seasons, play the first episode if (_episodes.isNotEmpty) { await navigateToVideoPlayerWithRefresh( context, metadata: _episodes.first, isOffline: widget.isOffline, onRefresh: _loadFullMetadata, ); } else { await _playFirstEpisode(); } } else { appLogger.d('Playing: ${metadata.title}'); // For movies or episodes, play directly await navigateToVideoPlayerWithRefresh( context, metadata: metadata, isOffline: widget.isOffline, onRefresh: _loadFullMetadata, playbackData: _playbackData, ); } } final primaryTrailer = _getPrimaryTrailer(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final colorScheme = Theme.of(context).colorScheme; // In keyboard/d-pad mode, focused buttons get a prominent style. // overlayColor is set to transparent to prevent the Material focus // overlay from dimming the background color we set. final focusBg = colorScheme.inverseSurface; final focusFg = colorScheme.onInverseSurface; final tonalBg = colorScheme.secondaryContainer; final tonalFg = colorScheme.onSecondaryContainer; final noOverlay = WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return Colors.transparent; return null; // default for other states }); ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) { if (!isKeyboardMode) { if (padding != null) { return FilledButton.styleFrom(padding: padding); } return IconButton.styleFrom( minimumSize: const Size(48, 48), maximumSize: const Size(48, 48), foregroundColor: foregroundColor, ); } return ButtonStyle( padding: padding != null ? WidgetStatePropertyAll(padding) : null, minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, overlayColor: noOverlay, backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return focusBg; return tonalBg; }), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return focusFg; return foregroundColor ?? tonalFg; }), ); } return Focus( skipTraversal: true, onKeyEvent: _handlePlayButtonKeyEvent, child: Row( children: [ SizedBox( height: 48, child: FilledButton( focusNode: _playButtonFocusNode, autofocus: isKeyboardMode, onPressed: onPlayPressed, style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)), child: playButtonLabel.isNotEmpty ? Row( mainAxisSize: MainAxisSize.min, children: [ playButtonIcon, const SizedBox(width: 8), Text(playButtonLabel, style: const TextStyle(fontSize: 16)), ], ) : playButtonIcon, ), ), const SizedBox(width: 12), // Trailer button (only if trailer is available) if (primaryTrailer != null) ...[ IconButton.filledTonal( onPressed: () async { await navigateToVideoPlayer(context, metadata: primaryTrailer); }, icon: const AppIcon(Symbols.theaters_rounded, fill: 1), tooltip: t.tooltips.playTrailer, iconSize: 20, style: actionButtonStyle(), ), const SizedBox(width: 12), ], // Shuffle button (only for shows and seasons) if (metadata.isShow || metadata.isSeason) ...[ IconButton.filledTonal( onPressed: () async { await _handleShufflePlayWithQueue(context, metadata); }, icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), tooltip: t.tooltips.shufflePlay, iconSize: 20, style: actionButtonStyle(), ), const SizedBox(width: 12), ], // Download button (hide in offline mode - already downloaded) if (!widget.isOffline) Consumer( builder: (context, downloadProvider, _) { final globalKey = metadata.globalKey; final progress = downloadProvider.getProgress(globalKey); final isQueueing = downloadProvider.isQueueing(globalKey); // Debug logging if (progress != null) { appLogger.d( 'UI rebuilding for $globalKey: status=${progress.status}, progress=${progress.progress}%', ); } // State 1: Queueing (building download queue) if (isQueueing) { return IconButton.filledTonal( onPressed: null, icon: const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), iconSize: 20, style: actionButtonStyle(), ); } // State 2: Queued (waiting to download) if (progress?.status == DownloadStatus.queued) { final currentFile = progress?.currentFile; final tooltip = currentFile != null && currentFile.contains('episodes') ? 'Queued $currentFile' : 'Queued'; return IconButton.filledTonal( onPressed: null, tooltip: tooltip, icon: const AppIcon(Symbols.schedule_rounded, fill: 1), iconSize: 20, style: actionButtonStyle(), ); } // State 3: Downloading (active download) if (progress?.status == DownloadStatus.downloading) { // Show episode count in tooltip for shows/seasons final currentFile = progress?.currentFile; final tooltip = currentFile != null && currentFile.contains('episodes') ? 'Downloading $currentFile' : 'Downloading...'; return IconButton.filledTonal( onPressed: null, tooltip: tooltip, icon: _buildRadialProgress(progress?.progressPercent), iconSize: 20, style: actionButtonStyle(), ); } // State 4: Paused (can resume) if (progress?.status == DownloadStatus.paused) { return IconButton.filledTonal( onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; await downloadProvider.resumeDownload(globalKey, client); if (context.mounted) { showAppSnackBar(context, 'Download resumed'); } }, icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), tooltip: 'Resume download', iconSize: 20, style: actionButtonStyle(foregroundColor: Colors.amber), ); } // State 5: Failed (can retry) if (progress?.status == DownloadStatus.failed) { return IconButton.filledTonal( onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); if (versionConfig == null || !context.mounted) return; await downloadProvider.deleteDownload(globalKey); try { await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadQueued); } } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } }, icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), tooltip: 'Retry download', iconSize: 20, style: actionButtonStyle(foregroundColor: Colors.red), ); } // State 6: Cancelled (can delete or retry) if (progress?.status == DownloadStatus.cancelled) { return IconButton.filledTonal( onPressed: () async { // Show options: Delete or Retry final retry = await showConfirmDialog( context, title: 'Cancelled Download', message: 'This download was cancelled. What would you like to do?', cancelText: t.common.delete, confirmText: 'Retry', ); if (!retry && context.mounted) { await downloadProvider.deleteDownload(globalKey); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); } } else if (retry && context.mounted) { final client = _getClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); if (versionConfig == null || !context.mounted) return; await downloadProvider.deleteDownload(globalKey); try { await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadQueued); } } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } } }, icon: const AppIcon(Symbols.cancel_rounded, fill: 1), tooltip: 'Cancelled download', iconSize: 20, style: actionButtonStyle(foregroundColor: Colors.grey), ); } // State 7: Partial Download (some episodes downloaded, not all) if (progress?.status == DownloadStatus.partial) { final hasSyncRule = downloadProvider.hasSyncRule(globalKey); final currentFile = progress?.currentFile; if (hasSyncRule) { // Synced partial — this is the normal state for sync rules final syncRule = downloadProvider.getSyncRule(globalKey); final isEnabled = syncRule?.enabled ?? true; final tooltip = currentFile != null ? '$currentFile (syncing ${t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?')})' : t.downloads.keepSynced; return IconButton.filledTonal( onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey), tooltip: tooltip, icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), iconSize: 20, style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), ); } final tooltip = currentFile != null ? 'Downloaded $currentFile - Click to complete' : 'Partially downloaded - Click to complete'; return IconButton.filledTonal( onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; final versionConfig = await _resolveDownloadVersion(context, metadata, client); if (versionConfig == null || !context.mounted) return; final count = await downloadProvider.queueMissingEpisodes( metadata, client, versionConfig: versionConfig, ); if (context.mounted) { final message = count > 0 ? t.downloads.episodesQueued(count: count) : 'All episodes already downloaded'; showAppSnackBar(context, message); } }, tooltip: tooltip, icon: const AppIcon(Symbols.downloading_rounded, fill: 1), iconSize: 20, style: actionButtonStyle(foregroundColor: Colors.orange), ); } // State 8: Downloaded/Completed (can delete) if (downloadProvider.isDownloaded(globalKey)) { final hasSyncRule = downloadProvider.hasSyncRule(globalKey); if (hasSyncRule) { // Synced + complete — show sync icon final syncRule = downloadProvider.getSyncRule(globalKey); final isEnabled = syncRule?.enabled ?? true; return IconButton.filledTonal( onPressed: () => _showSyncRuleActions(context, downloadProvider, metadata, globalKey), icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), iconSize: 20, style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), ); } return IconButton.filledTonal( onPressed: () async { // Show delete download confirmation final confirmed = await showDeleteConfirmation( context, title: t.downloads.deleteDownload, message: t.downloads.deleteConfirm(title: metadata.displayTitle), ); if (confirmed && context.mounted) { await downloadProvider.deleteDownload(globalKey); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); } } }, icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), tooltip: t.downloads.deleteDownload, iconSize: 20, style: actionButtonStyle(foregroundColor: Colors.green), ); } // State 9: Not downloaded (default - can download) return IconButton.filledTonal( onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; try { final result = await showDownloadOptionsAndQueue( context, metadata: metadata, client: client, downloadProvider: downloadProvider, ); if (result == null || !context.mounted) return; showSuccessSnackBar(context, result.toSnackBarMessage()); } on CellularDownloadBlockedException { if (context.mounted) { showErrorSnackBar(context, t.settings.cellularDownloadBlocked); } } }, icon: const AppIcon(Symbols.download_rounded, fill: 1), tooltip: t.downloads.downloadNow, iconSize: 20, style: actionButtonStyle(), ); }, ), const SizedBox(width: 12), // Mark as watched/unwatched toggle (works offline too) IconButton.filledTonal( onPressed: () async { try { final isWatched = metadata.isWatched; if (widget.isOffline) { // Offline mode: queue action for later sync final offlineWatch = context.read(); if (isWatched) { await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); } else { await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); } if (mounted) { showAppSnackBar( context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, ); _updateWatchStateOffline(); _loadOfflineOnDeckEpisode(); } } else { // Online mode: send to server final client = _getClientForMetadata(context); if (client == null) return; if (isWatched) { await client.markAsUnwatched(metadata.ratingKey, metadata: metadata); } else { await client.markAsWatched(metadata.ratingKey, metadata: metadata); } if (mounted) { _watchStateChanged = true; showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); } } } catch (e) { if (mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } }, icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, iconSize: 20, style: actionButtonStyle(), ), // Three-dots menu button (hidden in offline mode) if (!widget.isOffline) ...[ const SizedBox(width: 12), MediaContextMenu( key: _contextMenuKey, item: metadata, onRefresh: (_) => _loadFullMetadata(), child: Builder( builder: (buttonContext) => IconButton.filledTonal( onPressed: () { final renderBox = buttonContext.findRenderObject() as RenderBox?; if (renderBox != null) { final position = renderBox.localToGlobal(renderBox.size.center(Offset.zero)); _contextMenuKey.currentState?.showContextMenu(buttonContext, position: position); } }, icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), iconSize: 20, style: actionButtonStyle(), ), ), ), ], ], ), ); } /// Build a metadata chip with optional leading icon or widget Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) { final textWidget = Text( text, style: TextStyle( color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: FontWeight.w500, ), ); final hasLeading = leading != null || icon != null; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8), borderRadius: const BorderRadius.all(Radius.circular(100)), ), child: hasLeading ? Row( mainAxisSize: MainAxisSize.min, children: [ if (leading != null) leading else AppIcon(icon!, fill: 1, color: Theme.of(context).colorScheme.onSecondaryContainer, size: 16), const SizedBox(width: 4), textWidget, ], ) : textWidget, ); } /// Build a rating chip that shows a source icon when available, /// falling back to a generic Material icon. Widget _buildRatingChip(String? imageUri, double value, IconData fallbackIcon) { final info = parseRatingImage(imageUri, value); if (info != null) { return _buildMetadataChip(info.formattedValue, leading: SvgPicture.asset(info.assetPath, width: 16, height: 16)); } return _buildMetadataChip('${(value * 10).toStringAsFixed(0)}%', icon: fallbackIcon); } /// Build all rating chips for the metadata. /// When both critic and audience ratings are from Rotten Tomatoes, /// they are combined into a single badge. List _buildRatingChips(PlexMetadata metadata) { final chips = []; final bothRT = metadata.rating != null && metadata.audienceRating != null && isRottenTomatoes(metadata.ratingImage) && isRottenTomatoes(metadata.audienceRatingImage); if (bothRT) { final critic = parseRatingImage(metadata.ratingImage, metadata.rating)!; final audience = parseRatingImage(metadata.audienceRatingImage, metadata.audienceRating)!; chips.add(_buildCombinedRtChip(critic, audience)); } else { if (metadata.rating != null) { chips.add(_buildRatingChip(metadata.ratingImage, metadata.rating!, Symbols.star_rounded)); } if (metadata.audienceRating != null) { chips.add(_buildRatingChip(metadata.audienceRatingImage, metadata.audienceRating!, Symbols.people_rounded)); } } // User rating chip (tappable) if (!widget.isOffline) { chips.add(_buildUserRatingChip(metadata)); } return chips; } Widget _buildUserRatingChip(PlexMetadata metadata) { final hasRating = metadata.userRating != null && metadata.userRating! > 0; final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0; 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: () => _showRatingDialog(metadata, starValue), borderRadius: 100, disableScale: true, focusColor: Colors.transparent, onFocusChange: (_) => setState(() {}), 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 } return KeyEventResult.ignored; }, child: GestureDetector( onTap: () => _showRatingDialog(metadata, starValue), 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: MainAxisSize.min, children: [ AppIcon( Symbols.star_rounded, fill: hasRating ? 1 : 0, color: showFocus ? fgColor : (hasRating ? Colors.amber : fgColor), size: 16, ), const SizedBox(width: 4), Text( hasRating ? formatRating(starValue) : t.mediaMenu.rate, style: TextStyle(color: fgColor, fontSize: 13, fontWeight: FontWeight.w500), ), ], ), ), ), ); } void _showRatingDialog(PlexMetadata metadata, double currentStarValue) { showModalBottomSheet( context: context, builder: (context) => RatingBottomSheet( currentRating: currentStarValue, onRate: (stars) async { final client = _getClientForMetadata(this.context); if (client == null) return; final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale final success = await client.rateItem(metadata.ratingKey, plexRating); if (success) { setStateIfMounted(() { _fullMetadata = _fullMetadata?.copyWith(userRating: plexRating); }); } }, onClear: () async { final client = _getClientForMetadata(this.context); if (client == null) return; final success = await client.rateItem(metadata.ratingKey, -1); if (success) { setStateIfMounted(() { _fullMetadata = _fullMetadata?.copyWith(userRating: 0); }); } }, ), ); } /// Build a combined RT chip showing critic + audience side by side. Widget _buildCombinedRtChip(RatingInfo critic, RatingInfo audience) { final textStyle = TextStyle( color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: FontWeight.w500, ); return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8), borderRadius: const BorderRadius.all(Radius.circular(100)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset(critic.assetPath, width: 16, height: 16), const SizedBox(width: 4), Text(critic.formattedValue, style: textStyle), const SizedBox(width: 10), SvgPicture.asset(audience.assetPath, width: 16, height: 16), const SizedBox(width: 4), Text(audience.formattedValue, style: textStyle), ], ), ); } /// Get the correct PlexClient for this metadata's server /// Returns null in offline mode or if serverId is null PlexClient? _getClientForMetadata(BuildContext context) { return getServerBoundClient(context); } void _navigateToActorMedia(PlexRole actor) { final personId = actor.id?.toString() ?? actor.tagKey; if (personId == null || widget.metadata.serverId == null) return; Navigator.push( context, MaterialPageRoute( builder: (_) => ActorMediaScreen( actorName: actor.tag, personId: personId, actorThumb: actor.thumb, characterName: actor.role, serverId: widget.metadata.serverId!, serverName: widget.metadata.serverName, ), ), ); } /// Resolve version selection for download using shared utility. Future _resolveDownloadVersion( BuildContext context, PlexMetadata metadata, PlexClient client, ) { return resolveDownloadVersion(context, metadata, client, fallbackVersions: _fullMetadata?.mediaVersions); } /// Shows actions for a synced item: edit count, remove rule, delete downloads. Future _showSyncRuleActions( BuildContext context, DownloadProvider downloadProvider, PlexMetadata metadata, String globalKey, ) async { final syncRule = downloadProvider.getSyncRule(globalKey); 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: globalKey, currentCount: syncRule.episodeCount, ); if (updated && context.mounted) { showSuccessSnackBar(context, t.downloads.syncRuleUpdated); } case _SyncRuleAction.remove: final removed = await confirmAndRemoveSyncRule( context, downloadProvider: downloadProvider, globalKey: globalKey, displayTitle: metadata.displayTitle, ); if (removed && context.mounted) { showSuccessSnackBar(context, t.downloads.syncRuleRemoved); } 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(globalKey); await downloadProvider.deleteDownload(globalKey); if (context.mounted) { showSuccessSnackBar(context, t.downloads.downloadDeleted); } } } } Future _loadFullMetadata() async { setState(() { _isLoadingMetadata = true; }); // Offline mode: try to load full metadata from cache (has clearLogo, summary, etc.) if (widget.isOffline) { final cachedMetadata = await PlexApiCache.instance.getMetadata( widget.metadata.serverId ?? '', widget.metadata.ratingKey, ); if (!mounted) return; setState(() { _fullMetadata = cachedMetadata ?? widget.metadata; _isLoadingMetadata = false; }); if (widget.metadata.isShow) { _loadSeasonsFromDownloads(); // Get offline OnDeck episode _loadOfflineOnDeckEpisode(); } else if (widget.metadata.isSeason) { _seasons = [widget.metadata]; _showEpisodesDirectly = true; _loadEpisodesFromDownloads(); } return; } try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); if (client == null) { // No client available, use passed metadata setState(() { _fullMetadata = widget.metadata; _isLoadingMetadata = false; }); return; } // Fetch full metadata with clearLogo and OnDeck episode final result = await client.getMetadataWithImagesAndOnDeck(widget.metadata.ratingKey); final metadata = result['metadata'] as PlexMetadata?; final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; final playbackData = result['playbackData'] as PlexVideoPlaybackData?; if (!mounted) return; if (metadata != null) { // Preserve serverId from original metadata final metadataWithServerId = metadata.copyWith( serverId: widget.metadata.serverId, serverName: widget.metadata.serverName, ); final onDeckWithServerId = onDeckEpisode?.copyWith( serverId: widget.metadata.serverId, serverName: widget.metadata.serverName, ); setState(() { _fullMetadata = metadataWithServerId; _onDeckEpisode = onDeckWithServerId; _playbackData = playbackData; _isLoadingMetadata = false; }); // Load seasons if it's a show if (metadata.isShow) { _loadSeasons(); } else if (metadata.isSeason) { _seasons = [widget.metadata]; _showEpisodesDirectly = true; _fetchAllEpisodes(); } // Load extras (trailers, behind-the-scenes, etc.) _loadExtras(); _loadRelatedHubs(); return; } // Fallback to passed metadata setState(() { _fullMetadata = widget.metadata; _isLoadingMetadata = false; }); if (widget.metadata.isShow) { _loadSeasons(); } else if (widget.metadata.isSeason) { _seasons = [widget.metadata]; _showEpisodesDirectly = true; _fetchAllEpisodes(); } } catch (e) { // Fallback to passed metadata on error if (!mounted) return; setState(() { _fullMetadata = widget.metadata; _isLoadingMetadata = false; }); if (widget.metadata.isShow) { _loadSeasons(); } else if (widget.metadata.isSeason) { _seasons = [widget.metadata]; _showEpisodesDirectly = true; _fetchAllEpisodes(); } } } Future _loadSeasons() async { _seasonsCompleter = Completer(); setState(() { _isLoadingSeasons = true; }); try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); // Fetch seasons and library prefs in parallel final sectionId = (_fullMetadata ?? widget.metadata).librarySectionID?.toString(); final seasonsFuture = client?.getChildren(widget.metadata.ratingKey) ?? Future.value([]); final prefsFuture = (sectionId != null && client != null) ? client.getLibrarySectionPrefs(sectionId) : Future.value({}); final results = await Future.wait([seasonsFuture, prefsFuture]); final seasons = results[0] as List; final prefs = results[1] as Map; // Preserve serverId for each season final seasonsWithServerId = seasons .map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); // Check the server setting the season display mode const flattenSeasonsAlways = 1; const flattenSeasonsSingleSeason = 2; final flattenSeasons = int.tryParse(prefs['flattenSeasons']?.toString() ?? ''); final isAlways = flattenSeasons == flattenSeasonsAlways; final isSingleSeason = flattenSeasons == flattenSeasonsSingleSeason; final shouldShowEpisodesDirectly = isAlways || seasonsWithServerId.isEmpty || (isSingleSeason && 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; _showEpisodesDirectly = shouldShowEpisodesDirectly; _selectedSeasonIndex = onDeckSeasonIndex; }); if (shouldShowEpisodesDirectly) { await _fetchAllEpisodes(); } else if (seasonsWithServerId.isNotEmpty) { // Fetch episodes for the auto-selected season _fetchSeasonEpisodes(onDeckSeasonIndex); } } catch (e) { setStateIfMounted(() { _isLoadingSeasons = false; }); } finally { if (!(_seasonsCompleter?.isCompleted ?? true)) { _seasonsCompleter?.complete(); } } } /// Load seasons from downloaded episodes (offline mode) void _loadSeasonsFromDownloads() { _seasonsCompleter = Completer(); setState(() { _isLoadingSeasons = true; }); final downloadProvider = context.read(); final episodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); // Group episodes by season final Map> seasonMap = {}; for (final episode in episodes) { final seasonNum = episode.parentIndex ?? 0; seasonMap.putIfAbsent(seasonNum, () => []).add(episode); } // Create season metadata from episodes final seasons = seasonMap.entries.map((entry) { final firstEp = entry.value.first; return PlexMetadata( ratingKey: firstEp.parentRatingKey ?? '', key: '/library/metadata/${firstEp.parentRatingKey}', type: 'season', title: firstEp.parentTitle ?? 'Season ${entry.key}', index: entry.key, leafCount: entry.value.length, thumb: firstEp.parentThumb, parentRatingKey: firstEp.grandparentRatingKey, serverId: widget.metadata.serverId, serverName: widget.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.parentRatingKey ?? ''; _episodeCache[seasonRatingKey] = entry.value..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); } final onDeckSeasonIndex = _findOnDeckSeasonIndex(seasons); setState(() { _seasons = seasons; _isLoadingSeasons = false; _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(); final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.parentRatingKey ?? ''); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == widget.metadata.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); setState(() { _episodes = seasonEpisodes; _isLoadingEpisodes = false; }); } /// 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, or fall back to 0 int _findOnDeckSeasonIndex(List seasons) { // Prefer explicit initial season (from navigation) if (widget.initialSeasonIndex != null && seasons.isNotEmpty) { final idx = seasons.indexWhere((s) => s.index == widget.initialSeasonIndex); if (idx != -1) return idx; } // Fall back to on-deck episode's season if (_onDeckEpisode != null && seasons.isNotEmpty) { final onDeckParentIndex = _onDeckEpisode!.parentIndex; if (onDeckParentIndex != null) { final idx = seasons.indexWhere((s) => s.index == onDeckParentIndex); if (idx != -1) return idx; } } return 0; } /// Fetch episodes for a specific season by index, using cache when available Future _fetchSeasonEpisodes(int seasonIndex) async { if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; // Check cache first final cached = _episodeCache[season.ratingKey]; if (cached != null) { setStateIfMounted(() { _episodes = List.of(cached); _isLoadingSeasonEpisodes = false; }); return; } setStateIfMounted(() => _isLoadingSeasonEpisodes = true); try { if (widget.isOffline) { // Offline: load from downloads final downloadProvider = context.read(); final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); _episodeCache[season.ratingKey] = seasonEpisodes; setStateIfMounted(() { _episodes = List.of(seasonEpisodes); _isLoadingSeasonEpisodes = false; }); } else { final client = _getClientForMetadata(context); if (client == null) { setStateIfMounted(() => _isLoadingSeasonEpisodes = false); return; } final episodes = await client.getChildren(season.ratingKey); final episodesWithServerId = episodes .map((e) => e.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); _episodeCache[season.ratingKey] = episodesWithServerId; setStateIfMounted(() { _episodes = List.of(episodesWithServerId); _isLoadingSeasonEpisodes = false; }); } } catch (e) { setStateIfMounted(() => _isLoadingSeasonEpisodes = false); } } /// Load extras (trailers, behind-the-scenes, etc.) Future _loadExtras() async { // Only load extras for movies and shows if (!widget.metadata.isMovie && !widget.metadata.isShow) { return; } // Skip in offline mode (no server available) if (widget.isOffline) { return; } try { final client = _getClientForMetadata(context); if (client == null) { return; } final extras = await client.getExtras(widget.metadata.ratingKey); // Preserve serverId for each extra (needed for multi-server setups) final extrasWithServerId = extras .map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); setStateIfMounted(() { _extras = extrasWithServerId; }); } catch (e) { // Silently fail - extras section won't appear if fetch fails } } /// Load related hubs (collections, similar, "more from" director/actor) Future _loadRelatedHubs() async { if (!widget.metadata.isMovie && !widget.metadata.isShow) { return; } if (widget.isOffline) { return; } try { final client = _getClientForMetadata(context); if (client == null) return; final hubs = await client.getRelatedHubs(widget.metadata.ratingKey); setStateIfMounted(() { _relatedHubs = hubs; _relatedHubKeys = List.generate(hubs.length, (_) => GlobalKey()); }); } catch (e) { // Silently fail - related sections won't appear if fetch fails } } /// 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 ?? widget.metadata; if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (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 ?? widget.metadata; if (metadata.role != null && metadata.role!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else { _focusSectionAboveCast(); } } bool get _hasInfoRows { final metadata = _fullMetadata ?? widget.metadata; return metadata.studio != null || metadata.contentRating != null; } /// Focus the trailing info rows (studio / 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); } /// Intercept DOWN from the play button row to focus the first available section KeyEventResult _handlePlayButtonKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (!event.isActionable) return KeyEventResult.ignored; // UP: focus the rating chip if available if (key.isUpKey) { if (!widget.isOffline) { _ratingChipFocusNode.requestFocus(); return KeyEventResult.handled; } return KeyEventResult.handled; } if (!key.isDownKey) return KeyEventResult.ignored; final metadata = _fullMetadata ?? widget.metadata; // DOWN order: overview → seasons → cast → extras if (metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); return KeyEventResult.handled; } if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { // Focus the selected season tab chip _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); return KeyEventResult.handled; } if (_episodes.isNotEmpty) { _firstEpisodeFocusNode.requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); return KeyEventResult.handled; } if (metadata.role != null && metadata.role!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); return KeyEventResult.handled; } if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); return KeyEventResult.handled; } if (_relatedHubs.isNotEmpty) { _relatedHubKeys.first.currentState?.requestFocusFromMemory(); return KeyEventResult.handled; } return KeyEventResult.handled; // consume to prevent unwanted traversal } /// Get the responsive card width used by seasons/extras/cast rows. /// Uses the shared grid size calculator for consistency with library grids. double _getResponsiveCardWidth() { final density = context.read().libraryDensity; final availableWidth = MediaQuery.of(context).size.width; return GridSizeCalculator.getCellWidth(availableWidth, context, density); } /// Handle key events for the overview section KeyEventResult _handleOverviewKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (key.isBackKey) return KeyEventResult.ignored; if (!event.isActionable) return KeyEventResult.ignored; final metadata = _fullMetadata ?? widget.metadata; // UP: always play button (overview is directly below play) if (key.isUpKey) { _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); _playButtonFocusNode.requestFocus(); return KeyEventResult.handled; } if (key.isDownKey) { if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (_episodes.isNotEmpty) { _firstEpisodeFocusNode.requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); } else if (metadata.role != null && metadata.role!.isNotEmpty) { _castFocusNode.requestFocus(); _scrollSectionIntoView(_castSectionKey); } else if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); } else if (_relatedHubs.isNotEmpty) { _relatedHubKeys.first.currentState?.requestFocusFromMemory(); } else if (_hasInfoRows) { _focusInfoRows(); } return KeyEventResult.handled; } // LEFT/RIGHT/SELECT: consume to prevent unwanted traversal if (key.isLeftKey || key.isRightKey || key.isSelectKey) { return KeyEventResult.handled; } return KeyEventResult.ignored; } /// Show context menu for a season tab void _showSeasonTabContextMenu(int index, {Offset? position}) { final key = _seasonContextMenuKeys.putIfAbsent(index, () => GlobalKey()); 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 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()); Offset? tapPosition; return Padding( padding: const EdgeInsets.only(right: 8), child: MediaContextMenu( key: contextMenuKey, item: season, onRefresh: (_) { _watchStateChanged = true; }, 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, 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: () { _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; // Handle SELECT with long-press detection if (key.isSelectKey) { if (event is KeyDownEvent) { _selectKeyTimer?.cancel(); _isSelectKeyDown = true; _longPressTriggered = false; _selectKeyTimer = Timer(_longPressDuration, () { if (!mounted) return; if (_isSelectKeyDown) { _longPressTriggered = true; SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); _extraCardKeys[_focusedExtraIndex]?.currentState?.showContextMenu(); } }); return KeyEventResult.handled; } else if (event is KeyRepeatEvent) { return KeyEventResult.handled; } else if (event is KeyUpEvent) { final timerWasActive = _selectKeyTimer?.isActive ?? false; _selectKeyTimer?.cancel(); if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) { if (_focusedExtraIndex < _extras!.length) { navigateToVideoPlayer(context, metadata: _extras![_focusedExtraIndex]); } } _isSelectKeyDown = false; _longPressTriggered = false; return KeyEventResult.handled; } } if (!event.isActionable) return KeyEventResult.ignored; if (_extras == null || _extras!.isEmpty) return KeyEventResult.ignored; // LEFT: previous extra if (key.isLeftKey) { if (_focusedExtraIndex > 0) { setState(() => _focusedExtraIndex--); scrollListToIndex( _extrasScrollController, _focusedExtraIndex, itemExtent: _getResponsiveCardWidth() + 4, leadingPadding: 0, ); } return KeyEventResult.handled; } // RIGHT: next extra if (key.isRightKey) { if (_focusedExtraIndex < _extras!.length - 1) { setState(() => _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; } /// Handle key events for the cast row (locked focus pattern) KeyEventResult _handleCastKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (key.isBackKey) return KeyEventResult.ignored; if (!event.isActionable) return KeyEventResult.ignored; final metadata = _fullMetadata ?? widget.metadata; final roleCount = metadata.role?.length ?? 0; // LEFT: previous cast member if (key.isLeftKey) { if (_focusedCastIndex > 0) { setState(() => _focusedCastIndex--); scrollListToIndex( _castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 6 + 4, leadingPadding: 0, ); } return KeyEventResult.handled; } // RIGHT: next cast member if (key.isRightKey) { if (_focusedCastIndex < roleCount - 1) { setState(() => _focusedCastIndex++); scrollListToIndex( _castScrollController, _focusedCastIndex, itemExtent: _getResponsiveCardWidth() + 6 + 4, leadingPadding: 0, ); } return KeyEventResult.handled; } if (key.isUpKey) { // If episodes are visible, focus the last episode (cast is right below episodes) if (_episodes.isNotEmpty) { final target = _episodes.length == 1 ? _firstEpisodeFocusNode : _lastEpisodeFocusNode; target.requestFocus(); } else { _focusSectionAboveCast(); } return KeyEventResult.handled; } // DOWN: extras → related hubs → info rows → consume if (key.isDownKey) { if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); } else if (_relatedHubs.isNotEmpty) { _relatedHubKeys.first.currentState?.requestFocusFromMemory(); } else if (_hasInfoRows) { _focusInfoRows(); } return KeyEventResult.handled; } // SELECT: navigate to actor media if (key.isSelectKey) { final metadata = _fullMetadata ?? widget.metadata; if (_focusedCastIndex < (metadata.role?.length ?? 0)) { _navigateToActorMedia(metadata.role![_focusedCastIndex]); } return KeyEventResult.handled; } return KeyEventResult.ignored; } /// Handle vertical navigation between related hub sections bool _handleRelatedHubNavigation(int hubIndex, bool isUp) { if (_relatedHubKeys.isEmpty) return false; if (isUp && hubIndex == 0) { if (_extras != null && _extras!.isNotEmpty) { _extrasFocusNode.requestFocus(); _scrollSectionIntoView(_extrasSectionKey); } else { _focusSectionAboveExtras(); } return true; } final targetIndex = isUp ? hubIndex - 1 : hubIndex + 1; if (targetIndex < 0 || targetIndex >= _relatedHubKeys.length) { if (!isUp && _hasInfoRows) _focusInfoRows(); return true; // at boundary, consume } _relatedHubKeys[targetIndex].currentState?.requestFocusFromMemory(); return true; } /// Handle key events for the trailing info rows (studio / contentRating). /// UP returns to the previous focusable section; all other directions consume. 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; } // DOWN / LEFT / RIGHT / SELECT: consume — info rows are the terminal row. return KeyEventResult.handled; } IconData _getRelatedHubIcon(PlexHub 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; } /// Build episode list directly when the library hides seasons for single-season shows Widget _buildEpisodesList() { final client = _getClientForMetadata(context); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, itemCount: _episodes.length, itemBuilder: (context, index) { final episode = _episodes[index]; String? localPosterPath; if (widget.isOffline && episode.serverId != null) { final artworkRef = context.read().getArtworkPaths(episode.globalKey); localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, episode.serverId!); } return EpisodeCard( episode: episode, client: client, isOffline: widget.isOffline, autofocus: false, focusNode: index == 0 ? _firstEpisodeFocusNode : index == _episodes.length - 1 && _episodes.length > 1 ? _lastEpisodeFocusNode : null, onNavigateUp: index == 0 ? () { if (!_showEpisodesDirectly) { _focusSelectedSeasonTab(); } else if ((_fullMetadata ?? widget.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: () async { final refreshed = await client?.getMetadataWithImages(episode.ratingKey); if (refreshed != null) { setStateIfMounted(() { _episodes[index] = refreshed; _syncEpisodeToCache(index, refreshed); }); } }, ); }, onRefresh: widget.isOffline ? null : (ratingKey) async { final refreshed = await client?.getMetadataWithImages(ratingKey); if (refreshed != null) { setStateIfMounted(() { final i = _episodes.indexWhere((e) => e.ratingKey == ratingKey); if (i != -1) { _episodes[i] = refreshed; _syncEpisodeToCache(i, refreshed); } }); } }, onListRefresh: widget.isOffline ? null : _refreshCurrentEpisodes, ); }, ); } /// Sync an updated episode back into the episode cache void _syncEpisodeToCache(int episodeIndex, PlexMetadata updated) { if (_showEpisodesDirectly || _seasons.isEmpty) return; if (_selectedSeasonIndex >= _seasons.length) return; final season = _seasons[_selectedSeasonIndex]; final cached = _episodeCache[season.ratingKey]; if (cached != null && episodeIndex < cached.length) { cached[episodeIndex] = updated; } } /// Refresh episodes for the current context (inline season or all flattened) Future _refreshCurrentEpisodes() async { if (_showEpisodesDirectly) { await _fetchAllEpisodes(); } else if (_seasons.isNotEmpty) { // Clear cache for current season and re-fetch final season = _seasons[_selectedSeasonIndex]; _episodeCache.remove(season.ratingKey); await _fetchSeasonEpisodes(_selectedSeasonIndex); } } Future _fetchAllEpisodes() async { if (_seasons.isEmpty) return; final client = _getClientForMetadata(context); if (client == null) return; setStateIfMounted(() => _isLoadingEpisodes = true); try { final episodeLists = await Future.wait(_seasons.map((season) => client.getChildren(season.ratingKey))); setStateIfMounted(() { _episodes = episodeLists.expand((e) => e).toList(); _isLoadingEpisodes = false; }); } catch (_) { setStateIfMounted(() => _isLoadingEpisodes = false); } } /// Load the next unwatched episode for offline mode (offline OnDeck) Future _loadOfflineOnDeckEpisode() async { final offlineWatchProvider = context.read(); final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey); if (nextEpisode != null) { setStateIfMounted(() { _onDeckEpisode = nextEpisode; }); appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}'); } } /// Offline: update viewCount in the API cache and re-read metadata from it. Future _updateWatchStateOffline() async { final serverId = widget.metadata.serverId; if (serverId == null) return; final ratingKey = widget.metadata.ratingKey; final cache = PlexApiCache.instance; final syncService = context.read(); final endpoint = '/library/metadata/$ratingKey'; final cached = await cache.get(serverId, endpoint); final json = PlexCacheParser.extractFirstMetadata(cached); if (json == null) return; final localStatus = await syncService.getLocalWatchStatus('$serverId:$ratingKey'); if (localStatus == true) { json['viewCount'] = 1; } else if (localStatus == false) { json['viewCount'] = 0; json['viewOffset'] = 0; } await cache.put(serverId, endpoint, { 'MediaContainer': { 'Metadata': [json], }, }); setStateIfMounted(() { _fullMetadata = PlexMetadata.fromJsonWithImages( json, ).copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); }); } Future _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; } // Skip Season 0 (Specials) — prefer the first regular season final firstSeason = _seasons.firstWhere((s) => (s.index ?? 0) > 0, orElse: () => _seasons.first); // Get episodes of the first season List episodes; if (!mounted) return; if (widget.isOffline) { // In offline mode, get episodes from downloads final downloadProvider = context.read(); final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.metadata.ratingKey); // Filter to episodes of this season episodes = allEpisodes.where((ep) => ep.parentIndex == firstSeason.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); } else { final client = _getClientForMetadata(context); if (client == null) return; episodes = await client.getChildren(firstSeason.ratingKey); } if (episodes.isEmpty) { if (mounted) { showErrorSnackBar(context, t.messages.noEpisodesFound); } return; } // Play the first episode final firstEpisode = episodes.first; // Preserve serverId for the episode final episodeWithServerId = firstEpisode.copyWith( serverId: widget.metadata.serverId, serverName: widget.metadata.serverName, ); 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 using play queues /// Note: Shuffle requires server connectivity (play queue API) Future _handleShufflePlayWithQueue(BuildContext context, PlexMetadata metadata) async { // Shuffle requires server connectivity if (widget.isOffline) { if (context.mounted) { showErrorSnackBar(context, 'Shuffle not available offline'); } return; } final client = _getClientForMetadata(context); if (client == null) return; final playbackState = context.read(); try { // Show loading indicator if (context.mounted) { showDialog( context: context, barrierDismissible: false, builder: (context) => const Center(child: CircularProgressIndicator()), ); } // Determine the rating key for the play queue String showRatingKey; if (metadata.isShow) { showRatingKey = metadata.ratingKey; } else if (metadata.isSeason) { // For seasons, we need the show's rating key // The season's parentRatingKey should point to the show if (metadata.parentRatingKey == null) { throw Exception('Season is missing parentRatingKey'); } showRatingKey = metadata.parentRatingKey!; } else { throw Exception('Shuffle play only works for shows and seasons'); } // Create a shuffled play queue for the show final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1); // Close loading indicator if (context.mounted) { Navigator.pop(context); } if (playQueue == null || playQueue.items == null || playQueue.items!.isEmpty) { if (context.mounted) { showErrorSnackBar(context, t.messages.noEpisodesFound); } return; } // Initialize playback state with the play queue await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey); // Set the client for the playback state provider playbackState.setClient(client); // Navigate to the first episode in the shuffled queue final firstEpisode = playQueue.items!.first.copyWith( serverId: metadata.serverId, serverName: metadata.serverName, ); if (context.mounted) { await navigateToVideoPlayer(context, metadata: firstEpisode); // Refresh metadata when returning from video player _loadFullMetadata(); } } catch (e) { // Close loading indicator if it's still open if (context.mounted && Navigator.canPop(context)) { Navigator.pop(context); } if (context.mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } } @override Widget build(BuildContext context) { // Use full metadata if loaded, otherwise use passed metadata final metadata = _fullMetadata ?? widget.metadata; final isShow = metadata.isShow; final isMobile = PlatformDetector.isMobile(context); final isTv = PlatformDetector.isTV(); KeyEventResult handleBack(FocusNode _, KeyEvent event) => handleBackKeyNavigation(context, event, result: _watchStateChanged); // Show loading state while fetching full metadata if (_isLoadingMetadata) { final loading = Focus( onKeyEvent: handleBack, child: Scaffold( appBar: AppBar(), body: const Center(child: CircularProgressIndicator()), ), ); final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); if (!blockSystemBack) { return loading; } return PopScope( canPop: false, // Prevent system back from double-popping on Android keyboard/TV // ignore: no-empty-block - required callback, blocks system back on Android TV onPopInvokedWithResult: (didPop, result) {}, child: loading, ); } // Determine header height based on screen size final size = MediaQuery.of(context).size; final headerHeight = size.height * 0.6; final content = OverlaySheetHost( child: Focus( onKeyEvent: handleBack, child: Scaffold( body: Stack( children: [ CustomScrollView( controller: _scrollController, slivers: [ // Hero header with background art SliverToBoxAdapter( child: Stack( children: [ // Background Art (fixed height, no parallax) SizedBox( height: headerHeight, width: double.infinity, child: (metadata.art != null || metadata.backgroundSquare != null) ? Builder( builder: (context) { final containerAspect = size.width / headerHeight; final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); // Check for offline local file first if (widget.isOffline && widget.metadata.serverId != null) { final localPath = context.read().getArtworkLocalPath( widget.metadata.serverId!, heroArtPath, ); if (localPath != null && File(localPath).existsSync()) { return PlexOptimizedImage( client: null, imagePath: null, localFilePath: localPath, fit: BoxFit.cover, imageType: ImageType.art, errorWidget: (context, url, error) => const PlaceholderContainer(), ); } // Offline but no local file - show placeholder return const PlaceholderContainer(); } // Online - use network image final client = _getClientForMetadata(context); final mediaQuery = MediaQuery.of(context); final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); final imageUrl = PlexImageHelper.getOptimizedImageUrl( client: client, thumbPath: heroArtPath, maxWidth: mediaQuery.size.width, maxHeight: mediaQuery.size.height * 0.6, devicePixelRatio: dpr, imageType: ImageType.art, ); return blurArtwork( CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, placeholder: (context, url) => const PlaceholderContainer(), errorWidget: (context, url, error) => const PlaceholderContainer(), ), ); }, ) : const PlaceholderContainer(), ), // Gradient overlay Positioned( top: 0, left: 0, right: 0, bottom: -1, // Extend 1px past to prevent subpixel gap child: Builder( builder: (context) { final bgColor = Theme.of(context).scaffoldBackgroundColor; return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], stops: const [0.3, 0.8, 1.0], ), ), ); }, ), ), // Content at bottom Positioned( bottom: 16, left: 0, right: 0, child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Clear logo or title if (metadata.clearLogo != null) SizedBox( height: 120, width: 400, child: Builder( builder: (context) { // Check for offline local file first if (widget.isOffline && widget.metadata.serverId != null) { final localPath = context.read().getArtworkLocalPath( widget.metadata.serverId!, metadata.clearLogo, ); if (localPath != null && File(localPath).existsSync()) { return PlexOptimizedImage( client: null, imagePath: null, localFilePath: localPath, fit: BoxFit.contain, alignment: Alignment.centerLeft, imageType: ImageType.logo, errorWidget: (context, url, error) => _buildTitleText(context, metadata.displayTitle), ); } // Offline but no local file - show title text return _buildTitleText(context, metadata.displayTitle); } // Online - use network image final client = _getClientForMetadata(context); final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); final logoUrl = PlexImageHelper.getOptimizedImageUrl( client: client, thumbPath: metadata.clearLogo, maxWidth: 400, maxHeight: 120, devicePixelRatio: dpr, imageType: ImageType.logo, ); return blurArtwork( CachedNetworkImage( imageUrl: logoUrl, filterQuality: FilterQuality.medium, fit: BoxFit.contain, alignment: Alignment.centerLeft, memCacheWidth: (400 * dpr).clamp(200, 800).round(), placeholder: (context, url) => Align( alignment: Alignment.centerLeft, child: Text( metadata.displayTitle, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white.withValues(alpha: 0.3), fontWeight: FontWeight.bold, shadows: [ Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8), ], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), errorWidget: (context, url, error) { return _buildTitleText(context, metadata.displayTitle); }, ), sigma: 10, clip: false, ); }, ), ) else Text( metadata.displayTitle, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white, fontWeight: FontWeight.bold, shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), // Metadata chips Wrap( spacing: 8, runSpacing: 8, children: [ if (metadata.year != null) _buildMetadataChip('${metadata.year}'), if (metadata.editionTitle != null) _buildMetadataChip(metadata.editionTitle!), if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)), if (metadata.duration != null) _buildMetadataChip(formatDurationTextual(metadata.duration!)), ..._buildRatingChips(metadata), ], ), const SizedBox(height: 16), // Action buttons _buildActionButtons(metadata), ], ), ), ), ), ], ), ), // Main content SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Summary if (metadata.summary != null && metadata.summary!.isNotEmpty) ...[ Text( key: _overviewSectionKey, t.discover.overview, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), Focus( focusNode: _overviewFocusNode, onKeyEvent: _handleOverviewKeyEvent, onFocusChange: (_) => setState(() {}), child: Builder( builder: (context) { final showFocus = _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); return AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.all(4), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8)), border: Border.all( color: showFocus ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5) : Colors.transparent, width: 2, ), ), child: () { final summaryStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6); if (isTv) { return Text(metadata.summary!, style: summaryStyle); } return CollapsibleText( text: metadata.summary!, maxLines: isMobile ? 6 : 4, style: summaryStyle, ); }(), ); }, ), ), const SizedBox(height: 24), ], // Seasons / Episodes (for TV shows and seasons) if (isShow && !_showEpisodesDirectly) ...[ // Season tabs + inline episodes if (_isLoadingSeasons) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), ) else if (_seasons.isEmpty) Padding( padding: const EdgeInsets.all(32), child: Center( child: Text( t.messages.noSeasonsFound, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), ), ), ) else ...[ Text( key: _seasonsSectionKey, t.libraries.groupings.episodes, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), _buildSeasonTabs(), const SizedBox(height: 16), if (_isLoadingSeasonEpisodes) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), ) else if (_episodes.isNotEmpty) _buildEpisodesList() else Padding( padding: const EdgeInsets.all(32), child: Center( child: Text( t.messages.noEpisodesFoundGeneral, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), ), ), ), ], const SizedBox(height: 24), ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ // Server says flatten — existing behavior unchanged Text( key: _seasonsSectionKey, t.libraries.groupings.episodes, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), if (_isLoadingSeasons || _isLoadingEpisodes) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), ) else if (_episodes.isNotEmpty) _buildEpisodesList() else Padding( padding: const EdgeInsets.all(32), child: Center( child: Text( t.messages.noEpisodesFoundGeneral, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), ), ), ), const SizedBox(height: 24), ], // Cast if (metadata.role != null && metadata.role!.isNotEmpty) ...[ Text( key: _castSectionKey, t.discover.cast, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), 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: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), 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], icon: _getRelatedHubIcon(_relatedHubs[i]), inset: true, onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), ), const SizedBox(height: 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: CrossAxisAlignment.start, children: [ if (metadata.studio != null) ...[ _buildInfoRow(t.discover.studio, metadata.studio!), const SizedBox(height: 12), ], if (metadata.contentRating != null) ...[ _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), const SizedBox(height: 12), ], ], ), ), ], ), ), ), SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)), ], ), // Sticky top bar with fading background Positioned( top: 0, left: 0, right: 0, child: ValueListenableBuilder( 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: Container( height: MediaQuery.of(context).padding.top + 58, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8), Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.5), Theme.of(context).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), ), context: context, )!, ), ], ), ), ), ); final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); if (!blockSystemBack) { return content; } return PopScope( canPop: false, // Prevent system back from double-popping on Android keyboard/TV // ignore: no-empty-block - required callback, blocks system back on Android TV onPopInvokedWithResult: (didPop, result) {}, child: content, ); } /// Get the primary trailer from the extras list PlexMetadata? _getPrimaryTrailer() { if (_extras == null || _extras!.isEmpty) return null; // If there's a primaryExtraKey, try to find that specific trailer final metadata = _fullMetadata ?? widget.metadata; if (metadata.primaryExtraKey != null) { // Extract rating key from primaryExtraKey (e.g., "/library/metadata/52601" -> "52601") final primaryKey = metadata.primaryExtraKey!.split('/').last; try { return _extras!.firstWhere((extra) => extra.ratingKey == primaryKey); } catch (_) { // Primary key not found, fall through to find any trailer } } // Otherwise, find the first item with subtype 'trailer' try { return _extras!.firstWhere((extra) => extra.subtype == 'trailer'); } catch (_) { // No trailer found, return null (button won't appear) return null; } } /// 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(PlexMetadata metadata) { final cardWidth = _getResponsiveCardWidth(); const innerPadding = 3.0; final imageSize = cardWidth; // image + inner padding + text area + outer list padding + focus scale headroom final containerHeight = imageSize + innerPadding * 2 + 66 + 16; final hasFocus = _castFocusNode.hasFocus; return Focus( focusNode: _castFocusNode, onKeyEvent: _handleCastKeyEvent, onFocusChange: (_) => setState(() {}), child: SizedBox( height: containerHeight, child: HorizontalScrollWithArrows( controller: _castScrollController, builder: (scrollController) => ListView.builder( controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: const EdgeInsets.symmetric(vertical: 5), itemCount: metadata.role!.length, itemBuilder: (context, index) { final actor = metadata.role![index]; final isFocused = hasFocus && index == _focusedCastIndex; return Padding( padding: const EdgeInsets.only(right: 4), child: FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isFocused, borderRadius: tokens(context).radiusSm, onTap: () => _navigateToActorMedia(actor), child: Padding( padding: const EdgeInsets.all(innerPadding), child: SizedBox( width: cardWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: PlexOptimizedImage( client: _getClientForMetadata(context), imagePath: actor.thumb, width: imageSize, height: imageSize, fit: BoxFit.cover, imageType: ImageType.avatar, fallbackIcon: Symbols.person_rounded, ), ), const SizedBox(height: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( actor.tag, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (actor.role != null) ...[ const SizedBox(height: 2), Text( actor.role!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ), ), ); }, ), ), ), ); } Widget _buildExtrasSection() { 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 + 66; final hasFocus = _extrasFocusNode.hasFocus; return Focus( focusNode: _extrasFocusNode, onKeyEvent: _handleExtrasKeyEvent, child: SizedBox( height: containerHeight, child: HorizontalScrollWithArrows( controller: _extrasScrollController, builder: (scrollController) => ListView.builder( 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()); return Padding( padding: const EdgeInsets.only(right: 4), child: FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isFocused, onTap: () => navigateToVideoPlayer(context, metadata: extra), child: MediaCard( key: cardKey, item: extra, width: cardWidth, height: posterHeight, forceGridMode: true, ), ), ); }, ), ), ), ); } Widget _buildInfoRow(String label, String value) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: Text( label, style: TextStyle(fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyLarge)), ], ); } String _getPlayButtonLabel(PlexMetadata 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 { // No on deck episode, will play first episode return t.discover.playEpisode(season: '1', episode: '1'); } } // For movies or episodes - NO TEXT, just icon return ''; } IconData _getPlayButtonIcon(PlexMetadata metadata) { // For TV shows if (metadata.isShow) { if (_onDeckEpisode != null) { final episode = _onDeckEpisode!; // Check if episode has been partially watched if (episode.viewOffset != null && episode.viewOffset! > 0) { return Symbols.resume_rounded; // Resume icon } } } else { // For movies or episodes if (metadata.viewOffset != null && metadata.viewOffset! > 0) { return Symbols.resume_rounded; // Resume icon } } return Symbols.play_arrow_rounded; // Default play icon } }