feat: clickable cast members to browse actor filmography

close #723
This commit is contained in:
edde746
2026-04-08 05:17:37 +02:00
parent b82c612f94
commit f926deb63b
3 changed files with 204 additions and 1 deletions
+168
View File
@@ -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<ActorMediaScreen> createState() => _ActorMediaScreenState();
}
class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
with
StandardItemLoader<ActorMediaScreen>,
GridFocusNodeMixin<ActorMediaScreen>,
FocusableDetailScreenMixin<ActorMediaScreen> {
@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<List<PlexMetadata>> fetchItems() async {
return await client.getPersonMedia(widget.personId);
}
@override
Future<void> loadItems() async {
await super.loadItems();
autoFocusFirstItemAfterLoad();
}
@override
List<FocusableAction> 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,
),
],
),
),
);
}
}
+27 -1
View File
@@ -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<MediaDetailScreen>
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<DownloadVersionConfig?> _resolveDownloadVersion(
BuildContext context,
@@ -1808,8 +1829,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
context: context,
isFocused: isFocused,
borderRadius: tokens(context).radiusSm,
onTap: () => _navigateToActorMedia(actor),
child: Padding(
padding: const EdgeInsets.all(innerPadding),
child: SizedBox(
+9
View File
@@ -1897,6 +1897,15 @@ class PlexClient {
);
}
/// Get media featuring a specific person (actor/director)
Future<List<PlexMetadata>> getPersonMedia(String personId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/library/people/$personId/media'),
_extractMetadataList,
'Failed to get person media',
);
}
/// Delete a collection
/// Deletes a library collection from the server
Future<bool> deleteCollection(String sectionId, String collectionId) async {