From 310c4280fcb1764fecdbc7a62c7d334d58e6332b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 16 May 2026 17:59:46 +0200 Subject: [PATCH] feat(ratings): add tracker rating sheet close #1031 --- lib/i18n/da.i18n.json | 13 + lib/i18n/de.i18n.json | 13 + lib/i18n/en.i18n.json | 13 + lib/i18n/es.i18n.json | 13 + lib/i18n/fr.i18n.json | 13 + lib/i18n/it.i18n.json | 13 + lib/i18n/ja.i18n.json | 13 + lib/i18n/ko.i18n.json | 13 + lib/i18n/nb.i18n.json | 13 + lib/i18n/nl.i18n.json | 13 + lib/i18n/pl.i18n.json | 13 + lib/i18n/pt.i18n.json | 13 + lib/i18n/ru.i18n.json | 13 + lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 40 +- lib/i18n/strings_de.g.dart | 40 +- lib/i18n/strings_en.g.dart | 62 +- lib/i18n/strings_es.g.dart | 40 +- lib/i18n/strings_fr.g.dart | 40 +- lib/i18n/strings_it.g.dart | 40 +- lib/i18n/strings_ja.g.dart | 40 +- lib/i18n/strings_ko.g.dart | 40 +- lib/i18n/strings_nb.g.dart | 40 +- lib/i18n/strings_nl.g.dart | 40 +- lib/i18n/strings_pl.g.dart | 40 +- lib/i18n/strings_pt.g.dart | 40 +- lib/i18n/strings_ru.g.dart | 40 +- lib/i18n/strings_sv.g.dart | 40 +- lib/i18n/strings_zh.g.dart | 40 +- lib/i18n/sv.i18n.json | 13 + lib/i18n/zh.i18n.json | 13 + lib/screens/media_detail_screen.dart | 60 +- .../trackers/anilist/anilist_client.dart | 31 + .../trackers/anilist/anilist_tracker.dart | 27 + lib/services/trackers/mal/mal_client.dart | 14 + lib/services/trackers/mal/mal_tracker.dart | 27 + lib/services/trackers/simkl/simkl_client.dart | 11 + .../trackers/simkl/simkl_tracker.dart | 97 +- lib/services/trackers/tracker.dart | 9 + .../trackers/tracker_id_resolver.dart | 57 + lib/services/trakt/trakt_client.dart | 10 + .../trakt/trakt_scrobble_service.dart | 136 ++- lib/widgets/media_context_menu.dart | 26 +- lib/widgets/rating_bottom_sheet.dart | 1083 ++++++++++++++--- test/services/trackers/rating_fetch_test.dart | 185 +++ 45 files changed, 2299 insertions(+), 295 deletions(-) create mode 100644 test/services/trackers/rating_fetch_test.dart diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 6f56504d..3c001e04 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Afspil fra begyndelsen", "playVersion": "Afspil version..." }, + "rateSheet": { + "title": "Bedøm", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Angiv en score", + "notRated": "Ikke bedømt", + "liked": "Synes godt om", + "notLiked": "Ikke liket", + "saved": "Gemt", + "notAvailable": "Intet match fundet", + "noConnectedTrackers": "Forbind en tracker i Indstillinger for at bedømme der." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, TV-serie", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index a7dd1371..ba180b02 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Von Anfang an abspielen", "playVersion": "Version abspielen..." }, + "rateSheet": { + "title": "Bewerten", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Eine Wertung festlegen", + "notRated": "Nicht bewertet", + "liked": "Gefällt mir", + "notLiked": "Nicht geliket", + "saved": "Gespeichert", + "notAvailable": "Keine Übereinstimmung gefunden", + "noConnectedTrackers": "Verbinde einen Tracker in den Einstellungen, um dort zu bewerten." + }, "accessibility": { "mediaCardMovie": "${title}, Film", "mediaCardShow": "${title}, Serie", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index c06acb0a..c038fe9f 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Play from Beginning", "playVersion": "Play Version..." }, + "rateSheet": { + "title": "Rate", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Set a score", + "notRated": "Not rated", + "liked": "Liked", + "notLiked": "Not liked", + "saved": "Saved", + "notAvailable": "No match found", + "noConnectedTrackers": "Connect a tracker in Settings to rate there." + }, "accessibility": { "mediaCardMovie": "${title}, movie", "mediaCardShow": "${title}, TV show", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 4e35f2d5..f5b4593e 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Reproducir desde el inicio", "playVersion": "Reproducir versión..." }, + "rateSheet": { + "title": "Calificar", + "server": "Servidor", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Establecer puntuación", + "notRated": "Sin calificar", + "liked": "Me gusta", + "notLiked": "Sin Me gusta", + "saved": "Guardado", + "notAvailable": "No se encontró coincidencia", + "noConnectedTrackers": "Conecta un tracker en Ajustes para calificar allí." + }, "accessibility": { "mediaCardMovie": "${title}, película", "mediaCardShow": "${title}, serie de TV", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 1f328842..a0e36b41 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Lire depuis le début", "playVersion": "Lire la version..." }, + "rateSheet": { + "title": "Noter", + "server": "Serveur", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Définir une note", + "notRated": "Non noté", + "liked": "Aimé", + "notLiked": "Non aimé", + "saved": "Enregistré", + "notAvailable": "Aucune correspondance trouvée", + "noConnectedTrackers": "Connectez un tracker dans Paramètres pour y noter." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, show TV", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 70b644a3..515bda73 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Riproduci dall'inizio", "playVersion": "Riproduci versione..." }, + "rateSheet": { + "title": "Valuta", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Imposta un punteggio", + "notRated": "Non valutato", + "liked": "Mi piace", + "notLiked": "Non contrassegnato con Mi piace", + "saved": "Salvato", + "notAvailable": "Nessuna corrispondenza trovata", + "noConnectedTrackers": "Collega un tracker in Impostazioni per valutarlo lì." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, serie TV", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 28adf24f..4e87bf7b 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "最初から再生", "playVersion": "バージョンを再生..." }, + "rateSheet": { + "title": "評価", + "server": "サーバー", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "スコアを設定", + "notRated": "未評価", + "liked": "いいね済み", + "notLiked": "いいねなし", + "saved": "保存済み", + "notAvailable": "一致なし", + "noConnectedTrackers": "設定でトラッカーを接続すると、そこで評価できます。" + }, "accessibility": { "mediaCardMovie": "${title}、映画", "mediaCardShow": "${title}、テレビ番組", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 10f2d21a..714be94b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "처음부터 재생", "playVersion": "버전 재생..." }, + "rateSheet": { + "title": "평가", + "server": "서버", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "점수 설정", + "notRated": "평가 없음", + "liked": "좋아요", + "notLiked": "좋아요 아님", + "saved": "저장됨", + "notAvailable": "일치 항목 없음", + "noConnectedTrackers": "설정에서 트래커를 연결하면 거기에도 평가할 수 있습니다." + }, "accessibility": { "mediaCardMovie": "${title}, 영화", "mediaCardShow": "${title}, TV 프로그램", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index c9fddf10..17316e08 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Spill fra begynnelsen", "playVersion": "Spill av versjon..." }, + "rateSheet": { + "title": "Vurder", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Sett en poengsum", + "notRated": "Ikke vurdert", + "liked": "Likt", + "notLiked": "Ikke likt", + "saved": "Lagret", + "notAvailable": "Ingen treff", + "noConnectedTrackers": "Koble til en sporer i Innstillinger for å vurdere der." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, TV-serie", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 119e04f0..d8614864 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Afspelen vanaf het begin", "playVersion": "Versie afspelen..." }, + "rateSheet": { + "title": "Beoordelen", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Score instellen", + "notRated": "Niet beoordeeld", + "liked": "Geliket", + "notLiked": "Niet geliket", + "saved": "Opgeslagen", + "notAvailable": "Geen match gevonden", + "noConnectedTrackers": "Verbind een tracker in Instellingen om daar te beoordelen." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, TV-serie", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 8830905b..38640d4d 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Odtwórz od początku", "playVersion": "Odtwórz wersję..." }, + "rateSheet": { + "title": "Oceń", + "server": "Serwer", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Ustaw ocenę", + "notRated": "Nie oceniono", + "liked": "Polubione", + "notLiked": "Nie polubiono", + "saved": "Zapisano", + "notAvailable": "Nie znaleziono dopasowania", + "noConnectedTrackers": "Połącz tracker w Ustawieniach, aby tam oceniać." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, serial TV", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 92d2a988..561eed68 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Reproduzir do início", "playVersion": "Reproduzir versão..." }, + "rateSheet": { + "title": "Avaliar", + "server": "Servidor", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Definir pontuação", + "notRated": "Sem avaliação", + "liked": "Gostei", + "notLiked": "Não marcado como gostei", + "saved": "Salvo", + "notAvailable": "Nenhuma correspondência encontrada", + "noConnectedTrackers": "Conecte um rastreador em Configurações para avaliar lá." + }, "accessibility": { "mediaCardMovie": "${title}, filme", "mediaCardShow": "${title}, série de TV", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 9e2ddc5e..a233ce42 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Воспроизвести сначала", "playVersion": "Воспроизвести версию..." }, + "rateSheet": { + "title": "Оценить", + "server": "Сервер", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Установить оценку", + "notRated": "Без оценки", + "liked": "Понравилось", + "notLiked": "Не отмечено понравившимся", + "saved": "Сохранено", + "notAvailable": "Совпадений не найдено", + "noConnectedTrackers": "Подключите трекер в настройках, чтобы оценивать там." + }, "accessibility": { "mediaCardMovie": "${title}, фильм", "mediaCardShow": "${title}, сериал", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 648c4e35..eb90c825 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 17295 (1153 per locale) +/// Strings: 17460 (1164 per locale) /// -/// Built on 2026-05-13 at 05:26 UTC +/// Built on 2026-05-16 at 13:41 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index c1b085be..43e6ec92 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -49,6 +49,7 @@ class TranslationsDa extends Translations with BaseTranslations 'Afspil version...'; } +// Path: rateSheet +class _TranslationsRateSheetDa extends TranslationsRateSheetEn { + _TranslationsRateSheetDa._(TranslationsDa root) : this._root = root, super.internal(root); + + final TranslationsDa _root; // ignore: unused_field + + // Translations + @override String get title => 'Bedøm'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Angiv en score'; + @override String get notRated => 'Ikke bedømt'; + @override String get liked => 'Synes godt om'; + @override String get notLiked => 'Ikke liket'; + @override String get saved => 'Gemt'; + @override String get notAvailable => 'Intet match fundet'; + @override String get noConnectedTrackers => 'Forbind en tracker i Indstillinger for at bedømme der.'; +} + // Path: accessibility class _TranslationsAccessibilityDa extends TranslationsAccessibilityEn { _TranslationsAccessibilityDa._(TranslationsDa root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsDa { 'mediaMenu.rate' => 'Bedøm', 'mediaMenu.playFromBeginning' => 'Afspil fra begyndelsen', 'mediaMenu.playVersion' => 'Afspil version...', + 'rateSheet.title' => 'Bedøm', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Angiv en score', + 'rateSheet.notRated' => 'Ikke bedømt', + 'rateSheet.liked' => 'Synes godt om', + 'rateSheet.notLiked' => 'Ikke liket', + 'rateSheet.saved' => 'Gemt', + 'rateSheet.notAvailable' => 'Intet match fundet', + 'rateSheet.noConnectedTrackers' => 'Forbind en tracker i Indstillinger for at bedømme der.', '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}', @@ -2273,6 +2305,8 @@ extension on TranslationsDa { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', 'profiles.removeConnectionTitle' => 'Fjern forbindelse?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Fjern ${displayName}s adgang til ${connectionLabel}. Andre profiler beholder den.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Slet profil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Fjern ${displayName} og forbindelserne. Servere forbliver tilgængelige.', 'profiles.profileNameLabel' => 'Profilnavn', @@ -2284,8 +2318,6 @@ extension on TranslationsDa { 'profiles.confirmPinTitle' => 'Bekræft PIN', 'profiles.pinSet' => 'PIN angivet', 'profiles.changePin' => 'Skift', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Fjern', 'profiles.connectionsLabel' => 'Forbindelser', 'profiles.add' => 'Tilføj', @@ -2787,6 +2819,8 @@ extension on TranslationsDa { 'metadataEdit.selectSquareArt' => 'Vælg kvadratisk billede', 'metadataEdit.fromUrl' => 'Fra URL', 'metadataEdit.uploadFile' => 'Upload fil', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Indtast billed-URL', 'metadataEdit.imageUrl' => 'Billed-URL', 'metadataEdit.metadataUpdated' => 'Metadata opdateret', @@ -2798,8 +2832,6 @@ extension on TranslationsDa { 'metadataEdit.libraryDefault' => 'Biblioteksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', 'metadataEdit.seriesDefault' => 'Seriestandard', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Episodesortering', 'metadataEdit.oldestFirst' => 'Ældste først', 'metadataEdit.newestFirst' => 'Nyeste først', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 946d1a10..5ff98aab 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -49,6 +49,7 @@ class TranslationsDe extends Translations with BaseTranslations 'Version abspielen...'; } +// Path: rateSheet +class _TranslationsRateSheetDe extends TranslationsRateSheetEn { + _TranslationsRateSheetDe._(TranslationsDe root) : this._root = root, super.internal(root); + + final TranslationsDe _root; // ignore: unused_field + + // Translations + @override String get title => 'Bewerten'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Eine Wertung festlegen'; + @override String get notRated => 'Nicht bewertet'; + @override String get liked => 'Gefällt mir'; + @override String get notLiked => 'Nicht geliket'; + @override String get saved => 'Gespeichert'; + @override String get notAvailable => 'Keine Übereinstimmung gefunden'; + @override String get noConnectedTrackers => 'Verbinde einen Tracker in den Einstellungen, um dort zu bewerten.'; +} + // Path: accessibility class _TranslationsAccessibilityDe extends TranslationsAccessibilityEn { _TranslationsAccessibilityDe._(TranslationsDe root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsDe { 'mediaMenu.rate' => 'Bewerten', 'mediaMenu.playFromBeginning' => 'Von Anfang an abspielen', 'mediaMenu.playVersion' => 'Version abspielen...', + 'rateSheet.title' => 'Bewerten', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Eine Wertung festlegen', + 'rateSheet.notRated' => 'Nicht bewertet', + 'rateSheet.liked' => 'Gefällt mir', + 'rateSheet.notLiked' => 'Nicht geliket', + 'rateSheet.saved' => 'Gespeichert', + 'rateSheet.notAvailable' => 'Keine Übereinstimmung gefunden', + 'rateSheet.noConnectedTrackers' => 'Verbinde einen Tracker in den Einstellungen, um dort zu 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}', @@ -2273,6 +2305,8 @@ extension on TranslationsDe { 'profiles.summaryMultiple' => ({required Object count}) => '${count} Profile', 'profiles.removeConnectionTitle' => 'Verbindung entfernen?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Zugriff von ${displayName} auf ${connectionLabel} entfernen. Andere Profile behalten ihn.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Profil löschen?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => '${displayName} und Verbindungen entfernen. Server bleiben verfügbar.', 'profiles.profileNameLabel' => 'Profilname', @@ -2284,8 +2318,6 @@ extension on TranslationsDe { 'profiles.confirmPinTitle' => 'PIN bestätigen', 'profiles.pinSet' => 'PIN festgelegt', 'profiles.changePin' => 'Ändern', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Entfernen', 'profiles.connectionsLabel' => 'Verbindungen', 'profiles.add' => 'Hinzufügen', @@ -2787,6 +2819,8 @@ extension on TranslationsDe { 'metadataEdit.selectSquareArt' => 'Quadratisches Bild auswählen', 'metadataEdit.fromUrl' => 'Von URL', 'metadataEdit.uploadFile' => 'Datei hochladen', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Bild-URL eingeben', 'metadataEdit.imageUrl' => 'Bild-URL', 'metadataEdit.metadataUpdated' => 'Metadaten aktualisiert', @@ -2798,8 +2832,6 @@ extension on TranslationsDe { 'metadataEdit.libraryDefault' => 'Bibliotheksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', 'metadataEdit.seriesDefault' => 'Serienstandard', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Episodensortierung', 'metadataEdit.oldestFirst' => 'Älteste zuerst', 'metadataEdit.newestFirst' => 'Neueste zuerst', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 7254b9cf..4bd6de65 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -50,6 +50,7 @@ class Translations with BaseTranslations { late final TranslationsHotkeysEn hotkeys = TranslationsHotkeysEn.internal(_root); late final TranslationsFileInfoEn fileInfo = TranslationsFileInfoEn.internal(_root); late final TranslationsMediaMenuEn mediaMenu = TranslationsMediaMenuEn.internal(_root); + late final TranslationsRateSheetEn rateSheet = TranslationsRateSheetEn.internal(_root); late final TranslationsAccessibilityEn accessibility = TranslationsAccessibilityEn.internal(_root); late final TranslationsTooltipsEn tooltips = TranslationsTooltipsEn.internal(_root); late final TranslationsVideoControlsEn videoControls = TranslationsVideoControlsEn.internal(_root); @@ -1093,6 +1094,48 @@ class TranslationsMediaMenuEn { String get playVersion => 'Play Version...'; } +// Path: rateSheet +class TranslationsRateSheetEn { + TranslationsRateSheetEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Rate' + String get title => 'Rate'; + + /// en: 'Server' + String get server => 'Server'; + + /// en: '${rating} / 5' + String starValue({required Object rating}) => '${rating} / 5'; + + /// en: '${score} / 10' + String scoreValue({required Object score}) => '${score} / 10'; + + /// en: 'Set a score' + String get setScore => 'Set a score'; + + /// en: 'Not rated' + String get notRated => 'Not rated'; + + /// en: 'Liked' + String get liked => 'Liked'; + + /// en: 'Not liked' + String get notLiked => 'Not liked'; + + /// en: 'Saved' + String get saved => 'Saved'; + + /// en: 'No match found' + String get notAvailable => 'No match found'; + + /// en: 'Connect a tracker in Settings to rate there.' + String get noConnectedTrackers => 'Connect a tracker in Settings to rate there.'; +} + // Path: accessibility class TranslationsAccessibilityEn { TranslationsAccessibilityEn.internal(this._root); @@ -4410,6 +4453,17 @@ extension on Translations { 'mediaMenu.rate' => 'Rate', 'mediaMenu.playFromBeginning' => 'Play from Beginning', 'mediaMenu.playVersion' => 'Play Version...', + 'rateSheet.title' => 'Rate', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Set a score', + 'rateSheet.notRated' => 'Not rated', + 'rateSheet.liked' => 'Liked', + 'rateSheet.notLiked' => 'Not liked', + 'rateSheet.saved' => 'Saved', + 'rateSheet.notAvailable' => 'No match found', + 'rateSheet.noConnectedTrackers' => 'Connect a tracker in Settings to rate there.', '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}', @@ -4585,6 +4639,8 @@ extension on Translations { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiles', 'profiles.removeConnectionTitle' => 'Remove connection?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Remove ${displayName}\'s access to ${connectionLabel}. Other profiles keep it.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Delete profile?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Remove ${displayName} and its connections. Servers stay available.', 'profiles.profileNameLabel' => 'Profile name', @@ -4596,8 +4652,6 @@ extension on Translations { 'profiles.confirmPinTitle' => 'Confirm PIN', 'profiles.pinSet' => 'PIN set', 'profiles.changePin' => 'Change', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Remove', 'profiles.connectionsLabel' => 'Connections', 'profiles.add' => 'Add', @@ -5099,6 +5153,8 @@ extension on Translations { 'metadataEdit.selectSquareArt' => 'Select Square Art', 'metadataEdit.fromUrl' => 'From URL', 'metadataEdit.uploadFile' => 'Upload File', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Enter image URL', 'metadataEdit.imageUrl' => 'Image URL', 'metadataEdit.metadataUpdated' => 'Metadata updated', @@ -5110,8 +5166,6 @@ extension on Translations { 'metadataEdit.libraryDefault' => 'Library default', 'metadataEdit.accountDefault' => 'Account default', 'metadataEdit.seriesDefault' => 'Series default', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Episode Sorting', 'metadataEdit.oldestFirst' => 'Oldest first', 'metadataEdit.newestFirst' => 'Newest first', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 54e2dde7..87b1cd97 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -49,6 +49,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Reproducir versión...'; } +// Path: rateSheet +class _TranslationsRateSheetEs extends TranslationsRateSheetEn { + _TranslationsRateSheetEs._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Calificar'; + @override String get server => 'Servidor'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Establecer puntuación'; + @override String get notRated => 'Sin calificar'; + @override String get liked => 'Me gusta'; + @override String get notLiked => 'Sin Me gusta'; + @override String get saved => 'Guardado'; + @override String get notAvailable => 'No se encontró coincidencia'; + @override String get noConnectedTrackers => 'Conecta un tracker en Ajustes para calificar allí.'; +} + // Path: accessibility class _TranslationsAccessibilityEs extends TranslationsAccessibilityEn { _TranslationsAccessibilityEs._(TranslationsEs root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsEs { 'mediaMenu.rate' => 'Calificar', 'mediaMenu.playFromBeginning' => 'Reproducir desde el inicio', 'mediaMenu.playVersion' => 'Reproducir versión...', + 'rateSheet.title' => 'Calificar', + 'rateSheet.server' => 'Servidor', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Establecer puntuación', + 'rateSheet.notRated' => 'Sin calificar', + 'rateSheet.liked' => 'Me gusta', + 'rateSheet.notLiked' => 'Sin Me gusta', + 'rateSheet.saved' => 'Guardado', + 'rateSheet.notAvailable' => 'No se encontró coincidencia', + 'rateSheet.noConnectedTrackers' => 'Conecta un tracker en Ajustes para calificar allí.', '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}', @@ -2273,6 +2305,8 @@ extension on TranslationsEs { 'profiles.summaryMultiple' => ({required Object count}) => '${count} perfiles', 'profiles.removeConnectionTitle' => '¿Eliminar conexión?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Eliminar el acceso de ${displayName} a ${connectionLabel}. Los demás perfiles lo conservan.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => '¿Eliminar perfil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Eliminar ${displayName} y sus conexiones. Los servidores seguirán disponibles.', 'profiles.profileNameLabel' => 'Nombre del perfil', @@ -2284,8 +2318,6 @@ extension on TranslationsEs { 'profiles.confirmPinTitle' => 'Confirmar PIN', 'profiles.pinSet' => 'PIN establecido', 'profiles.changePin' => 'Cambiar', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Eliminar', 'profiles.connectionsLabel' => 'Conexiones', 'profiles.add' => 'Añadir', @@ -2787,6 +2819,8 @@ extension on TranslationsEs { 'metadataEdit.selectSquareArt' => 'Seleccionar imagen cuadrada', 'metadataEdit.fromUrl' => 'Desde URL', 'metadataEdit.uploadFile' => 'Subir archivo', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Introducir URL de imagen', 'metadataEdit.imageUrl' => 'URL de imagen', 'metadataEdit.metadataUpdated' => 'Metadatos actualizados', @@ -2798,8 +2832,6 @@ extension on TranslationsEs { 'metadataEdit.libraryDefault' => 'Predeterminado de biblioteca', 'metadataEdit.accountDefault' => 'Predeterminado de cuenta', 'metadataEdit.seriesDefault' => 'Predeterminado de serie', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Orden de episodios', 'metadataEdit.oldestFirst' => 'Más antiguos primero', 'metadataEdit.newestFirst' => 'Más recientes primero', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 0b4f10ac..fcb6eac2 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -49,6 +49,7 @@ class TranslationsFr extends Translations with BaseTranslations 'Lire la version...'; } +// Path: rateSheet +class _TranslationsRateSheetFr extends TranslationsRateSheetEn { + _TranslationsRateSheetFr._(TranslationsFr root) : this._root = root, super.internal(root); + + final TranslationsFr _root; // ignore: unused_field + + // Translations + @override String get title => 'Noter'; + @override String get server => 'Serveur'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Définir une note'; + @override String get notRated => 'Non noté'; + @override String get liked => 'Aimé'; + @override String get notLiked => 'Non aimé'; + @override String get saved => 'Enregistré'; + @override String get notAvailable => 'Aucune correspondance trouvée'; + @override String get noConnectedTrackers => 'Connectez un tracker dans Paramètres pour y noter.'; +} + // Path: accessibility class _TranslationsAccessibilityFr extends TranslationsAccessibilityEn { _TranslationsAccessibilityFr._(TranslationsFr root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsFr { 'mediaMenu.rate' => 'Noter', 'mediaMenu.playFromBeginning' => 'Lire depuis le début', 'mediaMenu.playVersion' => 'Lire la version...', + 'rateSheet.title' => 'Noter', + 'rateSheet.server' => 'Serveur', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Définir une note', + 'rateSheet.notRated' => 'Non noté', + 'rateSheet.liked' => 'Aimé', + 'rateSheet.notLiked' => 'Non aimé', + 'rateSheet.saved' => 'Enregistré', + 'rateSheet.notAvailable' => 'Aucune correspondance trouvée', + 'rateSheet.noConnectedTrackers' => 'Connectez un tracker dans Paramètres pour y 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}', @@ -2273,6 +2305,8 @@ extension on TranslationsFr { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profils', 'profiles.removeConnectionTitle' => 'Retirer la connexion ?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Supprimer l\'accès de ${displayName} à ${connectionLabel}. Les autres profils le conservent.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Supprimer le profil ?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Supprimer ${displayName} et ses connexions. Les serveurs restent disponibles.', 'profiles.profileNameLabel' => 'Nom du profil', @@ -2284,8 +2318,6 @@ extension on TranslationsFr { 'profiles.confirmPinTitle' => 'Confirmer le PIN', 'profiles.pinSet' => 'PIN défini', 'profiles.changePin' => 'Modifier', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Retirer', 'profiles.connectionsLabel' => 'Connexions', 'profiles.add' => 'Ajouter', @@ -2787,6 +2819,8 @@ extension on TranslationsFr { 'metadataEdit.selectSquareArt' => 'Sélectionner l\'image carrée', 'metadataEdit.fromUrl' => 'Depuis une URL', 'metadataEdit.uploadFile' => 'Importer un fichier', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Entrer l\'URL de l\'image', 'metadataEdit.imageUrl' => 'URL de l\'image', 'metadataEdit.metadataUpdated' => 'Métadonnées mises à jour', @@ -2798,8 +2832,6 @@ extension on TranslationsFr { 'metadataEdit.libraryDefault' => 'Par défaut de la bibliothèque', 'metadataEdit.accountDefault' => 'Par défaut du compte', 'metadataEdit.seriesDefault' => 'Par défaut de la série', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Tri des épisodes', 'metadataEdit.oldestFirst' => 'Plus anciens en premier', 'metadataEdit.newestFirst' => 'Plus récents en premier', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index b23dd518..d238a547 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -49,6 +49,7 @@ class TranslationsIt extends Translations with BaseTranslations 'Riproduci versione...'; } +// Path: rateSheet +class _TranslationsRateSheetIt extends TranslationsRateSheetEn { + _TranslationsRateSheetIt._(TranslationsIt root) : this._root = root, super.internal(root); + + final TranslationsIt _root; // ignore: unused_field + + // Translations + @override String get title => 'Valuta'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Imposta un punteggio'; + @override String get notRated => 'Non valutato'; + @override String get liked => 'Mi piace'; + @override String get notLiked => 'Non contrassegnato con Mi piace'; + @override String get saved => 'Salvato'; + @override String get notAvailable => 'Nessuna corrispondenza trovata'; + @override String get noConnectedTrackers => 'Collega un tracker in Impostazioni per valutarlo lì.'; +} + // Path: accessibility class _TranslationsAccessibilityIt extends TranslationsAccessibilityEn { _TranslationsAccessibilityIt._(TranslationsIt root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsIt { 'mediaMenu.rate' => 'Valuta', 'mediaMenu.playFromBeginning' => 'Riproduci dall\'inizio', 'mediaMenu.playVersion' => 'Riproduci versione...', + 'rateSheet.title' => 'Valuta', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Imposta un punteggio', + 'rateSheet.notRated' => 'Non valutato', + 'rateSheet.liked' => 'Mi piace', + 'rateSheet.notLiked' => 'Non contrassegnato con Mi piace', + 'rateSheet.saved' => 'Salvato', + 'rateSheet.notAvailable' => 'Nessuna corrispondenza trovata', + 'rateSheet.noConnectedTrackers' => 'Collega un tracker in Impostazioni per valutarlo lì.', '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}', @@ -2273,6 +2305,8 @@ extension on TranslationsIt { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profili', 'profiles.removeConnectionTitle' => 'Rimuovere la connessione?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Rimuovi l\'accesso di ${displayName} a ${connectionLabel}. Gli altri profili lo mantengono.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Eliminare il profilo?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Rimuovi ${displayName} e le sue connessioni. I server restano disponibili.', 'profiles.profileNameLabel' => 'Nome profilo', @@ -2284,8 +2318,6 @@ extension on TranslationsIt { 'profiles.confirmPinTitle' => 'Conferma PIN', 'profiles.pinSet' => 'PIN impostato', 'profiles.changePin' => 'Cambia', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Rimuovi', 'profiles.connectionsLabel' => 'Connessioni', 'profiles.add' => 'Aggiungi', @@ -2787,6 +2819,8 @@ extension on TranslationsIt { 'metadataEdit.selectSquareArt' => 'Seleziona immagine quadrata', 'metadataEdit.fromUrl' => 'Da URL', 'metadataEdit.uploadFile' => 'Carica file', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Inserisci URL immagine', 'metadataEdit.imageUrl' => 'URL immagine', 'metadataEdit.metadataUpdated' => 'Metadati aggiornati', @@ -2798,8 +2832,6 @@ extension on TranslationsIt { 'metadataEdit.libraryDefault' => 'Predefinito libreria', 'metadataEdit.accountDefault' => 'Predefinito account', 'metadataEdit.seriesDefault' => 'Predefinito serie', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Ordinamento episodi', 'metadataEdit.oldestFirst' => 'Più vecchi prima', 'metadataEdit.newestFirst' => 'Più recenti prima', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index a9256351..c18bb06e 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -49,6 +49,7 @@ class TranslationsJa extends Translations with BaseTranslations 'バージョンを再生...'; } +// Path: rateSheet +class _TranslationsRateSheetJa extends TranslationsRateSheetEn { + _TranslationsRateSheetJa._(TranslationsJa root) : this._root = root, super.internal(root); + + final TranslationsJa _root; // ignore: unused_field + + // Translations + @override String get title => '評価'; + @override String get server => 'サーバー'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'スコアを設定'; + @override String get notRated => '未評価'; + @override String get liked => 'いいね済み'; + @override String get notLiked => 'いいねなし'; + @override String get saved => '保存済み'; + @override String get notAvailable => '一致なし'; + @override String get noConnectedTrackers => '設定でトラッカーを接続すると、そこで評価できます。'; +} + // Path: accessibility class _TranslationsAccessibilityJa extends TranslationsAccessibilityEn { _TranslationsAccessibilityJa._(TranslationsJa root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsJa { 'mediaMenu.rate' => '評価', 'mediaMenu.playFromBeginning' => '最初から再生', 'mediaMenu.playVersion' => 'バージョンを再生...', + 'rateSheet.title' => '評価', + 'rateSheet.server' => 'サーバー', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'スコアを設定', + 'rateSheet.notRated' => '未評価', + 'rateSheet.liked' => 'いいね済み', + 'rateSheet.notLiked' => 'いいねなし', + 'rateSheet.saved' => '保存済み', + 'rateSheet.notAvailable' => '一致なし', + 'rateSheet.noConnectedTrackers' => '設定でトラッカーを接続すると、そこで評価できます。', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}、映画', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}、テレビ番組', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}、${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsJa { 'profiles.summaryMultiple' => ({required Object count}) => '${count}個のプロファイル', 'profiles.removeConnectionTitle' => '接続を削除しますか?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName}の${connectionLabel}へのアクセスを削除します。他のプロフィールには残ります。', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'プロファイルを削除しますか?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => '${displayName}とその接続を削除します。サーバーは引き続き利用できます。', 'profiles.profileNameLabel' => 'プロファイル名', @@ -2284,8 +2318,6 @@ extension on TranslationsJa { 'profiles.confirmPinTitle' => 'PINを確認', 'profiles.pinSet' => 'PIN設定済み', 'profiles.changePin' => '変更', - _ => null, - } ?? switch (path) { 'profiles.removePin' => '削除', 'profiles.connectionsLabel' => '接続', 'profiles.add' => '追加', @@ -2787,6 +2819,8 @@ extension on TranslationsJa { 'metadataEdit.selectSquareArt' => '正方形アートを選択', 'metadataEdit.fromUrl' => 'URLから', 'metadataEdit.uploadFile' => 'ファイルをアップロード', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => '画像URLを入力', 'metadataEdit.imageUrl' => '画像URL', 'metadataEdit.metadataUpdated' => 'メタデータを更新しました', @@ -2798,8 +2832,6 @@ extension on TranslationsJa { 'metadataEdit.libraryDefault' => 'ライブラリのデフォルト', 'metadataEdit.accountDefault' => 'アカウントのデフォルト', 'metadataEdit.seriesDefault' => 'シリーズのデフォルト', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'エピソードの並べ替え', 'metadataEdit.oldestFirst' => '古い順', 'metadataEdit.newestFirst' => '新しい順', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index b7f342e7..b82bc251 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -49,6 +49,7 @@ class TranslationsKo extends Translations with BaseTranslations '버전 재생...'; } +// Path: rateSheet +class _TranslationsRateSheetKo extends TranslationsRateSheetEn { + _TranslationsRateSheetKo._(TranslationsKo root) : this._root = root, super.internal(root); + + final TranslationsKo _root; // ignore: unused_field + + // Translations + @override String get title => '평가'; + @override String get server => '서버'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => '점수 설정'; + @override String get notRated => '평가 없음'; + @override String get liked => '좋아요'; + @override String get notLiked => '좋아요 아님'; + @override String get saved => '저장됨'; + @override String get notAvailable => '일치 항목 없음'; + @override String get noConnectedTrackers => '설정에서 트래커를 연결하면 거기에도 평가할 수 있습니다.'; +} + // Path: accessibility class _TranslationsAccessibilityKo extends TranslationsAccessibilityEn { _TranslationsAccessibilityKo._(TranslationsKo root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsKo { 'mediaMenu.rate' => '평가', 'mediaMenu.playFromBeginning' => '처음부터 재생', 'mediaMenu.playVersion' => '버전 재생...', + 'rateSheet.title' => '평가', + 'rateSheet.server' => '서버', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => '점수 설정', + 'rateSheet.notRated' => '평가 없음', + 'rateSheet.liked' => '좋아요', + 'rateSheet.notLiked' => '좋아요 아님', + 'rateSheet.saved' => '저장됨', + 'rateSheet.notAvailable' => '일치 항목 없음', + 'rateSheet.noConnectedTrackers' => '설정에서 트래커를 연결하면 거기에도 평가할 수 있습니다.', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 영화', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, TV 프로그램', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsKo { 'profiles.summaryMultiple' => ({required Object count}) => '${count}개 프로필', 'profiles.removeConnectionTitle' => '연결을 제거하시겠습니까?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '${displayName}의 ${connectionLabel} 접근 권한을 제거합니다. 다른 프로필에는 유지됩니다.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => '프로필을 삭제하시겠습니까?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => '${displayName} 및 해당 연결을 제거합니다. 서버는 계속 사용할 수 있습니다.', 'profiles.profileNameLabel' => '프로필 이름', @@ -2284,8 +2318,6 @@ extension on TranslationsKo { 'profiles.confirmPinTitle' => 'PIN 확인', 'profiles.pinSet' => 'PIN 설정됨', 'profiles.changePin' => '변경', - _ => null, - } ?? switch (path) { 'profiles.removePin' => '제거', 'profiles.connectionsLabel' => '연결', 'profiles.add' => '추가', @@ -2787,6 +2819,8 @@ extension on TranslationsKo { 'metadataEdit.selectSquareArt' => '정사각형 아트 선택', 'metadataEdit.fromUrl' => 'URL에서', 'metadataEdit.uploadFile' => '파일 업로드', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => '이미지 URL 입력', 'metadataEdit.imageUrl' => '이미지 URL', 'metadataEdit.metadataUpdated' => '메타데이터가 업데이트되었습니다', @@ -2798,8 +2832,6 @@ extension on TranslationsKo { 'metadataEdit.libraryDefault' => '라이브러리 기본값', 'metadataEdit.accountDefault' => '계정 기본값', 'metadataEdit.seriesDefault' => '시리즈 기본값', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => '에피소드 정렬', 'metadataEdit.oldestFirst' => '오래된 순', 'metadataEdit.newestFirst' => '최신 순', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 54c1953c..ebb98f2b 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -49,6 +49,7 @@ class TranslationsNb extends Translations with BaseTranslations 'Spill av versjon...'; } +// Path: rateSheet +class _TranslationsRateSheetNb extends TranslationsRateSheetEn { + _TranslationsRateSheetNb._(TranslationsNb root) : this._root = root, super.internal(root); + + final TranslationsNb _root; // ignore: unused_field + + // Translations + @override String get title => 'Vurder'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Sett en poengsum'; + @override String get notRated => 'Ikke vurdert'; + @override String get liked => 'Likt'; + @override String get notLiked => 'Ikke likt'; + @override String get saved => 'Lagret'; + @override String get notAvailable => 'Ingen treff'; + @override String get noConnectedTrackers => 'Koble til en sporer i Innstillinger for å vurdere der.'; +} + // Path: accessibility class _TranslationsAccessibilityNb extends TranslationsAccessibilityEn { _TranslationsAccessibilityNb._(TranslationsNb root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsNb { 'mediaMenu.rate' => 'Vurder', 'mediaMenu.playFromBeginning' => 'Spill fra begynnelsen', 'mediaMenu.playVersion' => 'Spill av versjon...', + 'rateSheet.title' => 'Vurder', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Sett en poengsum', + 'rateSheet.notRated' => 'Ikke vurdert', + 'rateSheet.liked' => 'Likt', + 'rateSheet.notLiked' => 'Ikke likt', + 'rateSheet.saved' => 'Lagret', + 'rateSheet.notAvailable' => 'Ingen treff', + 'rateSheet.noConnectedTrackers' => 'Koble til en sporer i Innstillinger for å vurdere der.', '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}', @@ -2273,6 +2305,8 @@ extension on TranslationsNb { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', 'profiles.removeConnectionTitle' => 'Fjerne tilkobling?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Fjern ${displayName}s tilgang til ${connectionLabel}. Andre profiler beholder den.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Slette profil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Fjern ${displayName} og tilkoblingene. Servere forblir tilgjengelige.', 'profiles.profileNameLabel' => 'Profilnavn', @@ -2284,8 +2318,6 @@ extension on TranslationsNb { 'profiles.confirmPinTitle' => 'Bekreft PIN', 'profiles.pinSet' => 'PIN satt', 'profiles.changePin' => 'Endre', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Fjern', 'profiles.connectionsLabel' => 'Tilkoblinger', 'profiles.add' => 'Legg til', @@ -2787,6 +2819,8 @@ extension on TranslationsNb { 'metadataEdit.selectSquareArt' => 'Velg kvadratisk bilde', 'metadataEdit.fromUrl' => 'Fra URL', 'metadataEdit.uploadFile' => 'Last opp fil', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Skriv inn bilde-URL', 'metadataEdit.imageUrl' => 'Bilde-URL', 'metadataEdit.metadataUpdated' => 'Metadata oppdatert', @@ -2798,8 +2832,6 @@ extension on TranslationsNb { 'metadataEdit.libraryDefault' => 'Bibliotekstandard', 'metadataEdit.accountDefault' => 'Kontostandard', 'metadataEdit.seriesDefault' => 'Seriestandard', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Episodesortering', 'metadataEdit.oldestFirst' => 'Eldste først', 'metadataEdit.newestFirst' => 'Nyeste først', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index ec8ee5a9..068b20ef 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -49,6 +49,7 @@ class TranslationsNl extends Translations with BaseTranslations 'Versie afspelen...'; } +// Path: rateSheet +class _TranslationsRateSheetNl extends TranslationsRateSheetEn { + _TranslationsRateSheetNl._(TranslationsNl root) : this._root = root, super.internal(root); + + final TranslationsNl _root; // ignore: unused_field + + // Translations + @override String get title => 'Beoordelen'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Score instellen'; + @override String get notRated => 'Niet beoordeeld'; + @override String get liked => 'Geliket'; + @override String get notLiked => 'Niet geliket'; + @override String get saved => 'Opgeslagen'; + @override String get notAvailable => 'Geen match gevonden'; + @override String get noConnectedTrackers => 'Verbind een tracker in Instellingen om daar te beoordelen.'; +} + // Path: accessibility class _TranslationsAccessibilityNl extends TranslationsAccessibilityEn { _TranslationsAccessibilityNl._(TranslationsNl root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsNl { 'mediaMenu.rate' => 'Beoordelen', 'mediaMenu.playFromBeginning' => 'Afspelen vanaf het begin', 'mediaMenu.playVersion' => 'Versie afspelen...', + 'rateSheet.title' => 'Beoordelen', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Score instellen', + 'rateSheet.notRated' => 'Niet beoordeeld', + 'rateSheet.liked' => 'Geliket', + 'rateSheet.notLiked' => 'Niet geliket', + 'rateSheet.saved' => 'Opgeslagen', + 'rateSheet.notAvailable' => 'Geen match gevonden', + 'rateSheet.noConnectedTrackers' => 'Verbind een tracker in Instellingen om daar te 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}', @@ -2273,6 +2305,8 @@ extension on TranslationsNl { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profielen', 'profiles.removeConnectionTitle' => 'Verbinding verwijderen?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Verwijder ${displayName}s toegang tot ${connectionLabel}. Andere profielen behouden die.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Profiel verwijderen?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Verwijder ${displayName} en de verbindingen. Servers blijven beschikbaar.', 'profiles.profileNameLabel' => 'Profielnaam', @@ -2284,8 +2318,6 @@ extension on TranslationsNl { 'profiles.confirmPinTitle' => 'PIN bevestigen', 'profiles.pinSet' => 'PIN ingesteld', 'profiles.changePin' => 'Wijzigen', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Verwijderen', 'profiles.connectionsLabel' => 'Verbindingen', 'profiles.add' => 'Toevoegen', @@ -2787,6 +2819,8 @@ extension on TranslationsNl { 'metadataEdit.selectSquareArt' => 'Vierkante afbeelding selecteren', 'metadataEdit.fromUrl' => 'Vanaf URL', 'metadataEdit.uploadFile' => 'Bestand uploaden', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Voer afbeeldings-URL in', 'metadataEdit.imageUrl' => 'Afbeeldings-URL', 'metadataEdit.metadataUpdated' => 'Metadata bijgewerkt', @@ -2798,8 +2832,6 @@ extension on TranslationsNl { 'metadataEdit.libraryDefault' => 'Bibliotheekstandaard', 'metadataEdit.accountDefault' => 'Accountstandaard', 'metadataEdit.seriesDefault' => 'Seriestandaard', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Afleveringen sorteren', 'metadataEdit.oldestFirst' => 'Oudste eerst', 'metadataEdit.newestFirst' => 'Nieuwste eerst', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index db5c1709..f369bc84 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -49,6 +49,7 @@ class TranslationsPl extends Translations with BaseTranslations 'Odtwórz wersję...'; } +// Path: rateSheet +class _TranslationsRateSheetPl extends TranslationsRateSheetEn { + _TranslationsRateSheetPl._(TranslationsPl root) : this._root = root, super.internal(root); + + final TranslationsPl _root; // ignore: unused_field + + // Translations + @override String get title => 'Oceń'; + @override String get server => 'Serwer'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Ustaw ocenę'; + @override String get notRated => 'Nie oceniono'; + @override String get liked => 'Polubione'; + @override String get notLiked => 'Nie polubiono'; + @override String get saved => 'Zapisano'; + @override String get notAvailable => 'Nie znaleziono dopasowania'; + @override String get noConnectedTrackers => 'Połącz tracker w Ustawieniach, aby tam oceniać.'; +} + // Path: accessibility class _TranslationsAccessibilityPl extends TranslationsAccessibilityEn { _TranslationsAccessibilityPl._(TranslationsPl root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsPl { 'mediaMenu.rate' => 'Oceń', 'mediaMenu.playFromBeginning' => 'Odtwórz od początku', 'mediaMenu.playVersion' => 'Odtwórz wersję...', + 'rateSheet.title' => 'Oceń', + 'rateSheet.server' => 'Serwer', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Ustaw ocenę', + 'rateSheet.notRated' => 'Nie oceniono', + 'rateSheet.liked' => 'Polubione', + 'rateSheet.notLiked' => 'Nie polubiono', + 'rateSheet.saved' => 'Zapisano', + 'rateSheet.notAvailable' => 'Nie znaleziono dopasowania', + 'rateSheet.noConnectedTrackers' => 'Połącz tracker w Ustawieniach, aby tam oceniać.', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, film', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, serial TV', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsPl { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profili', 'profiles.removeConnectionTitle' => 'Usunąć połączenie?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Usuń dostęp ${displayName} do ${connectionLabel}. Inne profile go zachowają.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Usunąć profil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Usuń ${displayName} i jego połączenia. Serwery pozostaną dostępne.', 'profiles.profileNameLabel' => 'Nazwa profilu', @@ -2284,8 +2318,6 @@ extension on TranslationsPl { 'profiles.confirmPinTitle' => 'Potwierdź PIN', 'profiles.pinSet' => 'PIN ustawiony', 'profiles.changePin' => 'Zmień', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Usuń', 'profiles.connectionsLabel' => 'Połączenia', 'profiles.add' => 'Dodaj', @@ -2787,6 +2819,8 @@ extension on TranslationsPl { 'metadataEdit.selectSquareArt' => 'Wybierz kwadratowy obraz', 'metadataEdit.fromUrl' => 'Z URL', 'metadataEdit.uploadFile' => 'Prześlij plik', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Wprowadź URL obrazu', 'metadataEdit.imageUrl' => 'URL obrazu', 'metadataEdit.metadataUpdated' => 'Metadane zaktualizowane', @@ -2798,8 +2832,6 @@ extension on TranslationsPl { 'metadataEdit.libraryDefault' => 'Domyślne biblioteki', 'metadataEdit.accountDefault' => 'Domyślne konta', 'metadataEdit.seriesDefault' => 'Domyślne serialu', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Sortowanie odcinków', 'metadataEdit.oldestFirst' => 'Najstarsze najpierw', 'metadataEdit.newestFirst' => 'Najnowsze najpierw', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 9119d01c..1b01573a 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -49,6 +49,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Reproduzir versão...'; } +// Path: rateSheet +class _TranslationsRateSheetPt extends TranslationsRateSheetEn { + _TranslationsRateSheetPt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Avaliar'; + @override String get server => 'Servidor'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Definir pontuação'; + @override String get notRated => 'Sem avaliação'; + @override String get liked => 'Gostei'; + @override String get notLiked => 'Não marcado como gostei'; + @override String get saved => 'Salvo'; + @override String get notAvailable => 'Nenhuma correspondência encontrada'; + @override String get noConnectedTrackers => 'Conecte um rastreador em Configurações para avaliar lá.'; +} + // Path: accessibility class _TranslationsAccessibilityPt extends TranslationsAccessibilityEn { _TranslationsAccessibilityPt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsPt { 'mediaMenu.rate' => 'Avaliar', 'mediaMenu.playFromBeginning' => 'Reproduzir do início', 'mediaMenu.playVersion' => 'Reproduzir versão...', + 'rateSheet.title' => 'Avaliar', + 'rateSheet.server' => 'Servidor', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Definir pontuação', + 'rateSheet.notRated' => 'Sem avaliação', + 'rateSheet.liked' => 'Gostei', + 'rateSheet.notLiked' => 'Não marcado como gostei', + 'rateSheet.saved' => 'Salvo', + 'rateSheet.notAvailable' => 'Nenhuma correspondência encontrada', + 'rateSheet.noConnectedTrackers' => 'Conecte um rastreador em Configurações para avaliar lá.', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, filme', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, série de TV', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsPt { 'profiles.summaryMultiple' => ({required Object count}) => '${count} perfis', 'profiles.removeConnectionTitle' => 'Remover conexão?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Remover acesso de ${displayName} a ${connectionLabel}. Outros perfis mantêm o acesso.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Excluir perfil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Remover ${displayName} e suas conexões. Servidores continuam disponíveis.', 'profiles.profileNameLabel' => 'Nome do perfil', @@ -2284,8 +2318,6 @@ extension on TranslationsPt { 'profiles.confirmPinTitle' => 'Confirmar PIN', 'profiles.pinSet' => 'PIN definido', 'profiles.changePin' => 'Alterar', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Remover', 'profiles.connectionsLabel' => 'Conexões', 'profiles.add' => 'Adicionar', @@ -2787,6 +2819,8 @@ extension on TranslationsPt { 'metadataEdit.selectSquareArt' => 'Selecionar Imagem Quadrada', 'metadataEdit.fromUrl' => 'Da URL', 'metadataEdit.uploadFile' => 'Enviar Arquivo', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Insira a URL da imagem', 'metadataEdit.imageUrl' => 'URL da Imagem', 'metadataEdit.metadataUpdated' => 'Metadados atualizados', @@ -2798,8 +2832,6 @@ extension on TranslationsPt { 'metadataEdit.libraryDefault' => 'Padrão da biblioteca', 'metadataEdit.accountDefault' => 'Padrão da conta', 'metadataEdit.seriesDefault' => 'Padrão da série', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Ordenação de Episódios', 'metadataEdit.oldestFirst' => 'Mais antigos primeiro', 'metadataEdit.newestFirst' => 'Mais recentes primeiro', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 5249c4dc..037b7f95 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -49,6 +49,7 @@ class TranslationsRu extends Translations with BaseTranslations 'Воспроизвести версию...'; } +// Path: rateSheet +class _TranslationsRateSheetRu extends TranslationsRateSheetEn { + _TranslationsRateSheetRu._(TranslationsRu root) : this._root = root, super.internal(root); + + final TranslationsRu _root; // ignore: unused_field + + // Translations + @override String get title => 'Оценить'; + @override String get server => 'Сервер'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Установить оценку'; + @override String get notRated => 'Без оценки'; + @override String get liked => 'Понравилось'; + @override String get notLiked => 'Не отмечено понравившимся'; + @override String get saved => 'Сохранено'; + @override String get notAvailable => 'Совпадений не найдено'; + @override String get noConnectedTrackers => 'Подключите трекер в настройках, чтобы оценивать там.'; +} + // Path: accessibility class _TranslationsAccessibilityRu extends TranslationsAccessibilityEn { _TranslationsAccessibilityRu._(TranslationsRu root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsRu { 'mediaMenu.rate' => 'Оценить', 'mediaMenu.playFromBeginning' => 'Воспроизвести сначала', 'mediaMenu.playVersion' => 'Воспроизвести версию...', + 'rateSheet.title' => 'Оценить', + 'rateSheet.server' => 'Сервер', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Установить оценку', + 'rateSheet.notRated' => 'Без оценки', + 'rateSheet.liked' => 'Понравилось', + 'rateSheet.notLiked' => 'Не отмечено понравившимся', + 'rateSheet.saved' => 'Сохранено', + 'rateSheet.notAvailable' => 'Совпадений не найдено', + 'rateSheet.noConnectedTrackers' => 'Подключите трекер в настройках, чтобы оценивать там.', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, фильм', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, сериал', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsRu { 'profiles.summaryMultiple' => ({required Object count}) => '${count} профилей', 'profiles.removeConnectionTitle' => 'Удалить соединение?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Удалить доступ ${displayName} к ${connectionLabel}. У других профилей он останется.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Удалить профиль?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Удалить ${displayName} и его подключения. Серверы останутся доступны.', 'profiles.profileNameLabel' => 'Имя профиля', @@ -2284,8 +2318,6 @@ extension on TranslationsRu { 'profiles.confirmPinTitle' => 'Подтвердить PIN', 'profiles.pinSet' => 'PIN установлен', 'profiles.changePin' => 'Изменить', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Удалить', 'profiles.connectionsLabel' => 'Соединения', 'profiles.add' => 'Добавить', @@ -2787,6 +2819,8 @@ extension on TranslationsRu { 'metadataEdit.selectSquareArt' => 'Выбрать квадратное изображение', 'metadataEdit.fromUrl' => 'По URL', 'metadataEdit.uploadFile' => 'Загрузить файл', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Введите URL изображения', 'metadataEdit.imageUrl' => 'URL изображения', 'metadataEdit.metadataUpdated' => 'Метаданные обновлены', @@ -2798,8 +2832,6 @@ extension on TranslationsRu { 'metadataEdit.libraryDefault' => 'По умолчанию библиотеки', 'metadataEdit.accountDefault' => 'По умолчанию аккаунта', 'metadataEdit.seriesDefault' => 'По умолчанию сериала', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Сортировка эпизодов', 'metadataEdit.oldestFirst' => 'Сначала старые', 'metadataEdit.newestFirst' => 'Сначала новые', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index e0cdfe10..768e148d 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -49,6 +49,7 @@ class TranslationsSv extends Translations with BaseTranslations 'Spela version...'; } +// Path: rateSheet +class _TranslationsRateSheetSv extends TranslationsRateSheetEn { + _TranslationsRateSheetSv._(TranslationsSv root) : this._root = root, super.internal(root); + + final TranslationsSv _root; // ignore: unused_field + + // Translations + @override String get title => 'Betygsätt'; + @override String get server => 'Server'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => 'Ange betyg'; + @override String get notRated => 'Inte betygsatt'; + @override String get liked => 'Gillad'; + @override String get notLiked => 'Inte gillad'; + @override String get saved => 'Sparat'; + @override String get notAvailable => 'Ingen matchning hittades'; + @override String get noConnectedTrackers => 'Anslut en spårare i Inställningar för att betygsätta där.'; +} + // Path: accessibility class _TranslationsAccessibilitySv extends TranslationsAccessibilityEn { _TranslationsAccessibilitySv._(TranslationsSv root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsSv { 'mediaMenu.rate' => 'Betygsätt', 'mediaMenu.playFromBeginning' => 'Spela från början', 'mediaMenu.playVersion' => 'Spela version...', + 'rateSheet.title' => 'Betygsätt', + 'rateSheet.server' => 'Server', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => 'Ange betyg', + 'rateSheet.notRated' => 'Inte betygsatt', + 'rateSheet.liked' => 'Gillad', + 'rateSheet.notLiked' => 'Inte gillad', + 'rateSheet.saved' => 'Sparat', + 'rateSheet.notAvailable' => 'Ingen matchning hittades', + 'rateSheet.noConnectedTrackers' => 'Anslut en spårare i Inställningar för att betygsätta där.', '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}', @@ -2273,6 +2305,8 @@ extension on TranslationsSv { 'profiles.summaryMultiple' => ({required Object count}) => '${count} profiler', 'profiles.removeConnectionTitle' => 'Ta bort anslutning?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => 'Ta bort ${displayName}s åtkomst till ${connectionLabel}. Andra profiler behåller den.', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => 'Ta bort profil?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => 'Ta bort ${displayName} och dess anslutningar. Servrar förblir tillgängliga.', 'profiles.profileNameLabel' => 'Profilnamn', @@ -2284,8 +2318,6 @@ extension on TranslationsSv { 'profiles.confirmPinTitle' => 'Bekräfta PIN', 'profiles.pinSet' => 'PIN angiven', 'profiles.changePin' => 'Ändra', - _ => null, - } ?? switch (path) { 'profiles.removePin' => 'Ta bort', 'profiles.connectionsLabel' => 'Anslutningar', 'profiles.add' => 'Lägg till', @@ -2787,6 +2819,8 @@ extension on TranslationsSv { 'metadataEdit.selectSquareArt' => 'Välj kvadratisk bild', 'metadataEdit.fromUrl' => 'Från URL', 'metadataEdit.uploadFile' => 'Ladda upp fil', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => 'Ange bild-URL', 'metadataEdit.imageUrl' => 'Bild-URL', 'metadataEdit.metadataUpdated' => 'Metadata uppdaterad', @@ -2798,8 +2832,6 @@ extension on TranslationsSv { 'metadataEdit.libraryDefault' => 'Biblioteksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', 'metadataEdit.seriesDefault' => 'Seriestandard', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => 'Avsnittsortering', 'metadataEdit.oldestFirst' => 'Äldst först', 'metadataEdit.newestFirst' => 'Nyast först', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 60c95911..44a8fd16 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -49,6 +49,7 @@ class TranslationsZh extends Translations with BaseTranslations '播放版本...'; } +// Path: rateSheet +class _TranslationsRateSheetZh extends TranslationsRateSheetEn { + _TranslationsRateSheetZh._(TranslationsZh root) : this._root = root, super.internal(root); + + final TranslationsZh _root; // ignore: unused_field + + // Translations + @override String get title => '评分'; + @override String get server => '服务器'; + @override String starValue({required Object rating}) => '${rating} / 5'; + @override String scoreValue({required Object score}) => '${score} / 10'; + @override String get setScore => '设置分数'; + @override String get notRated => '未评分'; + @override String get liked => '已喜欢'; + @override String get notLiked => '未喜欢'; + @override String get saved => '已保存'; + @override String get notAvailable => '未找到匹配项'; + @override String get noConnectedTrackers => '在设置中连接跟踪器即可在那里评分。'; +} + // Path: accessibility class _TranslationsAccessibilityZh extends TranslationsAccessibilityEn { _TranslationsAccessibilityZh._(TranslationsZh root) : this._root = root, super.internal(root); @@ -2098,6 +2119,17 @@ extension on TranslationsZh { 'mediaMenu.rate' => '评分', 'mediaMenu.playFromBeginning' => '从头播放', 'mediaMenu.playVersion' => '播放版本...', + 'rateSheet.title' => '评分', + 'rateSheet.server' => '服务器', + 'rateSheet.starValue' => ({required Object rating}) => '${rating} / 5', + 'rateSheet.scoreValue' => ({required Object score}) => '${score} / 10', + 'rateSheet.setScore' => '设置分数', + 'rateSheet.notRated' => '未评分', + 'rateSheet.liked' => '已喜欢', + 'rateSheet.notLiked' => '未喜欢', + 'rateSheet.saved' => '已保存', + 'rateSheet.notAvailable' => '未找到匹配项', + 'rateSheet.noConnectedTrackers' => '在设置中连接跟踪器即可在那里评分。', 'accessibility.mediaCardMovie' => ({required Object title}) => '${title}, 电影', 'accessibility.mediaCardShow' => ({required Object title}) => '${title}, 电视剧', 'accessibility.mediaCardEpisode' => ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}', @@ -2273,6 +2305,8 @@ extension on TranslationsZh { 'profiles.summaryMultiple' => ({required Object count}) => '${count} 个配置文件', 'profiles.removeConnectionTitle' => '移除连接?', 'profiles.removeConnectionMessage' => ({required Object displayName, required Object connectionLabel}) => '移除 ${displayName} 对 ${connectionLabel} 的访问。其他个人资料仍保留。', + _ => null, + } ?? switch (path) { 'profiles.deleteProfileTitle' => '删除配置文件?', 'profiles.deleteProfileMessage' => ({required Object displayName}) => '移除 ${displayName} 及其连接。服务器仍可用。', 'profiles.profileNameLabel' => '配置文件名称', @@ -2284,8 +2318,6 @@ extension on TranslationsZh { 'profiles.confirmPinTitle' => '确认 PIN', 'profiles.pinSet' => '已设置 PIN', 'profiles.changePin' => '更改', - _ => null, - } ?? switch (path) { 'profiles.removePin' => '移除', 'profiles.connectionsLabel' => '连接', 'profiles.add' => '添加', @@ -2787,6 +2819,8 @@ extension on TranslationsZh { 'metadataEdit.selectSquareArt' => '选择方形图片', 'metadataEdit.fromUrl' => '从 URL', 'metadataEdit.uploadFile' => '上传文件', + _ => null, + } ?? switch (path) { 'metadataEdit.enterImageUrl' => '输入图片 URL', 'metadataEdit.imageUrl' => '图片 URL', 'metadataEdit.metadataUpdated' => '元数据已更新', @@ -2798,8 +2832,6 @@ extension on TranslationsZh { 'metadataEdit.libraryDefault' => '媒体库默认', 'metadataEdit.accountDefault' => '账户默认', 'metadataEdit.seriesDefault' => '剧集默认', - _ => null, - } ?? switch (path) { 'metadataEdit.episodeSorting' => '剧集排序', 'metadataEdit.oldestFirst' => '最旧优先', 'metadataEdit.newestFirst' => '最新优先', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 55c01bbb..7531dc6b 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "Spela från början", "playVersion": "Spela version..." }, + "rateSheet": { + "title": "Betygsätt", + "server": "Server", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "Ange betyg", + "notRated": "Inte betygsatt", + "liked": "Gillad", + "notLiked": "Inte gillad", + "saved": "Sparat", + "notAvailable": "Ingen matchning hittades", + "noConnectedTrackers": "Anslut en spårare i Inställningar för att betygsätta där." + }, "accessibility": { "mediaCardMovie": "${title}, film", "mediaCardShow": "${title}, TV-serie", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 8ddf98ce..921e98c5 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -347,6 +347,19 @@ "playFromBeginning": "从头播放", "playVersion": "播放版本..." }, + "rateSheet": { + "title": "评分", + "server": "服务器", + "starValue": "${rating} / 5", + "scoreValue": "${score} / 10", + "setScore": "设置分数", + "notRated": "未评分", + "liked": "已喜欢", + "notLiked": "未喜欢", + "saved": "已保存", + "notAvailable": "未找到匹配项", + "noConnectedTrackers": "在设置中连接跟踪器即可在那里评分。" + }, "accessibility": { "mediaCardMovie": "${title}, 电影", "mediaCardShow": "${title}, 电视剧", diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index c18cf88f..626494d4 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -20,7 +20,6 @@ import '../focus/focusable_wrapper.dart'; import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/focus_builders.dart'; -import '../exceptions/media_server_exceptions.dart'; import '../media/media_backend.dart'; import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; @@ -774,9 +773,7 @@ class _MediaDetailScreenState extends State return ListenableBuilder( listenable: _ratingChipFocusNode, builder: (context, _) { - final activate = isNumeric - ? () => _showRatingDialog(context, metadata, starValue) - : () => _toggleLike(metadata); + final activate = () => _showRatingDialog(context, metadata); final colorScheme = Theme.of(context).colorScheme; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final showFocus = _ratingChipFocusNode.hasFocus && isKeyboardMode; @@ -831,57 +828,16 @@ class _MediaDetailScreenState extends State ); } - /// Like/unlike toggle for backends that only support binary ratings - /// (Jellyfin). Maps to [MediaServerClient.rate] with 10 (like) or -1 - /// (clear) — the Jellyfin client routes those through POST/DELETE on - /// `/UserItems/{id}/Rating`. - Future _toggleLike(MediaItem metadata) async { - final client = _getMediaClientForMetadata(context); - if (client == null) return; - final wasLiked = metadata.userRating != null && metadata.userRating! >= 6; - final newRating = wasLiked ? -1.0 : 10.0; - try { - await client.rate(metadata, newRating); - setStateIfMounted(() { - _fullMetadata = _fullMetadata?.copyWith(userRating: wasLiked ? 0 : 10); - }); - } on MediaServerHttpException catch (e) { - appLogger.w('Failed to toggle rating', error: e); - if (mounted) showErrorSnackBar(context, t.errors.failedToRate); - } - } - - void _showRatingDialog(BuildContext sheetContext, MediaItem metadata, double currentStarValue) { + void _showRatingDialog(BuildContext sheetContext, MediaItem metadata) { OverlaySheetController.showAdaptive( sheetContext, builder: (context) => RatingBottomSheet( - currentRating: currentStarValue, - onRate: (stars) async { - final client = _getMediaClientForMetadata(this.context); - if (client == null) return; - final plexRating = stars * 2.0; // Convert 0-5 stars to 0-10 scale - try { - await client.rate(metadata, plexRating); - setStateIfMounted(() { - _fullMetadata = _fullMetadata?.copyWith(userRating: plexRating); - }); - } on MediaServerHttpException catch (e) { - appLogger.w('Failed to set rating', error: e); - if (mounted) showErrorSnackBar(this.context, t.errors.failedToRate); - } - }, - onClear: () async { - final client = _getMediaClientForMetadata(this.context); - if (client == null) return; - try { - await client.rate(metadata, -1); - setStateIfMounted(() { - _fullMetadata = _fullMetadata?.copyWith(userRating: 0); - }); - } on MediaServerHttpException catch (e) { - appLogger.w('Failed to clear rating', error: e); - if (mounted) showErrorSnackBar(this.context, t.errors.failedToRate); - } + item: metadata, + serverClient: _getMediaClientForMetadata(this.context), + onServerRatingChanged: (rating) { + setStateIfMounted(() { + _fullMetadata = (_fullMetadata ?? widget.metadata).copyWith(userRating: rating); + }); }, ), ); diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index c15536f3..3c241815 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -50,6 +50,37 @@ class AnilistClient { await query(mutation, variables: {'mediaId': mediaId, 'progress': progress, 'status': status}); } + Future setMediaListScore({required int mediaId, required int score}) async { + const mutation = ''' + mutation(\$mediaId: Int, \$scoreRaw: Int) { + SaveMediaListEntry(mediaId: \$mediaId, scoreRaw: \$scoreRaw) { + id + } + } + '''; + await query(mutation, variables: {'mediaId': mediaId, 'scoreRaw': score.clamp(0, 10).toInt() * 10}); + } + + Future getMediaListScore(int mediaId) async { + const mediaQuery = ''' + query(\$mediaId: Int) { + Media(id: \$mediaId, type: ANIME) { + mediaListEntry { + scoreRaw: score(format: POINT_100) + } + } + } + '''; + final data = await query(mediaQuery, variables: {'mediaId': mediaId}); + final media = data['Media']; + if (media is! Map) return null; + final entry = media['mediaListEntry']; + if (entry is! Map) return null; + final scoreRaw = flexibleInt(entry['scoreRaw']); + if (scoreRaw == null || scoreRaw <= 0) return null; + return (scoreRaw / 10).round().clamp(1, 10).toInt(); + } + Future getAnimeEpisodeCount(int mediaId) async { const mediaQuery = ''' query(\$mediaId: Int) { diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index fc2dcc1d..a101c674 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -5,6 +5,7 @@ import '../../../utils/app_logger.dart'; import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; +import '../tracker_id_resolver.dart'; import 'anilist_client.dart'; import 'anilist_session.dart'; @@ -63,6 +64,32 @@ class AnilistTracker extends TrackerBase { appLogger.d('AniList: saved entry (anilist=$anilistId, progress=$watched, status=$status)'); } + Future rate(TrackerRatingContext ctx, int score) async { + final client = _client; + final anilistId = ctx.ids.anime?.anilist; + if (client == null || anilistId == null) throw const TrackerRatingUnavailableException('AniList'); + + final clamped = score.clamp(1, 10).toInt(); + await client.setMediaListScore(mediaId: anilistId, score: clamped); + appLogger.d('AniList: updated score (anilist=$anilistId, score=$clamped)'); + } + + Future clearRating(TrackerRatingContext ctx) async { + final client = _client; + final anilistId = ctx.ids.anime?.anilist; + if (client == null || anilistId == null) throw const TrackerRatingUnavailableException('AniList'); + + await client.setMediaListScore(mediaId: anilistId, score: 0); + appLogger.d('AniList: cleared score (anilist=$anilistId)'); + } + + Future getRating(TrackerRatingContext ctx) async { + final client = _client; + final anilistId = ctx.ids.anime?.anilist; + if (client == null || anilistId == null) throw const TrackerRatingUnavailableException('AniList'); + return client.getMediaListScore(anilistId); + } + Future _episodeCount(AnilistClient client, int anilistId) { final existing = _episodeCountLoads[anilistId]; if (existing != null) return existing; diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 276cfd6b..0d8dcd06 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -60,6 +60,20 @@ class MalClient { await _request('PUT', '/anime/$animeId/my_list_status', formBody: fields); } + Future getMyListScore(int animeId) async { + try { + final res = await _request('GET', '/anime/$animeId?fields=my_list_status'); + if (res is! Map) return null; + final myListStatus = res['my_list_status']; + if (myListStatus is! Map) return null; + final score = flexibleInt(myListStatus['score']); + return score != null && score > 0 ? score : null; + } on MalApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + Future getAnimeEpisodeCount(int animeId) async { final res = await _request('GET', '/anime/$animeId?fields=num_episodes'); if (res is! Map) return null; diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index 24cae065..6c99bef9 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -5,6 +5,7 @@ import '../../../utils/app_logger.dart'; import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; +import '../tracker_id_resolver.dart'; import 'mal_client.dart'; import 'mal_session.dart'; @@ -80,6 +81,32 @@ class MalTracker extends TrackerBase { appLogger.d('MAL: updated list status (mal=$malId, fields=$fields)'); } + Future rate(TrackerRatingContext ctx, int score) async { + final client = _client; + final malId = ctx.ids.anime?.mal; + if (client == null || malId == null) throw const TrackerRatingUnavailableException('MAL'); + + final clamped = score.clamp(1, 10).toInt(); + await client.updateMyListStatus(malId, {'score': '$clamped'}); + appLogger.d('MAL: updated score (mal=$malId, score=$clamped)'); + } + + Future clearRating(TrackerRatingContext ctx) async { + final client = _client; + final malId = ctx.ids.anime?.mal; + if (client == null || malId == null) throw const TrackerRatingUnavailableException('MAL'); + + await client.updateMyListStatus(malId, {'score': '0'}); + appLogger.d('MAL: cleared score (mal=$malId)'); + } + + Future getRating(TrackerRatingContext ctx) async { + final client = _client; + final malId = ctx.ids.anime?.mal; + if (client == null || malId == null) throw const TrackerRatingUnavailableException('MAL'); + return client.getMyListScore(malId); + } + Future _episodeCount(MalClient client, int malId) { final existing = _episodeCountLoads[malId]; if (existing != null) return existing; diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index 32a1bd31..db25fa33 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -39,6 +39,17 @@ class SimklClient { /// ``` Future addToHistory(Map body) => _request('POST', '/sync/history', body: body); + Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body); + + Future removeRatings(Map body) => _request('POST', '/sync/ratings/remove', body: body); + + Future> getRatings(String type) async { + final res = await _request('GET', '/sync/ratings/$type'); + if (res is List) return res; + if (res is Map && res[type] is List) return res[type] as List; + return const []; + } + Future _request(String method, String path, {Map? body}) async { final uri = Uri.parse('${SimklConstants.apiBase}$path'); final headers = SimklConstants.headers(accessToken: session.accessToken); diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index 94c3e1dc..8fe021ea 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -1,8 +1,14 @@ +import 'package:http/http.dart' as http; + +import '../../../models/trackers/anime_ids.dart'; import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; +import '../../../utils/external_ids.dart'; +import '../../../utils/json_utils.dart'; import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; +import '../tracker_id_resolver.dart'; import 'simkl_client.dart'; import 'simkl_session.dart'; @@ -35,9 +41,11 @@ class SimklTracker extends TrackerBase { @override bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableSimklScrobble); - void rebindSession(SimklSession? session, {required void Function() onSessionInvalidated}) { + void rebindSession(SimklSession? session, {required void Function() onSessionInvalidated, http.Client? httpClient}) { _client?.dispose(); - _client = session != null ? SimklClient(session, onSessionInvalidated: onSessionInvalidated) : null; + _client = session != null + ? SimklClient(session, onSessionInvalidated: onSessionInvalidated, httpClient: httpClient) + : null; } @override @@ -45,7 +53,7 @@ class SimklTracker extends TrackerBase { final client = _client; if (client == null) return; - final ids = _buildIds(ctx); + final ids = _buildIds(external: ctx.external, anime: ctx.anime); if (ids.isEmpty) return; final body = ctx.isMovie @@ -74,17 +82,90 @@ class SimklTracker extends TrackerBase { appLogger.d('Simkl: marked watched (ids=$ids, isMovie=${ctx.isMovie})'); } + Future getRating(TrackerRatingContext ctx) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Simkl'); + final ids = _buildIds(external: ctx.ids.external, anime: ctx.ids.anime); + if (ids.isEmpty) throw const TrackerRatingUnavailableException('Simkl'); + + final types = ctx.isMovie ? const ['movies'] : const ['shows', 'anime']; + for (final type in types) { + final entries = await client.getRatings(type); + for (final entry in entries) { + if (entry is! Map) continue; + final map = entry.cast(); + final media = map[ctx.isMovie ? 'movie' : 'show']; + final remoteIds = _nestedIds(media) ?? _nestedIds(map); + if (!_idsMatch(remoteIds, ids)) continue; + final rating = flexibleInt(map['user_rating']) ?? flexibleInt(map['rating']); + return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null; + } + } + return null; + } + + Future rate(TrackerRatingContext ctx, int score) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Simkl'); + final ids = _buildIds(external: ctx.ids.external, anime: ctx.ids.anime); + if (ids.isEmpty) throw const TrackerRatingUnavailableException('Simkl'); + + final clamped = score.clamp(1, 10).toInt(); + await client.addRatings(_ratingBody(ctx, ids, rating: clamped)); + appLogger.d('Simkl: updated score (ids=$ids, score=$clamped)'); + } + + Future clearRating(TrackerRatingContext ctx) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Simkl'); + final ids = _buildIds(external: ctx.ids.external, anime: ctx.ids.anime); + if (ids.isEmpty) throw const TrackerRatingUnavailableException('Simkl'); + + await client.removeRatings(_ratingBody(ctx, ids)); + appLogger.d('Simkl: cleared score (ids=$ids)'); + } + + Map _ratingBody(TrackerRatingContext ctx, Map ids, {int? rating}) { + final item = {'ids': ids, if (rating != null) 'rating': rating}; + return ctx.isMovie + ? { + 'movies': [item], + } + : { + 'shows': [item], + }; + } + + Map? _nestedIds(Object? value) { + if (value is! Map) return null; + final ids = value['ids']; + return ids is Map ? ids.cast() : null; + } + + bool _idsMatch(Map? remoteIds, Map localIds) { + if (remoteIds == null) return false; + for (final entry in localIds.entries) { + final remote = remoteIds[entry.key]; + if (remote == null) continue; + if (entry.value is String && remote.toString() == entry.value) return true; + final remoteInt = flexibleInt(remote); + final localInt = flexibleInt(entry.value); + if (remoteInt != null && localInt != null && remoteInt == localInt) return true; + } + return false; + } + /// Prefer Fribb's simkl_id for precision; otherwise send whatever Plex /// exposes. Simkl accepts tvdb/imdb/tmdb in both movie and show shapes. - Map _buildIds(TrackerContext ctx) { + Map _buildIds({required ExternalIds external, required AnimeIds? anime}) { final ids = {}; - final simklId = ctx.anime?.simkl; + final simklId = anime?.simkl; if (simklId != null) ids['simkl'] = simklId; - final tvdb = ctx.external.tvdb; + final tvdb = external.tvdb; if (tvdb != null) ids['tvdb'] = tvdb; - final tmdb = ctx.external.tmdb; + final tmdb = external.tmdb; if (tmdb != null) ids['tmdb'] = tmdb; - final imdb = ctx.external.imdb; + final imdb = external.imdb; if (imdb != null) ids['imdb'] = imdb; return ids; } diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 5e8cfc28..73e1c9ff 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -32,6 +32,15 @@ abstract class Tracker { Future markWatched(TrackerContext ctx); } +class TrackerRatingUnavailableException implements Exception { + final String trackerName; + + const TrackerRatingUnavailableException(this.trackerName); + + @override + String toString() => 'TrackerRatingUnavailableException($trackerName)'; +} + /// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient], /// [readEnabledSetting], and [markWatched]. abstract class TrackerBase implements Tracker { diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index 4f603b23..b5b34c89 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -1,4 +1,5 @@ import '../../media/media_item.dart'; +import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trackers/anime_ids.dart'; import '../../models/trackers/fribb_mapping_row.dart'; @@ -27,6 +28,22 @@ class TrackerIds { } } +/// Resolved IDs for manually rating a media item on external trackers. +/// +/// For TV items, [ids.external] is the show-level ID set. Trakt can then rate +/// the show/season/episode through nested season/episode numbers, while MAL, +/// AniList, and Simkl rate the mapped anime/show entry itself. +class TrackerRatingContext { + final TrackerIds ids; + final MediaKind kind; + final int? season; + final int? episodeNumber; + + const TrackerRatingContext({required this.ids, required this.kind, this.season, this.episodeNumber}); + + bool get isMovie => kind == MediaKind.movie; +} + /// Resolves item ids → tracker external IDs. Returns both backend-native /// external IDs (used by Trakt and by Simkl for non-anime matches) and Fribb /// anime IDs (used by MAL/AniList, and by Simkl for anime precision). @@ -100,6 +117,46 @@ class TrackerIdResolver { return ids.withAnimeProgress(progress); } + /// Resolve IDs for manual tracker ratings. Ratings can be attached to a + /// movie, show, season, or episode from the detail screen/context menu. + Future resolveForRating(MediaItem item) async { + switch (item.kind) { + case MediaKind.movie: + final ids = await resolveForMovie(item.id); + return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.movie); + case MediaKind.show: + final ids = await _resolveShow(item.id); + return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.show); + case MediaKind.season: + final showId = item.parentId; + final season = item.index ?? item.parentIndex; + if (showId == null || showId.isEmpty || season == null) return null; + final ids = await _resolveShow(showId, season: season); + return ids == null ? null : TrackerRatingContext(ids: ids, kind: MediaKind.season, season: season); + case MediaKind.episode: + final season = item.parentIndex; + final number = item.index; + if (season == null || number == null) return null; + final ids = await resolveShowForEpisode(item); + return ids == null + ? null + : TrackerRatingContext(ids: ids, kind: MediaKind.episode, season: season, episodeNumber: number); + default: + return null; + } + } + + Future _resolveShow(String showId, {int? season}) async { + if (showId.isEmpty) return null; + final cacheKey = season != null ? '$showId#s$season' : showId; + if (_cache.containsKey(cacheKey)) return _cache[cacheKey]; + + final external = await _fetchExternalIds(showId); + final ids = await _build(external, isEpisodeSeason: season, isMovie: false); + _cache[cacheKey] = ids; + return ids; + } + void clearCache() { _cache.clear(); _animeProgress.clearCache(); diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index f6b2e74f..1981873b 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -59,6 +59,16 @@ class TraktClient { Future removeFromHistory(TraktScrobbleRequest item) => _request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody()); + Future addRatings(Map body) => + _request('POST', '/sync/ratings', body: body, allowStatuses: const {200, 201}); + + Future removeRatings(Map body) => _request('POST', '/sync/ratings/remove', body: body); + + Future> getRatings(String type) async { + final res = await _request('GET', '/sync/ratings/$type'); + return res is List ? res : const []; + } + /// Refresh the access token. Coalesces concurrent calls so /// duplicate POSTs don't race when multiple in-flight requests hit 401. Future refresh() => _refreshCoalescer.run(_doRefresh); diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 86919efc..1336e43e 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -1,12 +1,16 @@ import 'dart:async'; +import 'package:http/http.dart' as http; + import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_scrobble_request.dart'; import '../../utils/app_logger.dart'; +import '../../utils/json_utils.dart'; import '../settings_service.dart'; +import '../trackers/tracker.dart'; import '../trackers/tracker_constants.dart'; import '../trackers/tracker_id_resolver.dart'; import 'trakt_client.dart'; @@ -66,12 +70,46 @@ class TraktScrobbleService { /// Switch to a different account. Cancels any in-flight scrobble for the /// previous account so we don't send a stop event to the wrong user. - void rebindToProfile(TraktSession? session, {required void Function() onSessionInvalidated}) { + void rebindToProfile( + TraktSession? session, { + required void Function() onSessionInvalidated, + http.Client? httpClient, + }) { _client?.dispose(); - _client = session != null ? TraktClient(session, onSessionInvalidated: onSessionInvalidated) : null; + _client = session != null + ? TraktClient(session, onSessionInvalidated: onSessionInvalidated, httpClient: httpClient) + : null; cancelInFlight(); } + Future getRating(TrackerRatingContext ctx) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + final localIds = TraktIds.fromExternal(ctx.ids.external).toJson(); + if (localIds.isEmpty) throw const TrackerRatingUnavailableException('Trakt'); + + final entries = await client.getRatings(_ratingType(ctx)); + for (final entry in entries) { + if (entry is! Map) continue; + if (!_ratingEntryMatches(ctx, entry.cast(), localIds)) continue; + final rating = flexibleInt(entry['rating']); + return rating != null && rating > 0 ? rating.clamp(1, 10).toInt() : null; + } + return null; + } + + Future rate(TrackerRatingContext ctx, int score) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + await client.addRatings(_ratingBody(ctx, rating: score.clamp(1, 10).toInt())); + } + + Future clearRating(TrackerRatingContext ctx) async { + final client = _client; + if (client == null) throw const TrackerRatingUnavailableException('Trakt'); + await client.removeRatings(_ratingBody(ctx)); + } + /// Drop the current scrobble state without sending a stop. Called on profile /// switch and when the service is disabled mid-playback. void cancelInFlight() { @@ -86,6 +124,100 @@ class TraktScrobbleService { bool get _canScrobble => _isEnabled && _client != null; + String _ratingType(TrackerRatingContext ctx) => switch (ctx.kind) { + MediaKind.movie => 'movies', + MediaKind.show => 'shows', + MediaKind.season => 'seasons', + MediaKind.episode => 'episodes', + _ => throw const TrackerRatingUnavailableException('Trakt'), + }; + + bool _ratingEntryMatches(TrackerRatingContext ctx, Map entry, Map localIds) { + final show = entry['show']; + final movie = entry['movie']; + return switch (ctx.kind) { + MediaKind.movie => _idsMatch(_nestedIds(movie), localIds), + MediaKind.show => _idsMatch(_nestedIds(show), localIds), + MediaKind.season => _idsMatch(_nestedIds(show), localIds) && _numberMatches(entry['season'], ctx.season), + MediaKind.episode => + _idsMatch(_nestedIds(show), localIds) && + _numberMatches(entry['episode'], ctx.episodeNumber) && + _seasonMatches(entry['episode'], ctx.season), + _ => false, + }; + } + + Map? _nestedIds(Object? value) { + if (value is! Map) return null; + final ids = value['ids']; + return ids is Map ? ids.cast() : null; + } + + bool _idsMatch(Map? remoteIds, Map localIds) { + if (remoteIds == null) return false; + for (final entry in localIds.entries) { + final local = entry.value; + if (local == null) continue; + final remote = remoteIds[entry.key]; + if (remote == null) continue; + if (local is String && remote.toString() == local) return true; + final remoteInt = flexibleInt(remote); + final localInt = flexibleInt(local); + if (remoteInt != null && localInt != null && remoteInt == localInt) return true; + } + return false; + } + + bool _numberMatches(Object? value, int? expected) { + if (expected == null || value is! Map) return false; + return flexibleInt(value['number']) == expected; + } + + bool _seasonMatches(Object? value, int? expected) { + if (expected == null || value is! Map) return false; + return flexibleInt(value['season']) == expected; + } + + Map _ratingBody(TrackerRatingContext ctx, {int? rating}) { + final ids = TraktIds.fromExternal(ctx.ids.external).toJson(); + final item = {'ids': ids, if (rating != null) 'rating': rating}; + + return switch (ctx.kind) { + MediaKind.movie => { + 'movies': [item], + }, + MediaKind.show => { + 'shows': [item], + }, + MediaKind.season => { + 'shows': [ + { + 'ids': ids, + 'seasons': [ + {'number': ctx.season, if (rating != null) 'rating': rating}, + ], + }, + ], + }, + MediaKind.episode => { + 'shows': [ + { + 'ids': ids, + 'seasons': [ + { + 'number': ctx.season, + 'episodes': [ + {'number': ctx.episodeNumber, if (rating != null) 'rating': rating}, + ], + }, + ], + }, + ], + }, + _ => throw const TrackerRatingUnavailableException('Trakt'), + }; + } + Future startPlayback(MediaItem metadata, MediaServerClient client, {bool isLive = false}) async { if (!_canScrobble) return; if (isLive) return; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 188cf7f9..57bf38fa 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../exceptions/media_server_exceptions.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -1116,32 +1115,13 @@ class MediaContextMenuState extends State { } Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { - final currentStarValue = (item.userRating != null && item.userRating! > 0) ? item.userRating! / 2.0 : 0.0; await OverlaySheetController.showAdaptive( context, showDragHandle: true, builder: (context) => RatingBottomSheet( - currentRating: currentStarValue, - onRate: (stars) async { - // 0-10 scale used by both Plex and Jellyfin rate endpoints. - final rating = stars * 2.0; - try { - await client.rate(item, rating); - widget.onRefresh?.call(item.id); - } on MediaServerHttpException catch (e) { - appLogger.w('Failed to set rating', error: e); - if (context.mounted) showErrorSnackBar(context, t.errors.failedToRate); - } - }, - onClear: () async { - try { - await client.rate(item, -1); - widget.onRefresh?.call(item.id); - } on MediaServerHttpException catch (e) { - appLogger.w('Failed to clear rating', error: e); - if (context.mounted) showErrorSnackBar(context, t.errors.failedToRate); - } - }, + item: item, + serverClient: client, + onServerRatingChanged: (_) => widget.onRefresh?.call(item.id), ), ); } diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index bec85758..9f133751 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -1,191 +1,978 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../widgets/app_icon.dart'; -import '../widgets/overlay_sheet.dart'; +import 'package:provider/provider.dart'; + import '../focus/dpad_navigator.dart'; -import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; -import '../utils/formatters.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_server_client.dart'; +import '../providers/trackers_provider.dart'; +import '../providers/trakt_account_provider.dart'; +import '../services/trackers/anilist/anilist_tracker.dart'; +import '../services/trackers/mal/mal_tracker.dart'; +import '../services/trackers/simkl/simkl_tracker.dart'; +import '../services/trackers/tracker.dart'; +import '../services/trackers/tracker_constants.dart'; +import '../services/trackers/tracker_id_resolver.dart'; +import '../services/trakt/trakt_scrobble_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/snackbar_helper.dart'; +import 'app_icon.dart'; +import 'backend_badge.dart'; +import 'bottom_sheet_header.dart'; class RatingBottomSheet extends StatefulWidget { - final double currentRating; - final Future Function(double stars) onRate; - final Future Function() onClear; + final MediaItem item; + final MediaServerClient? serverClient; + final ValueChanged? onServerRatingChanged; - const RatingBottomSheet({super.key, required this.currentRating, required this.onRate, required this.onClear}); + const RatingBottomSheet({super.key, required this.item, required this.serverClient, this.onServerRatingChanged}); @override State createState() => _RatingBottomSheetState(); } class _RatingBottomSheetState extends State { - late double _selectedRating; - late final FocusNode _starsFocusNode; + static const _autoSaveDelay = Duration(milliseconds: 600); + + late double _serverStars; + late double _serverRating; + late bool? _serverLike; + late final FocusNode _serverFocusNode; + + final Map _trackerScores = {}; + final Map _trackerFocusNodes = {}; + final Map _autoSaveTimers = {}; + final Map _trackerSourcesByKey = {}; + final Set _pendingAutoSaves = {}; + final Set _hiddenTrackers = {}; + final Set _loading = {}; + final Map _statuses = {}; + TrackerIdResolver? _resolver; + bool _resolverNeedsFribb = false; + String? _trackerLoadKey; @override void initState() { super.initState(); - _selectedRating = widget.currentRating; - _starsFocusNode = FocusNode(debugLabel: 'rating_stars'); + _serverFocusNode = FocusNode(debugLabel: 'rating_server'); + final rawServerRating = widget.item.userRating; + _serverRating = rawServerRating != null && rawServerRating > 0 ? rawServerRating : 0.0; + _serverStars = _serverRating > 0 ? _serverRating / 2.0 : 0.0; + _serverLike = rawServerRating == null ? null : rawServerRating >= 6.0; } @override void dispose() { - _starsFocusNode.dispose(); + _flushPendingAutoSavesOnDispose(); + _serverFocusNode.dispose(); + for (final node in _trackerFocusNodes.values) { + node.dispose(); + } + _resolver?.clearCache(); super.dispose(); } @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _selectedRating > 0 ? '${formatRating(_selectedRating)} / 5' : t.mediaMenu.rate, - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 16), - Focus( - focusNode: _starsFocusNode, - autofocus: InputModeTracker.isKeyboardMode(context), - 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: const BorderRadius.all(Radius.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( + final size = MediaQuery.sizeOf(context); + final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74); + + return Consumer2( + builder: (context, trakt, trackers, _) { + final allTrackerSources = _trackerSources(trakt, trackers); + final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList(); + _updateTrackerSourceMap(trackerSources); + _resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected; + _queueTrackerScoreLoad(allTrackerSources); + + final focusNodes = [ + if (widget.serverClient != null) _serverFocusNode, + for (final source in trackerSources) _trackerFocusNode(source.service), + ]; + var focusIndex = 0; + + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - if (widget.currentRating > 0) - Expanded( - child: FocusableButton( - onPressed: () { - widget.onClear(); - OverlaySheetController.closeAdaptive(context); - }, - child: OutlinedButton( - onPressed: () { - widget.onClear(); - OverlaySheetController.closeAdaptive(context); - }, - child: Text(t.common.clear), - ), - ), - ), - if (widget.currentRating > 0) const SizedBox(width: 12), - Expanded( - child: FocusableButton( - onPressed: _selectedRating > 0 - ? () { - widget.onRate(_selectedRating); - OverlaySheetController.closeAdaptive(context); - } - : null, - child: FilledButton( - onPressed: _selectedRating > 0 - ? () { - widget.onRate(_selectedRating); - OverlaySheetController.closeAdaptive(context); - } - : null, - child: Text(t.mediaMenu.rate), - ), + BottomSheetHeader(title: t.rateSheet.title, icon: Symbols.star_rounded), + Flexible( + child: ListView( + padding: const EdgeInsets.fromLTRB(10, 4, 10, 12), + children: [ + if (widget.serverClient != null) + _buildServerRow( + context, + widget.serverClient!, + _serverFocusNode, + autofocus: focusIndex == 0, + onNavigateUp: _navTo(focusNodes, focusIndex - 1), + onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), + ), + for (final source in trackerSources) + _buildTrackerRow( + context, + source, + _trackerFocusNode(source.service), + autofocus: focusIndex == 0, + onNavigateUp: _navTo(focusNodes, focusIndex - 1), + onNavigateDown: _navTo(focusNodes, focusIndex++ + 1), + ), + if (allTrackerSources.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: Text( + t.rateSheet.noConnectedTrackers, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ], ), ), ], ), + ); + }, + ); + } + + Widget _buildServerRow( + BuildContext context, + MediaServerClient client, + FocusNode focusNode, { + required bool autofocus, + required VoidCallback? onNavigateUp, + required VoidCallback? onNavigateDown, + }) { + final loading = _loading.contains(_serverKey); + final status = _statuses[_serverKey]; + final subtitle = + '${_backendLabel(client.backend)} - ${client.serverName ?? widget.item.serverName ?? t.common.unknown}'; + + if (client.capabilities.numericUserRating) { + final value = (_serverStars * 2).round().clamp(0, 10).toInt(); + return _RatingRow( + focusNode: focusNode, + autofocus: autofocus, + leading: BackendBadge(backend: client.backend, size: 22), + title: t.rateSheet.server, + subtitle: subtitle, + loading: loading, + status: status, + enabled: !loading, + onNavigateUp: onNavigateUp, + onNavigateDown: onNavigateDown, + onDecrease: () => _setServerStarUnits(value - 1), + onIncrease: () => _setServerStarUnits(value + 1), + onSubmit: () => unawaited(_submitServerStars()), + control: _StarRatingControl( + value: value, + enabled: !loading, + onChanged: _setServerStarUnits, + onSubmitValue: (units) => unawaited(_submitServerStars(stars: units / 2.0)), + ), + ); + } + + final value = switch (_serverLike) { + null => 0, + false => 1, + true => 2, + }; + return _RatingRow( + focusNode: focusNode, + autofocus: autofocus, + leading: BackendBadge(backend: client.backend, size: 22), + title: t.rateSheet.server, + subtitle: subtitle, + loading: loading, + status: status, + enabled: !loading, + onNavigateUp: onNavigateUp, + onNavigateDown: onNavigateDown, + onDecrease: () => _setServerLikeValue(value - 1), + onIncrease: () => _setServerLikeValue(value + 1), + onSubmit: () => unawaited(_submitServerLike()), + control: _BinaryControl( + value: value, + enabled: !loading, + onChanged: _setServerLikeValue, + onSubmitValue: (next) => unawaited(_submitServerLike(value: _likeFromIndex(next))), + ), + ); + } + + Widget _buildTrackerRow( + BuildContext context, + _TrackerRatingSource source, + FocusNode focusNode, { + required bool autofocus, + required VoidCallback? onNavigateUp, + required VoidCallback? onNavigateDown, + }) { + final key = source.service.name; + final loading = _loading.contains(key); + final status = _statuses[key]; + final score = _trackerScores[source.service] ?? 0; + return _RatingRow( + focusNode: focusNode, + autofocus: autofocus, + leading: _TrackerLogo(source.logoAsset), + title: source.title, + subtitle: source.username != null ? t.trackers.connectedAs(username: source.username!) : source.connectedLabel, + loading: loading, + status: status, + enabled: !loading, + onNavigateUp: onNavigateUp, + onNavigateDown: onNavigateDown, + onDecrease: () => _setTrackerScore(source, score - 1), + onIncrease: () => _setTrackerScore(source, score + 1), + onSubmit: () => unawaited(_submitTrackerRating(source)), + control: _StarRatingControl( + value: score, + enabled: !loading, + onChanged: (value) => _setTrackerScore(source, value), + onSubmitValue: (value) => unawaited(_submitTrackerRating(source, score: value)), + ), + ); + } + + List<_TrackerRatingSource> _trackerSources(TraktAccountProvider trakt, TrackersProvider trackers) { + final sources = <_TrackerRatingSource>[]; + if (trakt.isConnected) { + sources.add( + _TrackerRatingSource( + service: TrackerService.trakt, + title: t.trakt.title, + username: trakt.username, + connectedLabel: t.trakt.connected, + logoAsset: 'assets/trakt_circlemark.svg', + getRating: TraktScrobbleService.instance.getRating, + onRate: TraktScrobbleService.instance.rate, + onClear: TraktScrobbleService.instance.clearRating, + ), + ); + } + if (trackers.isMalConnected) { + sources.add( + _TrackerRatingSource( + service: TrackerService.mal, + title: t.trackers.services.mal, + username: trackers.malUsername, + connectedLabel: t.trakt.connected, + logoAsset: 'assets/mal_mark.svg', + getRating: MalTracker.instance.getRating, + onRate: MalTracker.instance.rate, + onClear: MalTracker.instance.clearRating, + ), + ); + } + if (trackers.isAnilistConnected) { + sources.add( + _TrackerRatingSource( + service: TrackerService.anilist, + title: t.trackers.services.anilist, + username: trackers.anilistUsername, + connectedLabel: t.trakt.connected, + logoAsset: 'assets/anilist_mark.svg', + getRating: AnilistTracker.instance.getRating, + onRate: AnilistTracker.instance.rate, + onClear: AnilistTracker.instance.clearRating, + ), + ); + } + if (trackers.isSimklConnected) { + sources.add( + _TrackerRatingSource( + service: TrackerService.simkl, + title: t.trackers.services.simkl, + username: trackers.simklUsername, + connectedLabel: t.trakt.connected, + logoAsset: 'assets/simkl_mark.svg', + getRating: SimklTracker.instance.getRating, + onRate: SimklTracker.instance.rate, + onClear: SimklTracker.instance.clearRating, + ), + ); + } + return sources; + } + + void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) { + _trackerSourcesByKey + ..clear() + ..addEntries(sources.map((source) => MapEntry(source.service.name, source))); + } + + void _queueTrackerScoreLoad(List<_TrackerRatingSource> sources) { + final services = sources.map((s) => s.service.name).join(','); + final key = '${widget.serverClient?.serverId}:${widget.item.id}:$services'; + if (_trackerLoadKey == key) return; + _trackerLoadKey = key; + if (sources.isEmpty) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _trackerLoadKey != key) return; + unawaited(_loadTrackerScores(sources)); + }); + } + + Future _loadTrackerScores(List<_TrackerRatingSource> sources) async { + setState(() { + for (final source in sources) { + _loading.add(source.service.name); + _statuses.remove(source.service.name); + } + }); + + TrackerRatingContext ctx; + try { + ctx = await _resolveTrackerContext(); + } on TrackerRatingUnavailableException { + if (!mounted) return; + setState(() { + for (final source in sources) { + if (_hidesWhenUnavailable(source)) { + _hideTrackerSource(source); + } else { + _loading.remove(source.service.name); + _statuses[source.service.name] = _SectionStatus(t.rateSheet.notAvailable, isError: true); + } + } + }); + return; + } + + await Future.wait( + sources.map((source) async { + final key = source.service.name; + try { + final score = await source.getRating(ctx); + if (!mounted) return; + setState(() { + _trackerScores[source.service] = score ?? 0; + _statuses.remove(key); + }); + } on TrackerRatingUnavailableException catch (e) { + appLogger.d('Rating unavailable', error: e); + if (!mounted) return; + if (_hidesWhenUnavailable(source)) { + setState(() { + _hideTrackerSource(source); + }); + return; + } + setState(() { + _statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true); + }); + } catch (e) { + appLogger.w('Failed to load rating', error: e); + if (!mounted) return; + setState(() { + _statuses[key] = _SectionStatus(t.errors.failedToRate, isError: true); + }); + } finally { + if (mounted) { + setState(() { + _loading.remove(key); + }); + } + } + }), + ); + } + + bool _hidesWhenUnavailable(_TrackerRatingSource source) { + return source.service == TrackerService.mal || source.service == TrackerService.anilist; + } + + void _hideTrackerSource(_TrackerRatingSource source) { + final key = source.service.name; + _hiddenTrackers.add(source.service); + _loading.remove(key); + _statuses.remove(key); + _trackerScores.remove(source.service); + _autoSaveTimers.remove(key)?.cancel(); + _pendingAutoSaves.remove(key); + _trackerSourcesByKey.remove(key); + } + + void _setServerStarUnits(int units) { + final clamped = units.clamp(0, 10).toInt(); + if ((_serverStars * 2).round() == clamped) return; + setState(() { + _serverStars = clamped / 2.0; + _statuses.remove(_serverKey); + }); + _scheduleAutoSave(_serverKey, _submitServerStars); + } + + void _setServerLikeValue(int value) { + final clamped = value.clamp(0, 2).toInt(); + final next = _likeFromIndex(clamped); + if (_serverLike == next) return; + setState(() { + _serverLike = next; + _statuses.remove(_serverKey); + }); + _scheduleAutoSave(_serverKey, _submitServerLike); + } + + bool? _likeFromIndex(int value) => switch (value.clamp(0, 2).toInt()) { + 0 => null, + 1 => false, + _ => true, + }; + + void _setTrackerScore(_TrackerRatingSource source, int score) { + final clamped = score.clamp(0, 10).toInt(); + if ((_trackerScores[source.service] ?? 0) == clamped) return; + setState(() { + _trackerScores[source.service] = clamped; + _statuses.remove(source.service.name); + }); + _scheduleAutoSave(source.service.name, () => _submitTrackerRating(source)); + } + + Future _submitServerStars({double? stars}) async { + _cancelAutoSave(_serverKey); + final value = stars ?? _serverStars; + if (value <= 0) { + await _clearServerRating(); + return; + } + + final rating = value * 2.0; + await _run(_serverKey, () async { + await widget.serverClient!.rate(widget.item, rating); + _serverRating = rating; + _serverStars = value; + widget.onServerRatingChanged?.call(rating); + }); + } + + Future _clearServerRating() async { + _cancelAutoSave(_serverKey); + await _run(_serverKey, () async { + await widget.serverClient!.rate(widget.item, -1); + _serverRating = 0; + _serverStars = 0; + _serverLike = null; + widget.onServerRatingChanged?.call(0); + }); + } + + Future _submitServerLike({bool? value}) async { + _cancelAutoSave(_serverKey); + final selected = value ?? _serverLike; + if (selected == null) { + await _clearServerRating(); + return; + } + + final rating = selected ? 10.0 : 0.0; + await _run(_serverKey, () async { + await widget.serverClient!.rate(widget.item, rating); + _serverLike = selected; + _serverRating = rating; + widget.onServerRatingChanged?.call(_serverRating); + }); + } + + Future _submitTrackerRating(_TrackerRatingSource source, {int? score}) async { + _cancelAutoSave(source.service.name); + final value = score ?? _trackerScores[source.service] ?? 0; + if (value <= 0) { + await _clearTrackerRating(source); + return; + } + await _run(source.service.name, () async { + final ctx = await _resolveTrackerContext(); + await source.onRate(ctx, value); + _trackerScores[source.service] = value; + }); + } + + Future _clearTrackerRating(_TrackerRatingSource source) async { + _cancelAutoSave(source.service.name); + await _run(source.service.name, () async { + final ctx = await _resolveTrackerContext(); + await source.onClear(ctx); + _trackerScores[source.service] = 0; + }); + } + + Future _resolveTrackerContext() async { + final client = widget.serverClient; + if (client == null) throw const TrackerRatingUnavailableException('tracker'); + _resolver ??= TrackerIdResolver(client, needsFribb: () => _resolverNeedsFribb); + final ctx = await _resolver!.resolveForRating(widget.item); + if (ctx == null) throw const TrackerRatingUnavailableException('tracker'); + return ctx; + } + + Future _run(String key, Future Function() action) async { + setState(() { + _loading.add(key); + _statuses.remove(key); + }); + + try { + await action(); + if (!mounted) return; + setState(() { + _statuses[key] = _SectionStatus(t.rateSheet.saved); + }); + } on TrackerRatingUnavailableException catch (e) { + appLogger.d('Rating unavailable', error: e); + if (!mounted) return; + setState(() { + _statuses[key] = _SectionStatus(t.rateSheet.notAvailable, isError: true); + }); + } catch (e) { + appLogger.w('Failed to update rating', error: e); + if (!mounted) return; + setState(() { + _statuses[key] = _SectionStatus(t.errors.failedToRate, isError: true); + }); + showErrorSnackBar(context, t.errors.failedToRate); + } finally { + if (mounted) { + setState(() { + _loading.remove(key); + }); + } + } + } + + void _scheduleAutoSave(String key, Future Function() submit) { + _pendingAutoSaves.add(key); + _autoSaveTimers.remove(key)?.cancel(); + _autoSaveTimers[key] = Timer(_autoSaveDelay, () { + _autoSaveTimers.remove(key); + _pendingAutoSaves.remove(key); + unawaited(submit()); + }); + } + + void _cancelAutoSave(String key) { + _autoSaveTimers.remove(key)?.cancel(); + _pendingAutoSaves.remove(key); + } + + void _flushPendingAutoSavesOnDispose() { + final pendingKeys = Set.from(_pendingAutoSaves); + for (final key in pendingKeys) { + _autoSaveTimers.remove(key)?.cancel(); + } + _pendingAutoSaves.clear(); + + for (final key in pendingKeys) { + if (key == _serverKey) { + unawaited(_saveServerRatingDetached()); + continue; + } + final source = _trackerSourcesByKey[key]; + if (source != null) unawaited(_saveTrackerRatingDetached(source)); + } + } + + Future _saveServerRatingDetached() async { + final client = widget.serverClient; + if (client == null) return; + + try { + if (client.capabilities.numericUserRating) { + final rating = _serverStars <= 0 ? -1.0 : _serverStars * 2.0; + await client.rate(widget.item, rating); + widget.onServerRatingChanged?.call(rating < 0 ? 0 : rating); + return; + } + + final selected = _serverLike; + final rating = selected == null ? -1.0 : (selected ? 10.0 : 0.0); + await client.rate(widget.item, rating); + widget.onServerRatingChanged?.call(rating < 0 ? 0 : rating); + } catch (e) { + appLogger.w('Failed to update rating after sheet close', error: e); + } + } + + Future _saveTrackerRatingDetached(_TrackerRatingSource source) async { + try { + final ctx = await _resolveTrackerContext(); + final value = _trackerScores[source.service] ?? 0; + if (value <= 0) { + await source.onClear(ctx); + } else { + await source.onRate(ctx, value); + } + } catch (e) { + appLogger.w('Failed to update tracker rating after sheet close', error: e); + } + } + + FocusNode _trackerFocusNode(TrackerService service) { + return _trackerFocusNodes.putIfAbsent(service, () => FocusNode(debugLabel: 'rating_${service.name}')); + } + + VoidCallback? _navTo(List nodes, int index) { + if (index < 0 || index >= nodes.length) return null; + return () => nodes[index].requestFocus(); + } + + String _backendLabel(MediaBackend backend) => switch (backend) { + MediaBackend.plex => 'Plex', + MediaBackend.jellyfin => 'Jellyfin', + }; +} + +const _serverKey = 'server'; + +class _TrackerRatingSource { + final TrackerService service; + final String title; + final String? username; + final String connectedLabel; + final String logoAsset; + final Future Function(TrackerRatingContext ctx) getRating; + final Future Function(TrackerRatingContext ctx, int score) onRate; + final Future Function(TrackerRatingContext ctx) onClear; + + const _TrackerRatingSource({ + required this.service, + required this.title, + required this.username, + required this.connectedLabel, + required this.logoAsset, + required this.getRating, + required this.onRate, + required this.onClear, + }); +} + +class _SectionStatus { + final String text; + final bool isError; + + const _SectionStatus(this.text, {this.isError = false}); +} + +class _RatingRow extends StatelessWidget { + final FocusNode focusNode; + final bool autofocus; + final Widget leading; + final String title; + final String subtitle; + final bool loading; + final _SectionStatus? status; + final bool enabled; + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateDown; + final VoidCallback onDecrease; + final VoidCallback onIncrease; + final VoidCallback onSubmit; + final Widget control; + + const _RatingRow({ + required this.focusNode, + required this.autofocus, + required this.leading, + required this.title, + required this.subtitle, + required this.loading, + required this.status, + required this.enabled, + required this.onNavigateUp, + required this.onNavigateDown, + required this.onDecrease, + required this.onIncrease, + required this.onSubmit, + required this.control, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final status = this.status; + final statusText = status?.isError == true ? status!.text : subtitle; + final statusColor = status?.isError == true ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant; + final controlWidth = MediaQuery.sizeOf(context).width < 390 ? 124.0 : 142.0; + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: FocusableWrapper( + focusNode: focusNode, + autofocus: autofocus && InputModeTracker.isKeyboardMode(context), + borderRadius: 12, + autoScroll: true, + disableScale: true, + useBackgroundFocus: true, + descendantsAreFocusable: false, + onSelect: enabled ? onSubmit : null, + onNavigateUp: onNavigateUp, + onNavigateDown: onNavigateDown, + onKeyEvent: (_, event) { + if (!enabled || !event.isActionable) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key.isLeftKey) { + onDecrease(); + return KeyEventResult.handled; + } + if (key.isRightKey) { + onIncrease(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + child: Row( + children: [ + SizedBox(width: 24, height: 24, child: Center(child: leading)), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700)), + Text( + statusText, + style: theme.textTheme.bodySmall?.copyWith(color: statusColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox(width: controlWidth, child: control), + const SizedBox(width: 8), + SizedBox( + width: 18, + height: 18, + child: _TrailingStatus(loading: loading, status: status), + ), + ], + ), + ), + ), + ); + } +} + +class _TrailingStatus extends StatelessWidget { + final bool loading; + final _SectionStatus? status; + + const _TrailingStatus({required this.loading, required this.status}); + + @override + Widget build(BuildContext context) { + if (loading) return const CircularProgressIndicator(strokeWidth: 2); + final status = this.status; + if (status == null) return const SizedBox.shrink(); + final color = status.isError ? Theme.of(context).colorScheme.error : Theme.of(context).colorScheme.primary; + return Tooltip( + message: status.text, + child: AppIcon( + status.isError ? Symbols.error_rounded : Symbols.check_circle_rounded, + fill: 1, + color: color, + size: 18, + ), + ); + } +} + +class _StarRatingControl extends StatefulWidget { + final int value; + final bool enabled; + final ValueChanged onChanged; + final ValueChanged onSubmitValue; + + const _StarRatingControl({ + required this.value, + required this.enabled, + required this.onChanged, + required this.onSubmitValue, + }); + + @override + State<_StarRatingControl> createState() => _StarRatingControlState(); +} + +class _StarRatingControlState extends State<_StarRatingControl> { + int? _pointerValue; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final starWidth = (constraints.maxWidth / 5).clamp(0.0, 27.0).toDouble(); + final iconSize = (starWidth * 0.9).clamp(0.0, 24.0).toDouble(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, + onTapUp: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, + onPanUpdate: widget.enabled ? (details) => _setFromDx(details.localPosition.dx, constraints.maxWidth) : null, + onPanEnd: widget.enabled ? (_) => widget.onSubmitValue(_pointerValue ?? widget.value) : null, + child: SizedBox( + height: 34, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: List.generate(5, (i) { + final threshold = (i + 1) * 2; + final filled = widget.value >= threshold; + final half = widget.value == threshold - 1; + return SizedBox( + width: starWidth, + child: Center( + child: AppIcon( + half ? Symbols.star_half_rounded : Symbols.star_rounded, + fill: filled || half ? 1 : 0, + color: filled || half ? Colors.amber : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.34), + size: iconSize, + ), + ), + ); + }), + ), + ), + ); + }, + ); + } + + void _setFromDx(double dx, double width) { + final safeWidth = width <= 0 ? 1.0 : width; + final value = ((dx.clamp(0.0, safeWidth) / safeWidth) * 10).round().clamp(0, 10).toInt(); + _pointerValue = value; + widget.onChanged(value); + } +} + +class _BinaryControl extends StatelessWidget { + final int value; + final bool enabled; + final ValueChanged onChanged; + final ValueChanged onSubmitValue; + + const _BinaryControl({ + required this.value, + required this.enabled, + required this.onChanged, + required this.onSubmitValue, + }); + + @override + Widget build(BuildContext context) { + final label = switch (value) { + 0 => t.rateSheet.notRated, + 1 => t.rateSheet.notLiked, + _ => t.rateSheet.liked, + }; + return _StepperPill( + label: label, + enabled: enabled, + onDecrease: () => onChanged((value - 1).clamp(0, 2).toInt()), + onIncrease: () => onChanged((value + 1).clamp(0, 2).toInt()), + onSubmit: () => onSubmitValue(value), + ); + } +} + +class _StepperPill extends StatelessWidget { + final String label; + final bool enabled; + final VoidCallback onDecrease; + final VoidCallback onIncrease; + final VoidCallback onSubmit; + + const _StepperPill({ + required this.label, + required this.enabled, + required this.onDecrease, + required this.onIncrease, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Container( + height: 32, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.54), + borderRadius: const BorderRadius.all(Radius.circular(100)), + ), + child: Row( + children: [ + _arrow(context, Symbols.chevron_left_rounded, onDecrease), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? onSubmit : null, + child: Center( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ), + ), + _arrow(context, Symbols.chevron_right_rounded, onIncrease), ], ), ); } - KeyEventResult _handleStarsKeyEvent(FocusNode _, KeyEvent event) { - if (!event.isActionable) return KeyEventResult.ignored; - final key = event.logicalKey; + Widget _arrow(BuildContext context, IconData icon, VoidCallback action) { + final theme = Theme.of(context); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled ? action : null, + child: SizedBox( + width: 30, + height: 32, + child: Center( + child: AppIcon( + icon, + fill: 1, + color: enabled ? theme.colorScheme.onSurfaceVariant : theme.disabledColor, + size: 20, + ), + ), + ), + ); + } +} - 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); - OverlaySheetController.closeAdaptive(context); - } - return KeyEventResult.handled; - } +class _TrackerLogo extends StatelessWidget { + final String asset; - return KeyEventResult.ignored; - } + const _TrackerLogo(this.asset); - 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; - }); + @override + Widget build(BuildContext context) { + final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; + return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color)); } } diff --git a/test/services/trackers/rating_fetch_test.dart b/test/services/trackers/rating_fetch_test.dart new file mode 100644 index 00000000..38ef88b4 --- /dev/null +++ b/test/services/trackers/rating_fetch_test.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/trackers/anime_ids.dart'; +import 'package:plezy/services/trackers/anilist/anilist_session.dart'; +import 'package:plezy/services/trackers/anilist/anilist_tracker.dart'; +import 'package:plezy/services/trackers/mal/mal_session.dart'; +import 'package:plezy/services/trackers/mal/mal_tracker.dart'; +import 'package:plezy/services/trackers/simkl/simkl_session.dart'; +import 'package:plezy/services/trackers/simkl/simkl_tracker.dart'; +import 'package:plezy/services/trackers/tracker_id_resolver.dart'; +import 'package:plezy/services/trakt/trakt_scrobble_service.dart'; +import 'package:plezy/services/trakt/trakt_session.dart'; +import 'package:plezy/utils/external_ids.dart'; + +int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +TraktSession _traktSession() => TraktSession( + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: _now() + 86400, + scope: 'public', + createdAt: _now(), +); + +SimklSession _simklSession() => SimklSession(accessToken: 'token', createdAt: _now()); + +MalSession _malSession() => + MalSession(accessToken: 'token', refreshToken: 'refresh', expiresAt: _now() + 86400, createdAt: _now()); + +AnilistSession _anilistSession() => AnilistSession(accessToken: 'token', expiresAt: _now() + 86400, createdAt: _now()); + +TrackerRatingContext _ctx({ + required MediaKind kind, + ExternalIds external = const ExternalIds(tvdb: 123, tmdb: 456, imdb: 'tt789'), + AnimeIds? anime, + int? season, + int? episodeNumber, +}) { + return TrackerRatingContext( + ids: TrackerIds(external: external, anime: anime), + kind: kind, + season: season, + episodeNumber: episodeNumber, + ); +} + +void main() { + tearDown(() { + TraktScrobbleService.instance.rebindToProfile(null, onSessionInvalidated: () {}); + SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + MalTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {}); + }); + + test('Trakt fetches the current episode rating by show ids and episode number', () async { + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/sync/ratings/episodes'); + return http.Response( + json.encode([ + { + 'rating': 8, + 'show': { + 'ids': {'tvdb': 123}, + }, + 'episode': {'season': 1, 'number': 2}, + }, + ]), + 200, + ); + }); + TraktScrobbleService.instance.rebindToProfile(_traktSession(), onSessionInvalidated: () {}, httpClient: client); + + final score = await TraktScrobbleService.instance.getRating( + _ctx(kind: MediaKind.episode, season: 1, episodeNumber: 2), + ); + + expect(score, 8); + }); + + test('Simkl fetches the current show rating by external ids', () async { + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/sync/ratings/shows'); + return http.Response( + json.encode({ + 'shows': [ + { + 'user_rating': 7, + 'show': { + 'ids': {'tmdb': 456}, + }, + }, + ], + }), + 200, + ); + }); + SimklTracker.instance.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: client); + + final score = await SimklTracker.instance.getRating(_ctx(kind: MediaKind.show)); + + expect(score, 7); + }); + + test('Simkl checks the anime ratings bucket for non-movie anime', () async { + final paths = []; + final client = MockClient((request) async { + expect(request.method, 'GET'); + paths.add(request.url.path); + if (request.url.path == '/sync/ratings/shows') { + return http.Response(json.encode({'shows': []}), 200); + } + if (request.url.path == '/sync/ratings/anime') { + return http.Response( + json.encode({ + 'anime': [ + { + 'user_rating': 9, + 'show': { + 'ids': {'simkl': 987}, + }, + }, + ], + }), + 200, + ); + } + fail('Unexpected Simkl request: ${request.url.path}'); + }); + SimklTracker.instance.rebindSession(_simklSession(), onSessionInvalidated: () {}, httpClient: client); + + final score = await SimklTracker.instance.getRating(_ctx(kind: MediaKind.show, anime: AnimeIds(simkl: 987))); + + expect(score, 9); + expect(paths, ['/sync/ratings/shows', '/sync/ratings/anime']); + }); + + test('MAL fetches the current list score', () async { + final client = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/v2/anime/21'); + expect(request.url.queryParameters['fields'], 'my_list_status'); + return http.Response( + json.encode({ + 'my_list_status': {'score': 9}, + }), + 200, + ); + }); + MalTracker.instance.rebindSession(_malSession(), onSessionInvalidated: () {}, httpClient: client); + + final score = await MalTracker.instance.getRating(_ctx(kind: MediaKind.show, anime: AnimeIds(mal: 21))); + + expect(score, 9); + }); + + test('AniList fetches point-100 score and maps it to a 1-10 score', () async { + final client = MockClient((request) async { + final body = json.decode(request.body) as Map; + expect(body['variables'], {'mediaId': 21}); + expect(body['query'], contains('mediaListEntry')); + expect(body['query'], contains('scoreRaw: score(format: POINT_100)')); + return http.Response( + json.encode({ + 'data': { + 'Media': { + 'mediaListEntry': {'scoreRaw': 80}, + }, + }, + }), + 200, + ); + }); + AnilistTracker.instance.rebindSession(_anilistSession(), onSessionInvalidated: () {}, httpClient: client); + + final score = await AnilistTracker.instance.getRating(_ctx(kind: MediaKind.show, anime: AnimeIds(anilist: 21))); + + expect(score, 8); + }); +}