From 1043db2fb879df1838184580d4ce953e982217a6 Mon Sep 17 00:00:00 2001 From: Tazio Date: Sun, 9 Nov 2025 15:57:33 +0100 Subject: [PATCH] feat: display roles --- lib/models/plex_metadata.dart | 7 ++ lib/models/plex_metadata.g.dart | 4 + lib/models/plex_role.dart | 29 +++++++ lib/models/plex_role.g.dart | 27 +++++++ lib/screens/media_detail_screen.dart | 110 +++++++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 lib/models/plex_role.dart create mode 100644 lib/models/plex_role.g.dart diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index e6d7d470..17f360f0 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import 'plex_role.dart'; + part 'plex_metadata.g.dart'; @JsonSerializable() @@ -34,6 +36,8 @@ class PlexMetadata { final int? viewCount; final int? leafCount; // Total number of episodes in a series/season final int? viewedLeafCount; // Number of watched episodes in a series/season + @JsonKey(name: 'Role') + final List? role; // Cast members // Transient field for clear logo (extracted from Image array) String? _clearLogo; @@ -70,6 +74,7 @@ class PlexMetadata { this.viewCount, this.leafCount, this.viewedLeafCount, + this.role, }); /// Create a copy of this metadata with optional field overrides @@ -104,6 +109,7 @@ class PlexMetadata { int? viewCount, int? leafCount, int? viewedLeafCount, + List? role, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -136,6 +142,7 @@ class PlexMetadata { viewCount: viewCount ?? this.viewCount, leafCount: leafCount ?? this.leafCount, viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount, + role: role ?? this.role, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index d82c26f8..5cf19850 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -37,6 +37,9 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( viewCount: (json['viewCount'] as num?)?.toInt(), leafCount: (json['leafCount'] as num?)?.toInt(), viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), + role: (json['Role'] as List?) + ?.map((e) => PlexRole.fromJson(e as Map)) + .toList(), ); Map _$PlexMetadataToJson(PlexMetadata instance) => @@ -71,4 +74,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'viewCount': instance.viewCount, 'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount, + 'Role': instance.role, }; diff --git a/lib/models/plex_role.dart b/lib/models/plex_role.dart new file mode 100644 index 00000000..45f30248 --- /dev/null +++ b/lib/models/plex_role.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'plex_role.g.dart'; + +@JsonSerializable() +class PlexRole { + final int id; + final String? filter; + final String tag; + final String? tagKey; + final String? role; + final String? thumb; + final int? count; + + PlexRole({ + required this.id, + this.filter, + required this.tag, + this.tagKey, + this.role, + this.thumb, + this.count, + }); + + factory PlexRole.fromJson(Map json) => + _$PlexRoleFromJson(json); + + Map toJson() => _$PlexRoleToJson(this); +} diff --git a/lib/models/plex_role.g.dart b/lib/models/plex_role.g.dart new file mode 100644 index 00000000..d3b7f6bb --- /dev/null +++ b/lib/models/plex_role.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'plex_role.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlexRole _$PlexRoleFromJson(Map json) => PlexRole( + id: (json['id'] as num).toInt(), + filter: json['filter'] as String?, + tag: json['tag'] as String, + tagKey: json['tagKey'] as String?, + role: json['role'] as String?, + thumb: json['thumb'] as String?, + count: (json['count'] as num?)?.toInt(), +); + +Map _$PlexRoleToJson(PlexRole instance) => { + 'id': instance.id, + 'filter': instance.filter, + 'tag': instance.tag, + 'tagKey': instance.tagKey, + 'role': instance.role, + 'thumb': instance.thumb, + 'count': instance.count, +}; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 5a6f762b..7e34dd28 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -751,6 +751,116 @@ class _MediaDetailScreenState extends State { const SizedBox(height: 24), ], + // Cast + if (metadata.role != null && metadata.role!.isNotEmpty) ...[ + Text( + 'Cast', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 200, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: metadata.role!.length, + separatorBuilder: (context, index) => + const SizedBox(width: 12), + itemBuilder: (context, index) { + final actor = metadata.role![index]; + return SizedBox( + width: 120, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: actor.thumb != null + ? CachedNetworkImage( + imageUrl: actor.thumb!, + width: 120, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => + Container( + width: 120, + height: 120, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + errorWidget: (context, url, error) => + Container( + width: 120, + height: 120, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + ) + : Container( + width: 120, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + actor.tag, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (actor.role != null) ...[ + const SizedBox(height: 2), + Text( + actor.role!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ); + }, + ), + ), + const SizedBox(height: 24), + ], + // Seasons (for TV shows) if (isShow) ...[ Text(