refactor: remove SeasonDetailScreen, unify into MediaDetailScreen

Extract EpisodeCard to its own widget file. MediaDetailScreen now handles
isSeason by showing episodes directly, with proper watch state tracking,
deletion handling, and D-pad focus navigation. Hide empty overview sections.
This commit is contained in:
edde746
2026-03-11 23:08:39 +01:00
parent 7febfa9771
commit 5538e0627b
5 changed files with 145 additions and 361 deletions
+131 -28
View File
@@ -51,7 +51,7 @@ 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 'season_detail_screen.dart';
import '../widgets/episode_card.dart';
class MediaDetailScreen extends StatefulWidget {
final PlexMetadata metadata;
@@ -121,12 +121,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
@override
bool get isServerBoundOffline => widget.isOffline;
// WatchStateAware: watch the show/movie and all season ratingKeys
// WatchStateAware: watch the show/movie and all season/episode ratingKeys
@override
Set<String>? get watchedRatingKeys {
final keys = <String>{widget.metadata.ratingKey};
for (final season in _seasons) {
keys.add(season.ratingKey);
if (_showEpisodesDirectly) {
for (final ep in _episodes) {
keys.add(ep.ratingKey);
}
} else {
for (final season in _seasons) {
keys.add(season.ratingKey);
}
}
return keys;
}
@@ -140,25 +146,40 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (serverId == null) return null;
final keys = <String>{toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)};
for (final season in _seasons) {
keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId));
if (_showEpisodesDirectly) {
for (final ep in _episodes) {
keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId));
}
} else {
for (final season in _seasons) {
keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId));
}
}
return keys;
}
@override
void onWatchStateChanged(WatchStateEvent event) {
// Lightweight refresh - no loader, preserves scroll position
if (!widget.isOffline) {
_refreshWatchState();
if (_showEpisodesDirectly) {
_updateEpisodeWatchState(event.ratingKey);
} else {
_refreshWatchState();
}
}
}
@override
Set<String>? get deletionRatingKeys {
final keys = <String>{widget.metadata.ratingKey};
for (final season in _seasons) {
keys.add(season.ratingKey);
if (_showEpisodesDirectly) {
for (final ep in _episodes) {
keys.add(ep.ratingKey);
}
} else {
for (final season in _seasons) {
keys.add(season.ratingKey);
}
}
return keys;
}
@@ -172,15 +193,37 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (serverId == null) return null;
final keys = <String>{toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)};
for (final season in _seasons) {
keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId));
if (_showEpisodesDirectly) {
for (final ep in _episodes) {
keys.add(toServerBoundGlobalKey(ep.ratingKey, serverId: ep.serverId ?? serverId));
}
} else {
for (final season in _seasons) {
keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId));
}
}
return keys;
}
@override
void onDeletionEvent(DeletionEvent event) {
if (widget.isOffline) return;
// 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 && 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);
@@ -258,12 +301,31 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
.map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
.toList();
});
} 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<void> _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;
});
}
} catch (_) {
// Silently fail
}
}
@override
void initState() {
super.initState();
@@ -365,6 +427,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// 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
@@ -963,6 +1037,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_loadSeasonsFromDownloads();
// Get offline OnDeck episode
_loadOfflineOnDeckEpisode();
} else if (widget.metadata.isSeason) {
_seasons = [widget.metadata];
_showEpisodesDirectly = true;
_loadEpisodesFromDownloads();
}
return;
}
@@ -1008,6 +1086,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// 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.)
@@ -1024,6 +1106,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (widget.metadata.isShow) {
_loadSeasons();
} else if (widget.metadata.isSeason) {
_seasons = [widget.metadata];
_showEpisodesDirectly = true;
_fetchAllEpisodes();
}
} catch (e) {
// Fallback to passed metadata on error
@@ -1035,6 +1121,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (widget.metadata.isShow) {
_loadSeasons();
} else if (widget.metadata.isSeason) {
_seasons = [widget.metadata];
_showEpisodesDirectly = true;
_fetchAllEpisodes();
}
}
}
@@ -1136,6 +1226,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
}
/// Load episodes from downloaded content for a season
void _loadEpisodesFromDownloads() {
final downloadProvider = context.read<DownloadProvider>();
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;
});
}
/// Load extras (trailers, behind-the-scenes, etc.)
Future<void> _loadExtras() async {
// Only load extras for movies and shows
@@ -1174,7 +1277,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final watchStateChanged = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (context) => SeasonDetailScreen(season: season, isOffline: widget.isOffline),
builder: (context) => MediaDetailScreen(metadata: season, isOffline: widget.isOffline),
),
);
if (watchStateChanged == true) {
@@ -1212,13 +1315,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final metadata = _fullMetadata ?? widget.metadata;
// DOWN order: overview → seasons → cast → extras
if (metadata.summary != null) {
if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
return KeyEventResult.handled;
}
if (metadata.isShow && _seasons.isNotEmpty) {
if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) {
_seasonsFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
return KeyEventResult.handled;
@@ -1320,7 +1423,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
// UP: overview → play button
if (key.isUpKey) {
final metadata = _fullMetadata ?? widget.metadata;
if (metadata.summary != null) {
if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
@@ -1361,9 +1464,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled;
}
// DOWN: seasons (if show) → cast → extras
// DOWN: seasons/episodes (if show/season) → cast → extras
if (key.isDownKey) {
if (metadata.isShow && _seasons.isNotEmpty) {
if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) {
_seasonsFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.role != null && metadata.role!.isNotEmpty) {
@@ -1503,16 +1606,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled;
}
// UP: cast → seasons (if show) → overview → play button
// UP: cast → seasons/episodes (if show/season) → overview → play button
if (key.isUpKey) {
final metadata = _fullMetadata ?? widget.metadata;
if (metadata.role != null && metadata.role!.isNotEmpty) {
_castFocusNode.requestFocus();
_scrollSectionIntoView(_castSectionKey);
} else if (metadata.isShow && _seasons.isNotEmpty) {
} else if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) {
_seasonsFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.summary != null) {
} else if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
@@ -1557,12 +1660,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return KeyEventResult.handled;
}
// UP: seasons (if show) → overview → play button
// UP: seasons/episodes (if show/season) → overview → play button
if (key.isUpKey) {
if (metadata.isShow && _seasons.isNotEmpty) {
if ((metadata.isShow || metadata.isSeason) && _seasons.isNotEmpty) {
_seasonsFocusNode.requestFocus();
_scrollSectionIntoView(_seasonsSectionKey);
} else if (metadata.summary != null) {
} else if (metadata.summary != null && metadata.summary!.isNotEmpty) {
_overviewFocusNode.requestFocus();
_scrollSectionIntoView(_overviewSectionKey);
} else {
@@ -2169,7 +2272,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Summary
if (metadata.summary != null) ...[
if (metadata.summary != null && metadata.summary!.isNotEmpty) ...[
Text(
key: _overviewSectionKey,
t.discover.overview,
@@ -2214,8 +2317,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
const SizedBox(height: 24),
],
// Seasons / Episodes (for TV shows)
if (isShow) ...[
// Seasons / Episodes (for TV shows and seasons)
if (isShow || metadata.isSeason) ...[
Text(
key: _seasonsSectionKey,
_showEpisodesDirectly ? t.libraries.groupings.episodes : t.discover.seasons,