@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user