diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 2db1ffcc..0ade1250 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -28,6 +28,7 @@ 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 '../providers/playback_state_provider.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; @@ -67,6 +68,8 @@ class _MediaDetailScreenState extends State List _seasons = []; bool _isLoadingSeasons = false; Completer? _seasonsCompleter; + List _episodes = []; + bool _isLoadingEpisodes = false; PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; PlexVideoPlaybackData? _playbackData; @@ -1045,15 +1048,53 @@ class _MediaDetailScreenState extends State // Use server-specific client for this metadata final client = _getClientForMetadata(context); - final seasons = await client?.getChildren(widget.metadata.ratingKey) ?? []; + // 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 = prefs['flattenSeasons']; + final isAlways = flattenSeasons == flattenSeasonsAlways; + final isSingleSeason = flattenSeasons == flattenSeasonsSingleSeason; + final shouldShowEpisodesDirectly = + isAlways || (isSingleSeason && seasonsWithServerId.length == 1); + setStateIfMounted(() { _seasons = seasonsWithServerId; _isLoadingSeasons = false; + if (shouldShowEpisodesDirectly) _isLoadingEpisodes = true; }); + + if (shouldShowEpisodesDirectly) { + try { + final episodeLists = await Future.wait( + seasonsWithServerId.map((season) => client!.getChildren(season.ratingKey)), + ); + final episodes = episodeLists.expand((e) => e).toList(); + setStateIfMounted(() { + _episodes = episodes; + _isLoadingEpisodes = false; + }); + } catch (e) { + setStateIfMounted(() { + _isLoadingEpisodes = false; + }); + } + } } catch (e) { setStateIfMounted(() { _isLoadingSeasons = false; @@ -1560,6 +1601,67 @@ class _MediaDetailScreenState extends State return KeyEventResult.ignored; } + /// Build episode list directly when the library hides seasons for single-season shows + Widget _buildEpisodesList() { + final client = _getClientForMetadata(context); + return Column( + children: _episodes.asMap().entries.map((entry) { + final index = entry.key; + final episode = entry.value; + 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, + 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; + }); + } + }, + ); + }, + 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; + }); + } + }, + onListRefresh: widget.isOffline ? null : _reloadEpisodes, + ); + }).toList(), + ); + } + + Future _reloadEpisodes() async { + if (_seasons.isEmpty) return; + final client = _getClientForMetadata(context); + try { + final episodes = await client?.getChildren(_seasons[0].ratingKey) ?? []; + setStateIfMounted(() { + _episodes = episodes; + }); + } catch (_) {} + } + /// Build vertical seasons list for smaller screens (<600px) Widget _buildVerticalSeasons() { return ListView.separated( @@ -2114,18 +2216,20 @@ class _MediaDetailScreenState extends State const SizedBox(height: 24), ], - // Seasons (for TV shows) + // Seasons / Episodes (for TV shows) if (isShow) ...[ Text( key: _seasonsSectionKey, - t.discover.seasons, + _episodes.isNotEmpty ? t.libraries.groupings.episodes : t.discover.seasons, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - if (_isLoadingSeasons) + if (_isLoadingSeasons || _isLoadingEpisodes) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), ) + else if (_episodes.isNotEmpty) + _buildEpisodesList() else if (_seasons.isEmpty) Padding( padding: const EdgeInsets.all(32), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 54343adb..5de87649 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -292,7 +292,7 @@ class _SeasonDetailScreenState extends State final artworkRef = downloadProvider.getArtworkPaths(globalKey); localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, episode.serverId!); } - return _EpisodeCard( + return EpisodeCard( episode: episode, client: _client, isOffline: widget.isOffline, @@ -332,7 +332,7 @@ class _SeasonDetailScreenState extends State } /// Episode card widget with D-pad long-press support -class _EpisodeCard extends StatefulWidget { +class EpisodeCard extends StatefulWidget { final PlexMetadata episode; final PlexClient? client; final VoidCallback onTap; @@ -342,7 +342,8 @@ class _EpisodeCard extends StatefulWidget { final bool isOffline; final String? localPosterPath; - const _EpisodeCard({ + const EpisodeCard({ + super.key, required this.episode, this.client, required this.onTap, @@ -354,10 +355,10 @@ class _EpisodeCard extends StatefulWidget { }); @override - State<_EpisodeCard> createState() => _EpisodeCardState(); + State createState() => _EpisodeCardState(); } -class _EpisodeCardState extends State<_EpisodeCard> { +class _EpisodeCardState extends State { final _contextMenuKey = GlobalKey(); Offset? _tapPosition; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 730333be..5ed6651e 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1226,6 +1226,19 @@ class PlexClient { return response.data; } + /// Get preferences for a library section. + /// + /// Returns a map of setting id --> value for all settings in the library. + Future> getLibrarySectionPrefs(String sectionId) async { + final response = await _dio.get('/library/sections/$sectionId/prefs'); + final container = _getMediaContainer(response); + if (container == null) return {}; + final settings = container['Setting']; + if (settings == null) return {}; + final list = settings is List ? settings : [settings]; + return {for (final s in list) s['id'] as String: s['value']}; + } + /// Get sessions (currently playing) Future> getSessions() async { final response = await _dio.get('/status/sessions');