From 1e6a1af1cfbeb4ab6565d0e876f8349724d92bd4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:56:57 +0100 Subject: [PATCH] feat: add user star ratings close #411 --- lib/i18n/de.i18n.json | 3 +- lib/i18n/en.i18n.json | 3 +- lib/i18n/es.i18n.json | 3 +- lib/i18n/fr.i18n.json | 3 +- lib/i18n/it.i18n.json | 3 +- lib/i18n/ko.i18n.json | 3 +- lib/i18n/nl.i18n.json | 3 +- lib/i18n/strings_de.g.dart | 4 + lib/i18n/strings_en.g.dart | 6 + lib/i18n/strings_es.g.dart | 4 + lib/i18n/strings_fr.g.dart | 4 + lib/i18n/strings_it.g.dart | 4 + lib/i18n/strings_ko.g.dart | 4 + lib/i18n/strings_nl.g.dart | 4 + lib/i18n/strings_sv.g.dart | 4 + lib/i18n/strings_zh.g.dart | 4 + lib/i18n/sv.i18n.json | 3 +- lib/i18n/zh.i18n.json | 3 +- lib/models/plex_metadata.dart | 4 + lib/models/plex_metadata.g.dart | 2 + lib/screens/media_detail_screen.dart | 119 ++++++++++++++++ lib/screens/season_detail_screen.dart | 31 ++-- lib/services/plex_client.dart | 13 ++ lib/widgets/media_context_menu.dart | 36 +++++ lib/widgets/rating_bottom_sheet.dart | 194 ++++++++++++++++++++++++++ 25 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 lib/widgets/rating_bottom_sheet.dart diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index a9081e22..961c40a0 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 476ee77f..2c8ba036 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index d4ef4d88..f8b8ff12 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 3e3c64a0..f498893d 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 2766d941..bc15d832 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 07db600b..7e939131 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -266,7 +266,8 @@ "confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?", "deleteMultipleWarning": "여러 항목이 삭제될 수 있습니다.", "mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다", - "mediaFailedToDelete": "미디어 항목 삭제 실패" + "mediaFailedToDelete": "미디어 항목 삭제 실패", + "rate": "평가" }, "accessibility": { "mediaCardMovie": "${title}, 영화", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index efdfba97..907e00ed 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 73c33643..043663c4 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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}', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 569886a1..9e6eae86 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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}', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 14b2b6f3..20e5bf89 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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}', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 4f19c0dc..e123d0ef 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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}', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 02f5c43c..75f8180b 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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}', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 58e7e7ef..33f89bcc 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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}', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 0cb8c348..8e16482d 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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}', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 845f4f5e..7e2fe4ce 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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}', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 5a90f090..3e9c5a43 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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}', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 61366681..bed00149 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index be56349e..9171c579 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -266,7 +266,8 @@ "confirmDelete": "确定要从文件系统中删除此项吗?", "deleteMultipleWarning": "可能会删除多个项目。", "mediaDeletedSuccessfully": "媒体项已成功删除", - "mediaFailedToDelete": "删除媒体项失败" + "mediaFailedToDelete": "删除媒体项失败", + "rate": "评分" }, "accessibility": { "mediaCardMovie": "${title}, 电影", diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 1070ae57..e1b0c2b1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -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, diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index db7bcc04..54ac64b9 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -18,6 +18,7 @@ PlexMetadata _$PlexMetadataFromJson(Map 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 _$PlexMetadataToJson(PlexMetadata instance) => 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 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 with WatchStateAw _seasonsFocusNode.dispose(); _extrasFocusNode.dispose(); _playButtonFocusNode.dispose(); + _ratingChipFocusNode.dispose(); _overviewFocusNode.dispose(); _castFocusNode.dispose(); _castScrollController.dispose(); @@ -772,9 +776,97 @@ class _MediaDetailScreenState extends State 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 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> { ); } } + diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index fd614e01..e52bcd8a 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -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, ), ], ], diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index cd2c43c5..f8f25c79 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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 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 diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index df8cd1c3..0f33ef08 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { ); } + // 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 { } 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 { } /// Handle remove from collection action + Future _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 _handleRemoveFromCollection(BuildContext context, PlexMetadata metadata) async { final client = _getClientForItem(); diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart new file mode 100644 index 00000000..374eb6f9 --- /dev/null +++ b/lib/widgets/rating_bottom_sheet.dart @@ -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 Function(double stars) onRate; + final Future Function() onClear; + + const RatingBottomSheet({ + super.key, + required this.currentRating, + required this.onRate, + required this.onClear, + }); + + @override + State createState() => _RatingBottomSheetState(); +} + +class _RatingBottomSheetState extends State { + 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; + }); + } +}