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,
|
||||
|
||||
@@ -3,7 +3,6 @@ import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../screens/collection_detail_screen.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../screens/playlist/playlist_detail_screen.dart';
|
||||
import 'video_player_navigation.dart';
|
||||
|
||||
@@ -96,14 +95,7 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
continue defaultCase;
|
||||
|
||||
case PlexMediaType.season:
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SeasonDetailScreen(season: metadata, isOffline: isOffline),
|
||||
),
|
||||
);
|
||||
onRefresh?.call(metadata.ratingKey);
|
||||
return MediaNavigationResult.navigated;
|
||||
continue defaultCase;
|
||||
|
||||
defaultCase:
|
||||
default:
|
||||
|
||||
@@ -2,334 +2,23 @@ import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../main.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../widgets/collapsible_text.dart';
|
||||
import '../widgets/plex_optimized_image.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_context_menu.dart';
|
||||
import '../widgets/placeholder_container.dart';
|
||||
import '../mixins/item_updatable.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 '../theme/mono_tokens.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata season;
|
||||
final bool isOffline;
|
||||
|
||||
const SeasonDetailScreen({super.key, required this.season, this.isOffline = false});
|
||||
|
||||
@override
|
||||
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen>
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware, MountedSetStateMixin, ServerBoundMediaMixin {
|
||||
PlexClient? _client;
|
||||
|
||||
@override
|
||||
PlexClient get client {
|
||||
final client = _client;
|
||||
if (client == null) {
|
||||
throw StateError('PlexClient unavailable for season ${widget.season.ratingKey}');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
List<PlexMetadata> _episodes = [];
|
||||
bool _isLoadingEpisodes = false;
|
||||
bool _watchStateChanged = false;
|
||||
// Capture keyboard mode once at init to avoid rebuild dependency
|
||||
bool _initialKeyboardMode = false;
|
||||
bool _suppressNextBackKeyUp = false;
|
||||
bool _routeSubscribed = false;
|
||||
|
||||
@override
|
||||
PlexMetadata get serverBoundMetadata => widget.season;
|
||||
|
||||
@override
|
||||
bool get isServerBoundOffline => widget.isOffline;
|
||||
|
||||
// WatchStateAware: watch all episode ratingKeys
|
||||
@override
|
||||
Set<String>? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet();
|
||||
|
||||
@override
|
||||
String? get watchStateServerId => serverBoundServerId;
|
||||
|
||||
@override
|
||||
Set<String>? get watchedGlobalKeys {
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
return _episodes.map((e) => toServerBoundGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
// Update the affected episode
|
||||
if (!widget.isOffline && _client != null) {
|
||||
updateItem(event.ratingKey);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get deletionRatingKeys {
|
||||
final keys = _episodes.map((e) => e.ratingKey).toSet();
|
||||
keys.add(widget.season.ratingKey);
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get deletionServerId => serverBoundServerId;
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys {
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
final keys = _episodes.map((e) => toServerBoundGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet();
|
||||
keys.add(toServerBoundGlobalKey(widget.season.ratingKey, 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 we have an episode that matches the rating key exactly, then remove it from our list
|
||||
final index = _episodes.indexWhere((e) => e.ratingKey == event.ratingKey);
|
||||
if (index != -1) {
|
||||
setState(() {
|
||||
_episodes.removeAt(index);
|
||||
});
|
||||
// If that was the last episode, navigate back to the show view
|
||||
if (_episodes.isEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize the client once in initState
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Capture keyboard mode once to avoid rebuild dependency when mode changes
|
||||
_initialKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
_client = getServerBoundClient(context);
|
||||
_loadEpisodes();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodes() async {
|
||||
setState(() {
|
||||
_isLoadingEpisodes = true;
|
||||
});
|
||||
|
||||
if (widget.isOffline) {
|
||||
// Load episodes from downloads
|
||||
_loadEpisodesFromDownloads();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = _client;
|
||||
if (client == null) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Episodes are automatically tagged with server info by PlexClient
|
||||
final episodes = await client.getChildren(widget.season.ratingKey);
|
||||
|
||||
setStateIfMounted(() {
|
||||
_episodes = episodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setStateIfMounted(() {
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Load episodes from downloaded content
|
||||
void _loadEpisodesFromDownloads() {
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
|
||||
// Get all downloaded episodes for the show (grandparentRatingKey)
|
||||
final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(widget.season.parentRatingKey ?? '');
|
||||
|
||||
// Filter to only this season's episodes
|
||||
final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == widget.season.index).toList()
|
||||
..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0));
|
||||
|
||||
setState(() {
|
||||
_episodes = seasonEpisodes;
|
||||
_isLoadingEpisodes = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateItem(String ratingKey) async {
|
||||
if (_client == null) {
|
||||
return;
|
||||
}
|
||||
_watchStateChanged = true;
|
||||
await super.updateItem(ratingKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
final index = _episodes.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
if (index != -1) {
|
||||
_episodes[index] = updatedMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_routeSubscribed) return;
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute) {
|
||||
routeObserver.subscribe(this, route);
|
||||
_routeSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_routeSubscribed) {
|
||||
routeObserver.unsubscribe(this);
|
||||
_routeSubscribed = false;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Returning from a child route (e.g., video player).
|
||||
// Suppress the first BACK KeyUp which can otherwise pop this route.
|
||||
_suppressNextBackKeyUp = true;
|
||||
}
|
||||
|
||||
KeyEventResult _handleBackKeyEvent(KeyEvent event) {
|
||||
if (_suppressNextBackKeyUp && event is KeyUpEvent && event.logicalKey.isBackKey) {
|
||||
_suppressNextBackKeyUp = false;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return handleBackKeyNavigation(context, event, result: _watchStateChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = Focus(
|
||||
onKeyEvent: (_, event) => _handleBackKeyEvent(event),
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
CustomAppBar(
|
||||
title: Text(widget.season.title),
|
||||
pinned: true,
|
||||
onBackPressed: () => Navigator.pop(context, _watchStateChanged),
|
||||
),
|
||||
if (_isLoadingEpisodes)
|
||||
const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))
|
||||
else if (_episodes.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: tokens(context).textMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
t.messages.noEpisodesFoundGeneral,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: tokens(context).textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final episode = _episodes[index];
|
||||
// Get local poster path for offline mode
|
||||
String? localPosterPath;
|
||||
if (widget.isOffline && episode.serverId != null) {
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final globalKey = episode.globalKey;
|
||||
// Get the artwork reference and convert to local file path
|
||||
final artworkRef = downloadProvider.getArtworkPaths(globalKey);
|
||||
localPosterPath = artworkRef?.getLocalPath(DownloadStorageService.instance, episode.serverId!);
|
||||
}
|
||||
return EpisodeCard(
|
||||
episode: episode,
|
||||
client: _client,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
autofocus: index == 0 && _initialKeyboardMode,
|
||||
onTap: () async {
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: episode,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _loadEpisodes,
|
||||
);
|
||||
},
|
||||
onRefresh: widget.isOffline ? null : updateItem,
|
||||
onListRefresh: widget.isOffline ? null : _loadEpisodes,
|
||||
);
|
||||
}, childCount: _episodes.length),
|
||||
),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
import '../../services/plex_client.dart';
|
||||
|
||||
/// Episode card widget with D-pad long-press support
|
||||
class EpisodeCard extends StatefulWidget {
|
||||
@@ -12,7 +12,6 @@ import '../providers/download_provider.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/formatters.dart';
|
||||
@@ -949,7 +948,7 @@ void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffli
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => SeasonDetailScreen(season: seasonStub, isOffline: isOffline)));
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline)));
|
||||
}
|
||||
|
||||
/// Navigate to the detail screen for a metadata item.
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../services/plex_client.dart';
|
||||
import '../services/play_queue_launcher.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/offline_mode_provider.dart';
|
||||
@@ -23,7 +24,6 @@ import '../focus/focusable_button.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/metadata_edit_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../utils/smart_deletion_handler.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
@@ -220,12 +220,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
// Go to Series (for episodes and seasons) — hide if already on that series' detail screen,
|
||||
// or on a season screen belonging to the same series
|
||||
// Go to Series (for episodes and seasons) — hide if already on that series' detail screen
|
||||
final ancestorMediaDetail = context.findAncestorWidgetOfExactType<MediaDetailScreen>();
|
||||
final ancestorSeasonDetail = context.findAncestorWidgetOfExactType<SeasonDetailScreen>();
|
||||
final ancestorSeriesKey =
|
||||
ancestorMediaDetail?.metadata.ratingKey ?? ancestorSeasonDetail?.season.parentRatingKey;
|
||||
final ancestorMeta = ancestorMediaDetail?.metadata;
|
||||
final ancestorSeriesKey = ancestorMeta != null && ancestorMeta.isSeason
|
||||
? ancestorMeta.parentRatingKey
|
||||
: ancestorMeta?.ratingKey;
|
||||
// For episodes, the show key is grandparentRatingKey; for seasons, it's parentRatingKey
|
||||
final itemSeriesKey =
|
||||
mediaType == PlexMediaType.episode ? metadata.grandparentRatingKey : metadata.parentRatingKey;
|
||||
@@ -235,11 +235,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
menuActions.add(_MenuAction(value: 'series', icon: Symbols.tv_rounded, label: t.mediaMenu.goToSeries));
|
||||
}
|
||||
|
||||
// Go to Season (for episodes) — hide if already on that season's detail screen
|
||||
// Go to Season (for episodes) — hide if already viewing that season's MediaDetailScreen
|
||||
if (mediaType == PlexMediaType.episode &&
|
||||
metadata.parentTitle != null &&
|
||||
context.findAncestorWidgetOfExactType<SeasonDetailScreen>()?.season.ratingKey !=
|
||||
metadata.parentRatingKey) {
|
||||
!(ancestorMeta != null &&
|
||||
ancestorMeta.isSeason &&
|
||||
ancestorMeta.ratingKey == metadata.parentRatingKey)) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'season', icon: Symbols.playlist_play_rounded, label: t.mediaMenu.goToSeason),
|
||||
);
|
||||
@@ -464,7 +465,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
await _navigateToRelated(
|
||||
context,
|
||||
metadata!.parentRatingKey,
|
||||
(metadata) => SeasonDetailScreen(season: metadata),
|
||||
(metadata) => MediaDetailScreen(metadata: metadata),
|
||||
t.messages.errorLoadingSeason,
|
||||
);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user