@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -266,7 +266,8 @@
|
||||
"confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?",
|
||||
"deleteMultipleWarning": "여러 항목이 삭제될 수 있습니다.",
|
||||
"mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다",
|
||||
"mediaFailedToDelete": "미디어 항목 삭제 실패"
|
||||
"mediaFailedToDelete": "미디어 항목 삭제 실패",
|
||||
"rate": "평가"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, 영화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -266,7 +266,8 @@
|
||||
"confirmDelete": "确定要从文件系统中删除此项吗?",
|
||||
"deleteMultipleWarning": "可能会删除多个项目。",
|
||||
"mediaDeletedSuccessfully": "媒体项已成功删除",
|
||||
"mediaFailedToDelete": "删除媒体项失败"
|
||||
"mediaFailedToDelete": "删除媒体项失败",
|
||||
"rate": "评分"
|
||||
},
|
||||
"accessibility": {
|
||||
"mediaCardMovie": "${title}, 电影",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user