feat: add user star ratings

close #411
This commit is contained in:
edde746
2026-02-20 09:57:44 +01:00
parent 637a3e9e37
commit 1e6a1af1cf
25 changed files with 441 additions and 23 deletions
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?",
"deleteMultipleWarning": "Mehrere Elemente können gelöscht werden.",
"mediaDeletedSuccessfully": "Medienelement erfolgreich gelöscht",
"mediaFailedToDelete": "Löschen des Medienelements fehlgeschlagen"
"mediaFailedToDelete": "Löschen des Medienelements fehlgeschlagen",
"rate": "Bewerten"
},
"accessibility": {
"mediaCardMovie": "${title}, Film",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Are you sure you want to delete this item from your filesystem?",
"deleteMultipleWarning": "Multiple items may be deleted.",
"mediaDeletedSuccessfully": "Media item deleted successfully",
"mediaFailedToDelete": "Failed to delete media item"
"mediaFailedToDelete": "Failed to delete media item",
"rate": "Rate"
},
"accessibility": {
"mediaCardMovie": "${title}, movie",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?",
"deleteMultipleWarning": "Es posible que se eliminen varios elementos.",
"mediaDeletedSuccessfully": "Elemento multimedia eliminado con éxito",
"mediaFailedToDelete": "Error al eliminar el elemento multimedia"
"mediaFailedToDelete": "Error al eliminar el elemento multimedia",
"rate": "Calificar"
},
"accessibility": {
"mediaCardMovie": "${title}, película",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?",
"deleteMultipleWarning": "Plusieurs éléments peuvent être supprimés.",
"mediaDeletedSuccessfully": "Élément média supprimé avec succès",
"mediaFailedToDelete": "Échec de la suppression de l'élément média"
"mediaFailedToDelete": "Échec de la suppression de l'élément média",
"rate": "Noter"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Sei sicuro di voler eliminare questo elemento dal tuo filesystem?",
"deleteMultipleWarning": "Potrebbero essere eliminati più elementi.",
"mediaDeletedSuccessfully": "Elemento multimediale eliminato con successo",
"mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale"
"mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale",
"rate": "Valuta"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?",
"deleteMultipleWarning": "여러 항목이 삭제될 수 있습니다.",
"mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다",
"mediaFailedToDelete": "미디어 항목 삭제 실패"
"mediaFailedToDelete": "미디어 항목 삭제 실패",
"rate": "평가"
},
"accessibility": {
"mediaCardMovie": "${title}, 영화",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?",
"deleteMultipleWarning": "Meerdere items kunnen worden verwijderd.",
"mediaDeletedSuccessfully": "Media-item succesvol verwijderd",
"mediaFailedToDelete": "Verwijderen van media-item mislukt"
"mediaFailedToDelete": "Verwijderen van media-item mislukt",
"rate": "Beoordelen"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Mehrere Elemente können gelöscht werden.';
@override String get mediaDeletedSuccessfully => 'Medienelement erfolgreich gelöscht';
@override String get mediaFailedToDelete => 'Löschen des Medienelements fehlgeschlagen';
@override String get rate => 'Bewerten';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsDe {
'mediaMenu.deleteMultipleWarning' => 'Mehrere Elemente können gelöscht werden.',
'mediaMenu.mediaDeletedSuccessfully' => 'Medienelement erfolgreich gelöscht',
'mediaMenu.mediaFailedToDelete' => 'Löschen des Medienelements fehlgeschlagen',
'mediaMenu.rate' => 'Bewerten',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, Film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, Serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+6
View File
@@ -856,6 +856,10 @@ class TranslationsMediaMenuEn {
/// en: 'Failed to delete media item'
String get mediaFailedToDelete => 'Failed to delete media item';
/// en: 'Rate'
String get rate => 'Rate';
}
// Path: accessibility
@@ -2962,6 +2966,8 @@ extension on Translations {
'mediaMenu.deleteMultipleWarning' => 'Multiple items may be deleted.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully',
'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item',
'mediaMenu.rate' => 'Rate',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, movie',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV show',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Es posible que se eliminen varios elementos.';
@override String get mediaDeletedSuccessfully => 'Elemento multimedia eliminado con éxito';
@override String get mediaFailedToDelete => 'Error al eliminar el elemento multimedia';
@override String get rate => 'Calificar';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsEs {
'mediaMenu.deleteMultipleWarning' => 'Es posible que se eliminen varios elementos.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimedia eliminado con éxito',
'mediaMenu.mediaFailedToDelete' => 'Error al eliminar el elemento multimedia',
'mediaMenu.rate' => 'Calificar',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, película',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, serie de TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Plusieurs éléments peuvent être supprimés.';
@override String get mediaDeletedSuccessfully => 'Élément média supprimé avec succès';
@override String get mediaFailedToDelete => 'Échec de la suppression de l\'élément média';
@override String get rate => 'Noter';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsFr {
'mediaMenu.deleteMultipleWarning' => 'Plusieurs éléments peuvent être supprimés.',
'mediaMenu.mediaDeletedSuccessfully' => 'Élément média supprimé avec succès',
'mediaMenu.mediaFailedToDelete' => 'Échec de la suppression de l\'élément média',
'mediaMenu.rate' => 'Noter',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, show TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Potrebbero essere eliminati più elementi.';
@override String get mediaDeletedSuccessfully => 'Elemento multimediale eliminato con successo';
@override String get mediaFailedToDelete => 'Impossibile eliminare l\'elemento multimediale';
@override String get rate => 'Valuta';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsIt {
'mediaMenu.deleteMultipleWarning' => 'Potrebbero essere eliminati più elementi.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimediale eliminato con successo',
'mediaMenu.mediaFailedToDelete' => 'Impossibile eliminare l\'elemento multimediale',
'mediaMenu.rate' => 'Valuta',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, serie TV',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => '여러 항목이 삭제될 수 있습니다.';
@override String get mediaDeletedSuccessfully => '미디어 항목이 성공적으로 삭제되었습니다';
@override String get mediaFailedToDelete => '미디어 항목 삭제 실패';
@override String get rate => '평가';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsKo {
'mediaMenu.deleteMultipleWarning' => '여러 항목이 삭제될 수 있습니다.',
'mediaMenu.mediaDeletedSuccessfully' => '미디어 항목이 성공적으로 삭제되었습니다',
'mediaMenu.mediaFailedToDelete' => '미디어 항목 삭제 실패',
'mediaMenu.rate' => '평가',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 영화',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV 프로그램',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Meerdere items kunnen worden verwijderd.';
@override String get mediaDeletedSuccessfully => 'Media-item succesvol verwijderd';
@override String get mediaFailedToDelete => 'Verwijderen van media-item mislukt';
@override String get rate => 'Beoordelen';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsNl {
'mediaMenu.deleteMultipleWarning' => 'Meerdere items kunnen worden verwijderd.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media-item succesvol verwijderd',
'mediaMenu.mediaFailedToDelete' => 'Verwijderen van media-item mislukt',
'mediaMenu.rate' => 'Beoordelen',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV-serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => 'Flera objekt kan komma att tas bort.';
@override String get mediaDeletedSuccessfully => 'Mediaobjekt borttaget';
@override String get mediaFailedToDelete => 'Kunde inte ta bort mediaobjekt';
@override String get rate => 'Betygsätt';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsSv {
'mediaMenu.deleteMultipleWarning' => 'Flera objekt kan komma att tas bort.',
'mediaMenu.mediaDeletedSuccessfully' => 'Mediaobjekt borttaget',
'mediaMenu.mediaFailedToDelete' => 'Kunde inte ta bort mediaobjekt',
'mediaMenu.rate' => 'Betygsätt',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV-serie',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+4
View File
@@ -400,6 +400,8 @@ class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn {
@override String get deleteMultipleWarning => '可能会删除多个项目。';
@override String get mediaDeletedSuccessfully => '媒体项已成功删除';
@override String get mediaFailedToDelete => '删除媒体项失败';
@override String get rate => '评分';
}
// Path: accessibility
@@ -1473,6 +1475,8 @@ extension on TranslationsZh {
'mediaMenu.deleteMultipleWarning' => '可能会删除多个项目。',
'mediaMenu.mediaDeletedSuccessfully' => '媒体项已成功删除',
'mediaMenu.mediaFailedToDelete' => '删除媒体项失败',
'mediaMenu.rate' => '评分',
'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 电影',
'accessibility.mediaCardShow' => ({required Object title}) => '${title}, 电视剧',
'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}',
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "Är du säker på att du vill ta bort detta objekt från ditt filsystem?",
"deleteMultipleWarning": "Flera objekt kan komma att tas bort.",
"mediaDeletedSuccessfully": "Mediaobjekt borttaget",
"mediaFailedToDelete": "Kunde inte ta bort mediaobjekt"
"mediaFailedToDelete": "Kunde inte ta bort mediaobjekt",
"rate": "Betygsätt"
},
"accessibility": {
"mediaCardMovie": "${title}, film",
+2 -1
View File
@@ -266,7 +266,8 @@
"confirmDelete": "确定要从文件系统中删除此项吗?",
"deleteMultipleWarning": "可能会删除多个项目。",
"mediaDeletedSuccessfully": "媒体项已成功删除",
"mediaFailedToDelete": "删除媒体项失败"
"mediaFailedToDelete": "删除媒体项失败",
"rate": "评分"
},
"accessibility": {
"mediaCardMovie": "${title}, 电影",
+4
View File
@@ -48,6 +48,7 @@ class PlexMetadata with MultiServerFields {
final String? summary;
final double? rating;
final double? audienceRating;
final double? userRating;
final int? year;
final String? originallyAvailableAt; // Full release date (YYYY-MM-DD)
final String? thumb;
@@ -128,6 +129,7 @@ class PlexMetadata with MultiServerFields {
this.summary,
this.rating,
this.audienceRating,
this.userRating,
this.year,
this.originallyAvailableAt,
this.thumb,
@@ -180,6 +182,7 @@ class PlexMetadata with MultiServerFields {
String? summary,
double? rating,
double? audienceRating,
double? userRating,
int? year,
String? originallyAvailableAt,
String? thumb,
@@ -230,6 +233,7 @@ class PlexMetadata with MultiServerFields {
summary: summary ?? this.summary,
rating: rating ?? this.rating,
audienceRating: audienceRating ?? this.audienceRating,
userRating: userRating ?? this.userRating,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
thumb: thumb ?? this.thumb,
+2
View File
@@ -18,6 +18,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
summary: json['summary'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
audienceRating: (json['audienceRating'] as num?)?.toDouble(),
userRating: (json['userRating'] as num?)?.toDouble(),
year: (json['year'] as num?)?.toInt(),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
thumb: json['thumb'] as String?,
@@ -67,6 +68,7 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'userRating': instance.userRating,
'year': instance.year,
'originallyAvailableAt': instance.originallyAvailableAt,
'thumb': instance.thumb,
+119
View File
@@ -10,6 +10,7 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/rating_bottom_sheet.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_wrapper.dart';
@@ -75,6 +76,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
int _focusedSeasonIndex = 0;
late final FocusNode _seasonsFocusNode;
late final FocusNode _playButtonFocusNode;
late final FocusNode _ratingChipFocusNode;
Timer? _selectKeyTimer;
bool _isSelectKeyDown = false;
bool _longPressTriggered = false;
@@ -256,6 +258,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
_seasonsFocusNode = FocusNode(debugLabel: 'seasons_row');
_extrasFocusNode = FocusNode(debugLabel: 'extras_row');
_playButtonFocusNode = FocusNode(debugLabel: 'play_button');
_ratingChipFocusNode = FocusNode(debugLabel: 'rating_chip');
_overviewFocusNode = FocusNode(debugLabel: 'overview');
_castFocusNode = FocusNode(debugLabel: 'cast_row');
_loadFullMetadata();
@@ -275,6 +278,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
_seasonsFocusNode.dispose();
_extrasFocusNode.dispose();
_playButtonFocusNode.dispose();
_ratingChipFocusNode.dispose();
_overviewFocusNode.dispose();
_castFocusNode.dispose();
_castScrollController.dispose();
@@ -772,9 +776,97 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
chips.add(_buildRatingChip(metadata.audienceRatingImage, metadata.audienceRating!, Symbols.people_rounded));
}
}
// User rating chip (tappable)
if (!widget.isOffline) {
chips.add(_buildUserRatingChip(metadata));
}
return chips;
}
Widget _buildUserRatingChip(PlexMetadata metadata) {
final hasRating = metadata.userRating != null && metadata.userRating! > 0;
final starValue = hasRating ? metadata.userRating! / 2.0 : 0.0;
return FocusableWrapper(
focusNode: _ratingChipFocusNode,
onSelect: () => _showRatingDialog(metadata, starValue),
borderRadius: 100,
useBackgroundFocus: true,
onKeyEvent: (_, event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isDownKey) {
_playButtonFocusNode.requestFocus();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled; // consume — nothing above
}
return KeyEventResult.ignored;
},
child: GestureDetector(
onTap: () => _showRatingDialog(metadata, starValue),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8),
borderRadius: const BorderRadius.all(Radius.circular(100)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(
Symbols.star_rounded,
fill: hasRating ? 1 : 0,
color: hasRating
? Colors.amber
: Theme.of(context).colorScheme.onSecondaryContainer,
size: 16,
),
const SizedBox(width: 4),
Text(
hasRating
? (starValue == starValue.truncateToDouble()
? '${starValue.toInt()}'
: starValue.toStringAsFixed(1))
: t.mediaMenu.rate,
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondaryContainer,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}
void _showRatingDialog(PlexMetadata metadata, double currentStarValue) {
showModalBottomSheet(
context: context,
builder: (context) => RatingBottomSheet(
currentRating: currentStarValue,
onRate: (stars) async {
final client = _getClientForMetadata(this.context);
if (client == null) return;
final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale
final success = await client.rateItem(metadata.ratingKey, plexRating);
if (success) _updateWatchState();
},
onClear: () async {
final client = _getClientForMetadata(this.context);
if (client == null) return;
final success = await client.rateItem(metadata.ratingKey, -1);
if (success) _updateWatchState();
},
),
);
}
/// Build a combined RT chip showing critic + audience side by side.
Widget _buildCombinedRtChip(RatingInfo critic, RatingInfo audience) {
final textStyle = TextStyle(
@@ -1036,6 +1128,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
KeyEventResult _handlePlayButtonKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
if (!event.isActionable) return KeyEventResult.ignored;
// UP: focus the rating chip if available
if (key.isUpKey) {
if (!widget.isOffline) {
_ratingChipFocusNode.requestFocus();
return KeyEventResult.handled;
}
return KeyEventResult.handled;
}
if (!key.isDownKey) return KeyEventResult.ignored;
final metadata = _fullMetadata ?? widget.metadata;
@@ -2386,6 +2488,22 @@ class _SeasonCardState extends State<_SeasonCard> {
t.discover.episodeCount(count: widget.season.leafCount.toString()),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
if (widget.season.userRating != null && widget.season.userRating! > 0) ...[
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(padding: EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 14, fill: 1, color: Colors.amber)),
const SizedBox(width: 3),
Text(
(widget.season.userRating! / 2) == (widget.season.userRating! / 2).truncateToDouble()
? '${(widget.season.userRating! / 2).toInt()}'
: (widget.season.userRating! / 2).toStringAsFixed(1),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
),
],
// Hide watch progress when offline (not tracked)
if (!widget.isOffline) ...[
const SizedBox(height: 8),
@@ -2473,3 +2591,4 @@ class _SeasonCardState extends State<_SeasonCard> {
);
}
}
+17 -14
View File
@@ -348,24 +348,27 @@ class _EpisodeCardState extends State<_EpisodeCard> {
}
Widget _buildEpisodeMetaRow(BuildContext context) {
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
final dot = Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text('', style: mutedStyle),
);
return Row(
children: [
if (widget.episode.duration != null)
Text(
formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
),
if (widget.episode.duration != null) Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.originallyAvailableAt != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
),
),
dot,
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
],
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
dot,
Padding(padding: const EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber)),
const SizedBox(width: 2),
Text(
formatFullDate(widget.episode.originallyAvailableAt!),
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12),
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
? '${(widget.episode.userRating! / 2).toInt()}'
: (widget.episode.userRating! / 2).toStringAsFixed(1),
style: mutedStyle,
),
],
],
+13
View File
@@ -1177,6 +1177,19 @@ class PlexClient {
await _dio.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey});
}
/// Rate a media item (0.0-10.0 scale, where each integer = half a star)
/// Pass -1 to clear an existing rating
Future<bool> rateItem(String ratingKey, double rating) {
return _wrapBoolApiCall(
() => _dio.put('/:/rate', queryParameters: {
'key': ratingKey,
'identifier': 'com.plexapp.plugins.library',
'rating': rating,
}),
'Failed to rate item',
);
}
/// Delete a media item from the library
/// This permanently removes the item and its associated files from the server
/// Returns true if deletion was successful, false otherwise
+36
View File
@@ -26,6 +26,7 @@ import '../theme/mono_tokens.dart';
import '../widgets/file_info_bottom_sheet.dart';
import '../widgets/focusable_list_tile.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/rating_bottom_sheet.dart';
import '../i18n/strings.g.dart';
/// Helper class to store menu action data
@@ -187,6 +188,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
);
}
// Rate (for movies, shows, seasons, and episodes)
if (mediaType == PlexMediaType.movie ||
mediaType == PlexMediaType.show ||
mediaType == PlexMediaType.season ||
mediaType == PlexMediaType.episode) {
menuActions.add(
_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate),
);
}
// Remove from Collection (only when viewing items within a collection)
if (widget.collectionId != null) {
menuActions.add(
@@ -397,6 +408,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
break;
case 'rate':
if (context.mounted) {
await _showRatingSheet(context, metadata!, client);
}
break;
case 'remove_from_collection':
await _handleRemoveFromCollection(context, metadata!);
break;
@@ -928,6 +945,25 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
/// Handle remove from collection action
Future<void> _showRatingSheet(BuildContext context, PlexMetadata metadata, PlexClient client) async {
final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0) ? metadata.userRating! / 2.0 : 0.0;
await showModalBottomSheet(
context: context,
builder: (context) => RatingBottomSheet(
currentRating: currentStarValue,
onRate: (stars) async {
final plexRating = stars * 2.0;
final success = await client.rateItem(metadata.ratingKey, plexRating);
if (success) widget.onRefresh?.call(metadata.ratingKey);
},
onClear: () async {
final success = await client.rateItem(metadata.ratingKey, -1);
if (success) widget.onRefresh?.call(metadata.ratingKey);
},
),
);
}
Future<void> _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async {
final client = _getClientForItem();
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../widgets/app_icon.dart';
import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
class RatingBottomSheet extends StatefulWidget {
final double currentRating;
final Future<void> Function(double stars) onRate;
final Future<void> Function() onClear;
const RatingBottomSheet({
super.key,
required this.currentRating,
required this.onRate,
required this.onClear,
});
@override
State<RatingBottomSheet> createState() => _RatingBottomSheetState();
}
class _RatingBottomSheetState extends State<RatingBottomSheet> {
late double _selectedRating;
late final FocusNode _starsFocusNode;
@override
void initState() {
super.initState();
_selectedRating = widget.currentRating;
_starsFocusNode = FocusNode(debugLabel: 'rating_stars');
}
@override
void dispose() {
_starsFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
Text(
_selectedRating > 0
? '${_selectedRating == _selectedRating.truncateToDouble() ? _selectedRating.toInt().toString() : _selectedRating.toStringAsFixed(1)} / 5'
: t.mediaMenu.rate,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 16),
Focus(
focusNode: _starsFocusNode,
autofocus: true,
onKeyEvent: _handleStarsKeyEvent,
child: LayoutBuilder(
builder: (context, constraints) {
final starSize = (constraints.maxWidth / 5).clamp(0.0, 48.0);
return GestureDetector(
onTapDown: (details) => _handleStarTap(details.localPosition.dx, starSize),
onPanUpdate: (details) => _handleStarTap(details.localPosition.dx, starSize),
child: AnimatedBuilder(
animation: _starsFocusNode,
builder: (context, child) {
final hasFocus = _starsFocusNode.hasFocus;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: hasFocus
? theme.colorScheme.primary.withValues(alpha: 0.12)
: null,
),
child: child,
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final starIndex = i + 1;
final IconData icon;
if (_selectedRating >= starIndex) {
icon = Symbols.star_rounded;
} else if (_selectedRating >= starIndex - 0.5) {
icon = Symbols.star_half_rounded;
} else {
icon = Symbols.star_rounded;
}
return SizedBox(
width: starSize,
height: starSize,
child: Center(
child: AppIcon(
icon,
fill: _selectedRating >= starIndex - 0.5 ? 1 : 0,
color: _selectedRating >= starIndex - 0.5
? Colors.amber
: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
size: starSize * 0.8,
),
),
);
}),
),
),
);
},
),
),
const SizedBox(height: 20),
Row(
children: [
if (widget.currentRating > 0)
Expanded(
child: OutlinedButton(
onPressed: () {
widget.onClear();
Navigator.pop(context);
},
child: Text(t.common.clear),
),
),
if (widget.currentRating > 0) const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _selectedRating > 0
? () {
widget.onRate(_selectedRating);
Navigator.pop(context);
}
: null,
child: Text(t.mediaMenu.rate),
),
),
],
),
],
),
),
);
}
KeyEventResult _handleStarsKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isRightKey) {
setState(() {
_selectedRating = (_selectedRating + 0.5).clamp(0.5, 5.0);
});
return KeyEventResult.handled;
}
if (key.isLeftKey) {
setState(() {
_selectedRating = (_selectedRating - 0.5).clamp(0.5, 5.0);
});
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.enter) {
if (_selectedRating > 0) {
widget.onRate(_selectedRating);
Navigator.pop(context);
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _handleStarTap(double dx, double starSize) {
final totalWidth = starSize * 5;
final clampedX = dx.clamp(0.0, totalWidth);
final halfStars = (clampedX / (starSize / 2)).ceil().clamp(1, 10);
setState(() {
_selectedRating = halfStars / 2.0;
});
}
}