From f926deb63b2216982fe9d7988dfcbf0743e92cc1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:17:23 +0200 Subject: [PATCH] feat: clickable cast members to browse actor filmography close #723 --- lib/screens/actor_media_screen.dart | 168 +++++++++++++++++++++++++++ lib/screens/media_detail_screen.dart | 28 ++++- lib/services/plex_client.dart | 9 ++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 lib/screens/actor_media_screen.dart diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart new file mode 100644 index 00000000..a7af452b --- /dev/null +++ b/lib/screens/actor_media_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../models/plex_metadata.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../widgets/plex_optimized_image.dart'; +import '../utils/plex_image_helper.dart'; +import '../i18n/strings.g.dart'; +import 'base_media_list_detail_screen.dart'; +import 'focusable_detail_screen_mixin.dart'; +import '../mixins/grid_focus_node_mixin.dart'; +import '../focus/key_event_utils.dart'; +import '../focus/focusable_action_bar.dart'; + +/// Screen to browse all media featuring a specific actor +class ActorMediaScreen extends StatefulWidget { + final String actorName; + final String personId; + final String? actorThumb; + final String? characterName; + final String serverId; + final String? serverName; + + const ActorMediaScreen({ + super.key, + required this.actorName, + required this.personId, + this.actorThumb, + this.characterName, + required this.serverId, + this.serverName, + }); + + @override + State createState() => _ActorMediaScreenState(); +} + +class _ActorMediaScreenState extends BaseMediaListDetailScreen + with + StandardItemLoader, + GridFocusNodeMixin, + FocusableDetailScreenMixin { + @override + PlexMetadata get mediaItem => PlexMetadata( + ratingKey: '', + serverId: widget.serverId, + serverName: widget.serverName, + ); + + @override + String get title => widget.actorName; + + @override + String get emptyMessage => t.discover.noContentAvailable; + + @override + bool get hasItems => items.isNotEmpty; + + @override + void dispose() { + disposeFocusResources(); + super.dispose(); + } + + @override + Future> fetchItems() async { + return await client.getPersonMedia(widget.personId); + } + + @override + Future loadItems() async { + await super.loadItems(); + autoFocusFirstItemAfterLoad(); + } + + @override + List getAppBarActions() { + return []; + } + + Widget _buildActorHeader() { + final theme = Theme.of(context); + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(40), + child: PlexOptimizedImage( + client: client, + imagePath: widget.actorThumb, + width: 80, + height: 80, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.actorName, + style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (widget.characterName != null) ...[ + const SizedBox(height: 4), + Text( + widget.characterName!, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (items.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + '${items.length} ${items.length == 1 ? 'title' : 'titles'}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (didPop) return; + final shouldPop = handleBackNavigation(); + if (shouldPop && mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + controller: scrollController, + slivers: [ + CustomAppBar(title: Text(widget.actorName), pinned: true, actions: buildFocusableAppBarActions()), + _buildActorHeader(), + ...buildStateSlivers(), + if (items.isNotEmpty) + buildFocusableGrid( + items: items, + onRefresh: updateItem, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9f546d4d..cdf7ffca 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -24,6 +24,7 @@ import '../utils/plex_image_helper.dart'; import '../../services/plex_client.dart'; import '../services/plex_api_cache.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'; @@ -56,6 +57,7 @@ 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'; class MediaDetailScreen extends StatefulWidget { @@ -1080,6 +1082,25 @@ class _MediaDetailScreenState extends State 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, @@ -1808,8 +1829,12 @@ class _MediaDetailScreenState extends State return KeyEventResult.handled; } - // SELECT: consume (cast is informational) + // 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; } @@ -2683,6 +2708,7 @@ class _MediaDetailScreenState extends State context: context, isFocused: isFocused, borderRadius: tokens(context).radiusSm, + onTap: () => _navigateToActorMedia(actor), child: Padding( padding: const EdgeInsets.all(innerPadding), child: SizedBox( diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d0806346..49a49a79 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1897,6 +1897,15 @@ class PlexClient { ); } + /// Get media featuring a specific person (actor/director) + Future> getPersonMedia(String personId) { + return _wrapListApiCall( + () => _http.get('/library/people/$personId/media'), + _extractMetadataList, + 'Failed to get person media', + ); + } + /// Delete a collection /// Deletes a library collection from the server Future deleteCollection(String sectionId, String collectionId) async {