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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user