From d5f3e581df2b79c8f8ceb132d7deb4861baf9cec Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:05:46 +0200 Subject: [PATCH] fix: eliminate cross-app consistency drift --- lib/focus/focusable_action_bar.dart | 3 +- lib/i18n/bg.i18n.json | 27 +- lib/i18n/da.i18n.json | 27 +- lib/i18n/de.i18n.json | 27 +- lib/i18n/en.i18n.json | 15 +- lib/i18n/es.i18n.json | 27 +- lib/i18n/fr.i18n.json | 27 +- lib/i18n/it.i18n.json | 27 +- lib/i18n/ja.i18n.json | 26 +- lib/i18n/ko.i18n.json | 26 +- lib/i18n/nb.i18n.json | 27 +- lib/i18n/nl.i18n.json | 27 +- lib/i18n/pl.i18n.json | 29 +- lib/i18n/pt.i18n.json | 27 +- lib/i18n/ru.i18n.json | 29 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 55 ++- lib/i18n/strings_da.g.dart | 55 ++- lib/i18n/strings_de.g.dart | 55 ++- lib/i18n/strings_en.g.dart | 57 ++- lib/i18n/strings_es.g.dart | 55 ++- lib/i18n/strings_fr.g.dart | 55 ++- lib/i18n/strings_it.g.dart | 55 ++- lib/i18n/strings_ja.g.dart | 54 ++- lib/i18n/strings_ko.g.dart | 54 ++- lib/i18n/strings_nb.g.dart | 55 ++- lib/i18n/strings_nl.g.dart | 55 ++- lib/i18n/strings_pl.g.dart | 57 ++- lib/i18n/strings_pt.g.dart | 55 ++- lib/i18n/strings_ru.g.dart | 57 ++- lib/i18n/strings_sv.g.dart | 55 ++- lib/i18n/strings_zh.g.dart | 54 ++- lib/i18n/sv.i18n.json | 27 +- lib/i18n/zh.i18n.json | 26 +- lib/main.dart | 8 +- lib/media/media_server_client.dart | 3 +- lib/mixins/item_updatable.dart | 36 +- lib/providers/discover_provider.dart | 35 +- lib/providers/theme_provider.dart | 13 - lib/screens/actor_media_screen.dart | 7 +- .../base_media_list_detail_screen.dart | 16 +- lib/screens/collection_detail_screen.dart | 15 +- .../mobile_remote_screen.dart | 80 +++-- lib/screens/discover_screen.dart | 73 ++-- lib/screens/downloads/downloads_screen.dart | 80 ++--- lib/screens/downloads/sync_rules_screen.dart | 5 +- .../focusable_detail_screen_mixin.dart | 38 +- lib/screens/hub_detail_screen.dart | 27 +- lib/screens/libraries/alpha_jump_bar.dart | 10 +- .../libraries/content_state_builder.dart | 8 +- lib/screens/libraries/folder_tree_item.dart | 28 +- lib/screens/libraries/folder_tree_view.dart | 17 +- lib/screens/libraries/libraries_screen.dart | 2 +- lib/screens/libraries/state_messages.dart | 23 +- .../libraries/tabs/base_library_tab.dart | 8 +- .../libraries/tabs/library_browse_tab.dart | 40 +-- .../tabs/library_collections_tab.dart | 9 +- .../libraries/tabs/library_playlists_tab.dart | 9 +- .../tabs/library_recommended_tab.dart | 15 +- lib/screens/livetv/live_tv_screen.dart | 41 +-- .../livetv/live_tv_show_schedule_screen.dart | 1 + lib/screens/main_screen.dart | 3 +- lib/screens/media_detail/action_buttons.dart | 2 +- lib/screens/media_detail_screen.dart | 64 ++-- lib/screens/metadata_edit_screen.dart | 2 +- lib/screens/music/album_detail_screen.dart | 3 - lib/screens/music/artist_detail_screen.dart | 9 +- .../playlist/playlist_detail_screen.dart | 30 +- lib/screens/playlist/playlist_item_card.dart | 61 ++-- lib/screens/profile/pin_entry_dialog.dart | 6 +- lib/screens/search_screen.dart | 22 +- lib/screens/settings/about_screen.dart | 3 +- .../settings/appearance_settings_screen.dart | 11 +- lib/screens/settings/logs_screen.dart | 3 +- lib/screens/settings/settings_screen.dart | 87 +++-- lib/screens/settings/settings_utils.dart | 16 +- .../settings/subtitle_styling_screen.dart | 14 +- .../settings/tracker_settings_screen.dart | 2 +- .../settings/trakt_settings_screen.dart | 2 +- .../widgets/player_prompt_overlays.dart | 4 +- lib/services/jellyfin_client.dart | 3 +- .../jellyfin_client/parts/file_info.dart | 28 +- lib/services/update_service.dart | 28 +- lib/utils/error_message_utils.dart | 37 +- lib/utils/formatters.dart | 6 +- lib/utils/media_image_helper.dart | 60 ++++ lib/utils/media_navigation_helper.dart | 21 +- .../screens/watch_together_screen.dart | 32 +- .../widgets/join_session_dialog.dart | 14 +- .../widgets/watch_together_overlay.dart | 19 +- lib/widgets/app_menu.dart | 36 ++ lib/widgets/catalog_context_menu.dart | 3 +- .../companion_remote/discovery_view.dart | 33 +- .../remote_session_dialog.dart | 22 +- lib/widgets/device_code_dialog.dart | 4 +- lib/widgets/episode_card.dart | 4 +- lib/widgets/focusable_media_card.dart | 3 +- lib/widgets/hub_section.dart | 9 +- lib/widgets/media_card.dart | 7 +- lib/widgets/media_context_menu.dart | 117 +++---- lib/widgets/music/track_row.dart | 2 +- lib/widgets/oauth_proxy_dialog.dart | 4 +- lib/widgets/optimized_media_image.dart | 42 +-- lib/widgets/seerr_request_sheet.dart | 2 +- lib/widgets/server_activities_button.dart | 2 +- lib/widgets/tv_browse_rail.dart | 2 +- lib/widgets/tv_spotlight_background.dart | 20 +- lib/widgets/tv_virtual_keyboard.dart | 39 ++- .../desktop_video_controls.dart | 4 +- .../video_controls/mobile_video_controls.dart | 5 +- .../sheets/subtitle_search_sheet.dart | 4 +- .../sheets/video_settings_sheet.dart | 12 +- .../widgets/sync_offset_control.dart | 4 +- .../widgets/track_chapter_controls.dart | 2 +- test/i18n/localized_value_labels_test.dart | 24 ++ test/providers/discover_provider_test.dart | 57 ++- test/providers/theme_provider_test.dart | 57 ++- .../catalog_item_detail_screen_test.dart | 17 +- test/screens/catalog_search_screen_test.dart | 128 +++++++ .../downloads_screen_focus_test.dart | 286 ++++++++++++++- .../focusable_detail_screen_mixin_test.dart | 128 ++++++- .../libraries/alpha_jump_bar_test.dart | 206 +++++++++++ .../libraries/state_messages_test.dart | 87 +++++ .../live_tv_show_schedule_screen_test.dart | 193 +++++++++++ .../playlist/playlist_item_card_test.dart | 183 ++++++++++ test/screens/playlist_detail_screen_test.dart | 250 +++++++++++++- .../add_local_profile_screen_test.dart | 3 +- test/screens/search_screen_test.dart | 162 +++++++-- .../settings/settings_screen_test.dart | 325 ++++++++++++++++++ test/services/jellyfin_auth_service_test.dart | 19 + test/services/jellyfin_media_info_test.dart | 44 +++ .../plex_client_http_contract_test.dart | 147 ++++++++ test/utils/error_message_utils_test.dart | 83 +++++ test/utils/media_image_helper_test.dart | 80 +++++ test/widgets/focusable_text_field_test.dart | 7 +- test/widgets/media_context_menu_test.dart | 269 +++++++++++++++ test/widgets/music/mini_player_test.dart | 20 +- test/widgets/optimized_media_image_test.dart | 54 ++- test/widgets/pin_entry_dialog_test.dart | 5 +- test/widgets/remote_session_dialog_test.dart | 2 + test/widgets/tv_virtual_keyboard_test.dart | 5 +- test/widgets/video_settings_sheet_test.dart | 55 ++- 142 files changed, 4751 insertions(+), 1075 deletions(-) create mode 100644 test/i18n/localized_value_labels_test.dart create mode 100644 test/screens/libraries/state_messages_test.dart create mode 100644 test/screens/livetv/live_tv_show_schedule_screen_test.dart create mode 100644 test/screens/playlist/playlist_item_card_test.dart create mode 100644 test/screens/settings/settings_screen_test.dart create mode 100644 test/utils/error_message_utils_test.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart index 15d425a8..6ffb1a97 100644 --- a/lib/focus/focusable_action_bar.dart +++ b/lib/focus/focusable_action_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../widgets/app_icon.dart'; import '../widgets/clickable_cursor.dart'; @@ -41,7 +42,7 @@ class FocusableAction { final FocusableActionBuilder? builder; const FocusableAction({ - this.icon = Icons.circle, + this.icon = Symbols.circle_rounded, this.iconColor, this.iconFill = 1.0, this.iconSize = 24, diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index e7ecae9f..6fc6385f 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Шаблон за маркер на финални надписи", "creditsPatternDescription": "Шаблон с регулярен израз за намиране на маркери за финални надписи в заглавия на глави", "invalidRegex": "Невалиден регулярен израз", + "regex": "Регулярен израз", "downloads": "Изтегляния", "downloadLocationDescription": "Изберете къде да се съхранява изтегленото съдържание", "downloadLocationDefault": "По подразбиране (хранилище на приложението)", @@ -336,6 +337,8 @@ "clearShortcut": "Изчисти клавишната комбинация", "noShortcutSet": "Няма зададена клавишна комбинация", "currentShortcut": "Текуща комбинация:", + "pressToRecord": "Избери, за да запишеш клавишна комбинация", + "recordingShortcut": "Натисни клавишната комбинация сега", "actions": { "playPause": "Пускане/пауза", "volumeUp": "Увеличи звука", @@ -434,7 +437,9 @@ "hue": "Нюанс", "saturation": "Наситеност", "brightness": "Яркост", - "hexColor": "Шестнадесетичен цвят" + "hexColor": "Шестнадесетичен цвят", + "expandText": "Разгъни текста", + "collapseText": "Свий текста" }, "tooltips": { "shufflePlay": "Разбъркано възпроизвеждане", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Маркирано като гледано (ще се синхронизира, когато сте онлайн)", "markedAsUnwatchedOffline": "Маркирано като негледано (ще се синхронизира, когато сте онлайн)", "autoRemovedWatchedDownload": "Автоматично премахнато: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Автоматично премахнато ${n} гледано изтегляне", + "other": "Автоматично премахнати ${n} гледани изтегляния" + }, "removedFromContinueWatching": "Премахнато от продължаване на гледането", "errorLoading": "Грешка: ${error}", "streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.", @@ -585,6 +594,11 @@ "backgroundColor": "Цвят на фона", "position": "Позиция", "assOverride": "ASS презаписване", + "overrideScale": "Мащабиране", + "overrideForce": "Принудително", + "overrideStrip": "Премахване на стиловете", + "positionTop": "Горе", + "positionBottom": "Долу", "bold": "Получер", "italic": "Курсив", "renderResolution": "Резолюция на изобразяване", @@ -710,7 +724,7 @@ "searchFailed": "Търсенето е неуспешно: ${error}", "connectionTimeout": "Изтече времето за връзка при зареждане на ${context}", "connectionFailed": "Не може да се осъществи връзка с медиен сървър", - "failedToLoad": "Неуспешно зареждане на ${context}: ${error}", + "unableToLoad": "Не може да се зареди ${context}. Опитайте отново.", "noClientAvailable": "Няма наличен клиент", "pleaseEnterToken": "Моля, въведете токен", "invalidToken": "Невалиден токен", @@ -966,7 +980,6 @@ "deleted": "Колекцията е изтрита", "deleteFailed": "Неуспешно изтриване на колекция", "deleteFailedWithError": "Неуспешно изтриване на колекция: ${error}", - "failedToLoadItems": "Неуспешно зареждане на елементите в колекцията: ${error}", "selectCollection": "Избери колекция", "collectionName": "Име на колекция", "enterCollectionName": "Въведете име на колекция", @@ -1049,6 +1062,8 @@ "hostingSession": "Сесия с домакин", "inSession": "В сесия", "sessionCode": "Код на сесията", + "openSessionControls": "Отвори контролите за сесията „Гледане заедно“", + "copySessionCode": "Копирай кода на сесията", "hostControlsPlayback": "Домакинът управлява възпроизвеждането", "anyoneCanControl": "Всеки може да управлява възпроизвеждането", "hostControls": "Контроли на домакина", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Скорост на възпроизвеждане", + "normalSpeed": "Нормална", + "sleepTimerActive": "Активен (${duration})", "zoom": "Мащаб", "sleepTimer": "Таймер за заспиване", "audioSync": "Синхронизация на аудио", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Обложката е обновена", "artworkUpdateFailed": "Неуспешно обновяване на обложката", "noArtworkAvailable": "Няма налична обложка", + "artworkOption": "Вариант за обложка ${index}", + "selectedArtworkOption": "Вариант за обложка ${index}, избран", "notSet": "Не е зададено", "libraryDefault": "По подразбиране за библиотеката", "accountDefault": "По подразбиране за акаунта", @@ -1486,6 +1505,7 @@ "title": "Активиране на Plezy в ${service}", "body": "Посетете ${url} и въведете този код:", "openToActivate": "Отворете ${service}, за да активирате", + "copyCode": "Копирай кода за активиране", "waitingForAuthorization": "Изчакване на оторизация…", "codeCopied": "Кодът е копиран" }, @@ -1493,6 +1513,7 @@ "title": "Вход в ${service}", "body": "Сканирайте този QR код или отворете URL-а на което и да е устройство.", "openToSignIn": "Отворете ${service}, за да влезете", + "copyUrl": "Копирай URL адреса за вход", "urlCopied": "URL адресът е копиран" }, "libraryFilter": { diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 687fc452..46f75ef3 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Rulletekstmarkørmønster", "creditsPatternDescription": "Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler", "invalidRegex": "Ugyldigt regulært udtryk", + "regex": "Regulært udtryk", "downloads": "Downloads", "downloadLocationDescription": "Vælg hvor downloadet indhold skal gemmes", "downloadLocationDefault": "Standard (App-lagring)", @@ -336,6 +337,8 @@ "clearShortcut": "Ryd genvej", "noShortcutSet": "Ingen genvej angivet", "currentShortcut": "Nuværende genvej:", + "pressToRecord": "Vælg for at registrere en genvej", + "recordingShortcut": "Tryk på genvejen nu", "actions": { "playPause": "Afspil/Pause", "volumeUp": "Lydstyrke op", @@ -434,7 +437,9 @@ "hue": "Farvetone", "saturation": "Mætning", "brightness": "Lysstyrke", - "hexColor": "Hexfarve" + "hexColor": "Hexfarve", + "expandText": "Udvid tekst", + "collapseText": "Fold tekst sammen" }, "tooltips": { "shufflePlay": "Afspil tilfældigt", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Markeret som set (synkroniseres online)", "markedAsUnwatchedOffline": "Markeret som uset (synkroniseres online)", "autoRemovedWatchedDownload": "Automatisk fjernet: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Fjernede automatisk ${n} set download", + "other": "Fjernede automatisk ${n} sete downloads" + }, "removedFromContinueWatching": "Fjernet fra Fortsæt med at se", "errorLoading": "Fejl: ${error}", "streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.", @@ -585,6 +594,11 @@ "backgroundColor": "Baggrundsfarve", "position": "Position", "assOverride": "ASS-tilsidesættelse", + "overrideScale": "Skaler", + "overrideForce": "Gennemtving", + "overrideStrip": "Fjern formatering", + "positionTop": "Øverst", + "positionBottom": "Nederst", "bold": "Fed", "italic": "Kursiv", "renderResolution": "Gengivelsesopløsning", @@ -710,7 +724,7 @@ "searchFailed": "Søgning mislykkedes: ${error}", "connectionTimeout": "Forbindelsestimeout ved indlæsning af ${context}", "connectionFailed": "Kan ikke oprette forbindelse til medieserver", - "failedToLoad": "Kunne ikke indlæse ${context}: ${error}", + "unableToLoad": "Kunne ikke indlæse ${context}. Prøv igen.", "noClientAvailable": "Ingen klient tilgængelig", "pleaseEnterToken": "Indtast et token", "invalidToken": "Ugyldigt token", @@ -966,7 +980,6 @@ "deleted": "Samling slettet", "deleteFailed": "Kunne ikke slette samling", "deleteFailedWithError": "Kunne ikke slette samling: ${error}", - "failedToLoadItems": "Kunne ikke indlæse samlingselementer: ${error}", "selectCollection": "Vælg samling", "collectionName": "Samlingsnavn", "enterCollectionName": "Indtast samlingsnavn", @@ -1049,6 +1062,8 @@ "hostingSession": "Vært for session", "inSession": "I session", "sessionCode": "Sessionskode", + "openSessionControls": "Åbn sessionsstyring for Se sammen", + "copySessionCode": "Kopiér sessionskode", "hostControlsPlayback": "Vært styrer afspilning", "anyoneCanControl": "Alle kan styre afspilning", "hostControls": "Værtskontrol", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Afspilningshastighed", + "normalSpeed": "Normal", + "sleepTimerActive": "Aktiv (${duration})", "zoom": "Zoom", "sleepTimer": "Sove-timer", "audioSync": "Lydsynkronisering", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Grafik opdateret", "artworkUpdateFailed": "Kunne ikke opdatere grafik", "noArtworkAvailable": "Ingen grafik tilgængelig", + "artworkOption": "Grafikvalg ${index}", + "selectedArtworkOption": "Grafikvalg ${index}, valgt", "notSet": "Ikke indstillet", "libraryDefault": "Biblioteksstandard", "accountDefault": "Kontostandard", @@ -1486,6 +1505,7 @@ "title": "Aktiver Plezy på ${service}", "body": "Besøg ${url} og indtast denne kode:", "openToActivate": "Åbn ${service} for at aktivere", + "copyCode": "Kopiér aktiveringskode", "waitingForAuthorization": "Venter på godkendelse…", "codeCopied": "Kode kopieret" }, @@ -1493,6 +1513,7 @@ "title": "Log ind på ${service}", "body": "Scan denne QR-kode, eller åbn URL'en på en enhed.", "openToSignIn": "Åbn ${service} for at logge ind", + "copyUrl": "Kopiér login-URL", "urlCopied": "URL kopieret" }, "libraryFilter": { diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 29391f6b..61357e53 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Abspann-Markierungsmuster", "creditsPatternDescription": "Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln", "invalidRegex": "Ungültiger regulärer Ausdruck", + "regex": "Regulärer Ausdruck", "downloads": "Downloads", "downloadLocationDescription": "Speicherort für heruntergeladene Inhalte wählen", "downloadLocationDefault": "Standard (App-Speicher)", @@ -336,6 +337,8 @@ "clearShortcut": "Kürzel löschen", "noShortcutSet": "Keine Tastenkombination festgelegt", "currentShortcut": "Aktuelle Tastenkombination:", + "pressToRecord": "Auswählen, um eine Tastenkombination aufzuzeichnen", + "recordingShortcut": "Jetzt die Tastenkombination drücken", "actions": { "playPause": "Wiedergabe/Pause", "volumeUp": "Lauter", @@ -434,7 +437,9 @@ "hue": "Farbton", "saturation": "Sättigung", "brightness": "Helligkeit", - "hexColor": "Hex-Farbe" + "hexColor": "Hex-Farbe", + "expandText": "Text ausklappen", + "collapseText": "Text einklappen" }, "tooltips": { "shufflePlay": "Zufallswiedergabe", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Als gesehen markiert (wird synchronisiert, wenn online)", "markedAsUnwatchedOffline": "Als ungesehen markiert (wird synchronisiert, wenn online)", "autoRemovedWatchedDownload": "Automatisch entfernt: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Automatisch entfernt: ${n} angesehener Download", + "other": "Automatisch entfernt: ${n} angesehene Downloads" + }, "removedFromContinueWatching": "Aus ‚Weiterschauen' entfernt", "errorLoading": "Fehler: ${error}", "streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.", @@ -585,6 +594,11 @@ "backgroundColor": "Hintergrundfarbe", "position": "Position", "assOverride": "ASS-Überschreibung", + "overrideScale": "Skalieren", + "overrideForce": "Erzwingen", + "overrideStrip": "Formatierung entfernen", + "positionTop": "Oben", + "positionBottom": "Unten", "bold": "Fett", "italic": "Kursiv", "renderResolution": "Render-Auflösung", @@ -710,7 +724,7 @@ "searchFailed": "Suche fehlgeschlagen: ${error}", "connectionTimeout": "Zeitüberschreitung beim Laden von ${context}", "connectionFailed": "Keine Verbindung zum Medienserver möglich", - "failedToLoad": "Fehler beim Laden von ${context}: ${error}", + "unableToLoad": "${context} konnte nicht geladen werden. Bitte erneut versuchen.", "noClientAvailable": "Kein Client verfügbar", "pleaseEnterToken": "Bitte Token eingeben", "invalidToken": "Ungültiges Token", @@ -966,7 +980,6 @@ "deleted": "Sammlung gelöscht", "deleteFailed": "Sammlung konnte nicht gelöscht werden", "deleteFailedWithError": "Sammlung konnte nicht gelöscht werden: ${error}", - "failedToLoadItems": "Sammlungselemente konnten nicht geladen werden: ${error}", "selectCollection": "Sammlung auswählen", "collectionName": "Sammlungsname", "enterCollectionName": "Sammlungsnamen eingeben", @@ -1049,6 +1062,8 @@ "hostingSession": "Sitzung Hosten", "inSession": "In Sitzung", "sessionCode": "Sitzungscode", + "openSessionControls": "Sitzungssteuerung für Gemeinsam Schauen öffnen", + "copySessionCode": "Sitzungscode kopieren", "hostControlsPlayback": "Host steuert die Wiedergabe", "anyoneCanControl": "Alle können die Wiedergabe steuern", "hostControls": "Host steuert", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Wiedergabegeschwindigkeit", + "normalSpeed": "Normal", + "sleepTimerActive": "Aktiv (${duration})", "zoom": "Zoom", "sleepTimer": "Schlaftimer", "audioSync": "Audio-Synchronisation", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork aktualisiert", "artworkUpdateFailed": "Artwork konnte nicht aktualisiert werden", "noArtworkAvailable": "Kein Artwork verfügbar", + "artworkOption": "Artwork-Option ${index}", + "selectedArtworkOption": "Artwork-Option ${index}, ausgewählt", "notSet": "Nicht festgelegt", "libraryDefault": "Bibliotheksstandard", "accountDefault": "Kontostandard", @@ -1486,6 +1505,7 @@ "title": "Plezy auf ${service} aktivieren", "body": "Gehe zu ${url} und gib diesen Code ein:", "openToActivate": "${service} zum Aktivieren öffnen", + "copyCode": "Aktivierungscode kopieren", "waitingForAuthorization": "Warte auf Autorisierung…", "codeCopied": "Code kopiert" }, @@ -1493,6 +1513,7 @@ "title": "Bei ${service} anmelden", "body": "Scanne diesen QR-Code oder öffne die URL auf einem beliebigen Gerät.", "openToSignIn": "${service} zum Anmelden öffnen", + "copyUrl": "Anmelde-URL kopieren", "urlCopied": "URL kopiert" }, "libraryFilter": { diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 65161141..1c3fd85f 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Credits Marker Pattern", "creditsPatternDescription": "Regex pattern to match credits markers in chapter titles", "invalidRegex": "Invalid regular expression", + "regex": "Regular expression", "downloads": "Downloads", "downloadLocationDescription": "Choose where to store downloaded content", "downloadLocationDefault": "Default (App Storage)", @@ -538,6 +539,10 @@ "markedAsWatchedOffline": "Marked as watched (will sync when online)", "markedAsUnwatchedOffline": "Marked as unwatched (will sync when online)", "autoRemovedWatchedDownload": "Auto-removed: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Auto-removed ${n} watched download", + "other": "Auto-removed ${n} watched downloads" + }, "removedFromContinueWatching": "Removed from Continue Watching", "errorLoading": "Error: ${error}", "streamInterrupted": "The stream was interrupted. Press play or seek to retry.", @@ -589,6 +594,11 @@ "backgroundColor": "Background Color", "position": "Position", "assOverride": "ASS Override", + "overrideScale": "Scale", + "overrideForce": "Force", + "overrideStrip": "Remove styling", + "positionTop": "Top", + "positionBottom": "Bottom", "bold": "Bold", "italic": "Italic", "renderResolution": "Render Resolution", @@ -714,7 +724,7 @@ "searchFailed": "Search failed: ${error}", "connectionTimeout": "Connection timeout while loading ${context}", "connectionFailed": "Unable to connect to media server", - "failedToLoad": "Failed to load ${context}: ${error}", + "unableToLoad": "Unable to load ${context}. Please try again.", "noClientAvailable": "No client available", "pleaseEnterToken": "Please enter a token", "invalidToken": "Invalid token", @@ -970,7 +980,6 @@ "deleted": "Collection deleted", "deleteFailed": "Failed to delete collection", "deleteFailedWithError": "Failed to delete collection: ${error}", - "failedToLoadItems": "Failed to load collection items: ${error}", "selectCollection": "Select Collection", "collectionName": "Collection Name", "enterCollectionName": "Enter collection name", @@ -1268,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Playback Speed", + "normalSpeed": "Normal", + "sleepTimerActive": "Active (${duration})", "zoom": "Zoom", "sleepTimer": "Sleep Timer", "audioSync": "Audio Sync", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 1afcad2d..f50d8d33 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Patrón de marcador de créditos", "creditsPatternDescription": "Expresión regular para reconocer marcadores de créditos en los títulos de capítulos", "invalidRegex": "Expresión regular no válida", + "regex": "Expresión regular", "downloads": "Descargas", "downloadLocationDescription": "Elegir dónde almacenar el contenido descargado", "downloadLocationDefault": "Predeterminado (Almacenamiento de la App)", @@ -336,6 +337,8 @@ "clearShortcut": "Borrar atajo", "noShortcutSet": "Sin atajo asignado", "currentShortcut": "Atajo actual:", + "pressToRecord": "Seleccionar para grabar un atajo", + "recordingShortcut": "Presiona el atajo ahora", "actions": { "playPause": "Reproducir/Pausar", "volumeUp": "Subir Volumen", @@ -434,7 +437,9 @@ "hue": "Tono", "saturation": "Saturación", "brightness": "Brillo", - "hexColor": "Color hexadecimal" + "hexColor": "Color hexadecimal", + "expandText": "Expandir texto", + "collapseText": "Contraer texto" }, "tooltips": { "shufflePlay": "Reproducción aleatoria", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Marcado como visto (se sincronizará al estar en línea)", "markedAsUnwatchedOffline": "Marcado como no visto (se sincronizará al estar en línea)", "autoRemovedWatchedDownload": "Eliminado automáticamente: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Se eliminó automáticamente ${n} descarga vista", + "other": "Se eliminaron automáticamente ${n} descargas vistas" + }, "removedFromContinueWatching": "Eliminado de Seguir Viendo", "errorLoading": "Error: ${error}", "streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.", @@ -585,6 +594,11 @@ "backgroundColor": "Color de Fondo", "position": "Posición", "assOverride": "Sobreescritura ASS", + "overrideScale": "Escalar", + "overrideForce": "Forzar", + "overrideStrip": "Quitar estilos", + "positionTop": "Arriba", + "positionBottom": "Abajo", "bold": "Negrita", "italic": "Cursiva", "renderResolution": "Resolución de renderizado", @@ -710,7 +724,7 @@ "searchFailed": "Error en la búsqueda: ${error}", "connectionTimeout": "Tiempo de conexión agotado al cargar ${context}", "connectionFailed": "No se puede conectar al servidor multimedia", - "failedToLoad": "Error al cargar ${context}: ${error}", + "unableToLoad": "No se pudo cargar ${context}. Inténtalo de nuevo.", "noClientAvailable": "No hay cliente disponible", "pleaseEnterToken": "Por favor, introduce un token", "invalidToken": "Token no válido", @@ -966,7 +980,6 @@ "deleted": "Colección eliminada", "deleteFailed": "Error al eliminar la colección", "deleteFailedWithError": "Error al eliminar la colección: ${error}", - "failedToLoadItems": "Error al cargar los elementos de la colección: ${error}", "selectCollection": "Seleccionar Colección", "collectionName": "Nombre de la Colección", "enterCollectionName": "Introduce el nombre de la colección", @@ -1049,6 +1062,8 @@ "hostingSession": "Anfitrión de la Sesión", "inSession": "En Sesión", "sessionCode": "Código de Sesión", + "openSessionControls": "Abrir los controles de la sesión de Ver Juntos", + "copySessionCode": "Copiar código de sesión", "hostControlsPlayback": "El anfitrión controla la reproducción", "anyoneCanControl": "Cualquiera puede controlar la reproducción", "hostControls": "Control del anfitrión", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Velocidad de reproducción", + "normalSpeed": "Normal", + "sleepTimerActive": "Activo (${duration})", "zoom": "Zoom", "sleepTimer": "Temporizador de apagado", "audioSync": "Sincronización de audio", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork actualizado", "artworkUpdateFailed": "Error al actualizar el artwork", "noArtworkAvailable": "No hay artwork disponible", + "artworkOption": "Opción de imagen ${index}", + "selectedArtworkOption": "Opción de imagen ${index}, seleccionada", "notSet": "No establecido", "libraryDefault": "Predeterminado de biblioteca", "accountDefault": "Predeterminado de cuenta", @@ -1486,6 +1505,7 @@ "title": "Activar Plezy en ${service}", "body": "Visita ${url} e introduce este código:", "openToActivate": "Abrir ${service} para activar", + "copyCode": "Copiar código de activación", "waitingForAuthorization": "Esperando autorización…", "codeCopied": "Código copiado" }, @@ -1493,6 +1513,7 @@ "title": "Inicia sesión en ${service}", "body": "Escanea este código QR o abre la URL en cualquier dispositivo.", "openToSignIn": "Abrir ${service} para iniciar sesión", + "copyUrl": "Copiar URL de inicio de sesión", "urlCopied": "URL copiada" }, "libraryFilter": { diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index f1e842bd..b7608b47 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Modèle de marqueur de générique", "creditsPatternDescription": "Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres", "invalidRegex": "Expression régulière invalide", + "regex": "Expression régulière", "downloads": "Téléchargement", "downloadLocationDescription": "Choisissez où stocker le contenu téléchargé", "downloadLocationDefault": "Par défaut (stockage de l'application)", @@ -336,6 +337,8 @@ "clearShortcut": "Effacer le raccourci", "noShortcutSet": "Aucun raccourci défini", "currentShortcut": "Raccourci actuel :", + "pressToRecord": "Sélectionner pour enregistrer un raccourci", + "recordingShortcut": "Appuyez maintenant sur le raccourci", "actions": { "playPause": "Lecture/Pause", "volumeUp": "Augmenter le volume", @@ -434,7 +437,9 @@ "hue": "Teinte", "saturation": "Saturation", "brightness": "Luminosité", - "hexColor": "Couleur hexadécimale" + "hexColor": "Couleur hexadécimale", + "expandText": "Développer le texte", + "collapseText": "Replier le texte" }, "tooltips": { "shufflePlay": "Lecture aléatoire", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Marqué comme vu (se synchronisera lorsque vous serez en ligne)", "markedAsUnwatchedOffline": "Marqué comme non vu (sera synchronisé lorsque vous serez en ligne)", "autoRemovedWatchedDownload": "Supprimé automatiquement : ${title}", + "autoRemovedWatchedDownloads": { + "one": "${n} téléchargement vu supprimé automatiquement", + "other": "${n} téléchargements vus supprimés automatiquement" + }, "removedFromContinueWatching": "Supprimer de \"Continuer à regarder\"", "errorLoading": "Erreur: ${error}", "streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.", @@ -585,6 +594,11 @@ "backgroundColor": "Couleur d'arrière-plan", "position": "Position", "assOverride": "Remplacement ASS", + "overrideScale": "Mettre à l’échelle", + "overrideForce": "Forcer", + "overrideStrip": "Supprimer le style", + "positionTop": "Haut", + "positionBottom": "Bas", "bold": "Gras", "italic": "Italique", "renderResolution": "Résolution de rendu", @@ -710,7 +724,7 @@ "searchFailed": "Recherche échouée: ${error}", "connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}", "connectionFailed": "Impossible de se connecter au serveur multimédia", - "failedToLoad": "Échec du chargement ${context}: ${error}", + "unableToLoad": "Impossible de charger ${context}. Réessayez.", "noClientAvailable": "Aucun client disponible", "pleaseEnterToken": "Veuillez saisir un token", "invalidToken": "Token invalide", @@ -966,7 +980,6 @@ "deleted": "Collection supprimée", "deleteFailed": "Échec de la suppression de la collection", "deleteFailedWithError": "Échec de la suppression de la collection: ${error}", - "failedToLoadItems": "Échec du chargement des éléments de la collection: ${error}", "selectCollection": "Sélectionner une collection", "collectionName": "Nom de la collection", "enterCollectionName": "Entrez le nom de la collection", @@ -1049,6 +1062,8 @@ "hostingSession": "Session d'hébergement", "inSession": "En session", "sessionCode": "Code de session", + "openSessionControls": "Ouvrir les commandes de la session Regarder ensemble", + "copySessionCode": "Copier le code de session", "hostControlsPlayback": "L'hôte contrôle la lecture", "anyoneCanControl": "Tout le monde peut contrôler la lecture", "hostControls": "Commandes de l'hôte", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Vitesse de lecture", + "normalSpeed": "Normale", + "sleepTimerActive": "Actif (${duration})", "zoom": "Zoom", "sleepTimer": "Minuterie de mise en veille", "audioSync": "Synchronisation audio", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork mis à jour", "artworkUpdateFailed": "Échec de la mise à jour de l'artwork", "noArtworkAvailable": "Aucun artwork disponible", + "artworkOption": "Option d'illustration ${index}", + "selectedArtworkOption": "Option d'illustration ${index}, sélectionnée", "notSet": "Non défini", "libraryDefault": "Par défaut de la bibliothèque", "accountDefault": "Par défaut du compte", @@ -1486,6 +1505,7 @@ "title": "Activer Plezy sur ${service}", "body": "Rendez-vous sur ${url} et entrez ce code :", "openToActivate": "Ouvrir ${service} pour activer", + "copyCode": "Copier le code d'activation", "waitingForAuthorization": "En attente d'autorisation…", "codeCopied": "Code copié" }, @@ -1493,6 +1513,7 @@ "title": "Se connecter à ${service}", "body": "Scannez ce code QR ou ouvrez l'URL sur n'importe quel appareil.", "openToSignIn": "Ouvrir ${service} pour se connecter", + "copyUrl": "Copier l'URL de connexion", "urlCopied": "URL copiée" }, "libraryFilter": { diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 93131218..6f3d023c 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Pattern marcatore titoli di coda", "creditsPatternDescription": "Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli", "invalidRegex": "Espressione regolare non valida", + "regex": "Espressione regolare", "downloads": "Download", "downloadLocationDescription": "Scegli dove salvare i contenuti scaricati", "downloadLocationDefault": "Predefinita (Archiviazione App)", @@ -336,6 +337,8 @@ "clearShortcut": "Elimina scorciatoia", "noShortcutSet": "Nessuna scorciatoia impostata", "currentShortcut": "Scorciatoia attuale:", + "pressToRecord": "Seleziona per registrare una scorciatoia", + "recordingShortcut": "Premi ora la scorciatoia", "actions": { "playPause": "Riproduci/Pausa", "volumeUp": "Alza volume", @@ -434,7 +437,9 @@ "hue": "Tonalità", "saturation": "Saturazione", "brightness": "Luminosità", - "hexColor": "Colore esadecimale" + "hexColor": "Colore esadecimale", + "expandText": "Espandi il testo", + "collapseText": "Comprimi il testo" }, "tooltips": { "shufflePlay": "Riproduzione casuale", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Segnato come visto (sincronizzato online)", "markedAsUnwatchedOffline": "Segnato come non visto (sincronizzato online)", "autoRemovedWatchedDownload": "Rimosso automaticamente: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Rimosso automaticamente ${n} download già visto", + "other": "Rimossi automaticamente ${n} download già visti" + }, "removedFromContinueWatching": "Rimosso da Continua a guardare", "errorLoading": "Errore: ${error}", "streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.", @@ -585,6 +594,11 @@ "backgroundColor": "Colore sfondo", "position": "Posizione", "assOverride": "Sovrascrittura ASS", + "overrideScale": "Ridimensiona", + "overrideForce": "Forza", + "overrideStrip": "Rimuovi stile", + "positionTop": "In alto", + "positionBottom": "In basso", "bold": "Grassetto", "italic": "Corsivo", "renderResolution": "Risoluzione di rendering", @@ -710,7 +724,7 @@ "searchFailed": "Ricerca fallita: ${error}", "connectionTimeout": "Timeout connessione durante caricamento di ${context}", "connectionFailed": "Impossibile connettersi al server multimediale", - "failedToLoad": "Impossibile caricare ${context}: ${error}", + "unableToLoad": "Impossibile caricare ${context}. Riprova.", "noClientAvailable": "Nessun client disponibile", "pleaseEnterToken": "Inserisci token", "invalidToken": "Token non valido", @@ -966,7 +980,6 @@ "deleted": "Raccolta eliminata", "deleteFailed": "Impossibile eliminare la raccolta", "deleteFailedWithError": "Impossibile eliminare la raccolta: ${error}", - "failedToLoadItems": "Impossibile caricare gli elementi della raccolta: ${error}", "selectCollection": "Seleziona raccolta", "collectionName": "Nome raccolta", "enterCollectionName": "Inserisci nome raccolta", @@ -1049,6 +1062,8 @@ "hostingSession": "Hosting Sessione", "inSession": "In Sessione", "sessionCode": "Codice Sessione", + "openSessionControls": "Apri i controlli della sessione di Guarda Insieme", + "copySessionCode": "Copia il codice della sessione", "hostControlsPlayback": "L'host controlla la riproduzione", "anyoneCanControl": "Tutti possono controllare la riproduzione", "hostControls": "Controllo host", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Velocità di riproduzione", + "normalSpeed": "Normale", + "sleepTimerActive": "Attivo (${duration})", "zoom": "Zoom", "sleepTimer": "Timer di spegnimento", "audioSync": "Sincronizzazione audio", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork aggiornato", "artworkUpdateFailed": "Aggiornamento artwork non riuscito", "noArtworkAvailable": "Nessun artwork disponibile", + "artworkOption": "Opzione artwork ${index}", + "selectedArtworkOption": "Opzione artwork ${index}, selezionata", "notSet": "Non impostato", "libraryDefault": "Predefinito libreria", "accountDefault": "Predefinito account", @@ -1486,6 +1505,7 @@ "title": "Attiva Plezy su ${service}", "body": "Visita ${url} e inserisci questo codice:", "openToActivate": "Apri ${service} per attivare", + "copyCode": "Copia il codice di attivazione", "waitingForAuthorization": "In attesa di autorizzazione…", "codeCopied": "Codice copiato" }, @@ -1493,6 +1513,7 @@ "title": "Accedi a ${service}", "body": "Scansiona questo codice QR o apri l'URL su qualsiasi dispositivo.", "openToSignIn": "Apri ${service} per accedere", + "copyUrl": "Copia l'URL di accesso", "urlCopied": "URL copiato" }, "libraryFilter": { diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 06b5c901..0ad42b6a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "クレジットマーカーパターン", "creditsPatternDescription": "チャプタータイトルのクレジットマーカーに一致する正規表現パターン", "invalidRegex": "無効な正規表現", + "regex": "正規表現", "downloads": "ダウンロード", "downloadLocationDescription": "ダウンロードコンテンツの保存場所を選択", "downloadLocationDefault": "デフォルト(アプリストレージ)", @@ -336,6 +337,8 @@ "clearShortcut": "ショートカットをクリア", "noShortcutSet": "ショートカット未設定", "currentShortcut": "現在のショートカット:", + "pressToRecord": "選択してショートカットを記録", + "recordingShortcut": "ショートカットを押してください", "actions": { "playPause": "再生/一時停止", "volumeUp": "音量を上げる", @@ -434,7 +437,9 @@ "hue": "色相", "saturation": "彩度", "brightness": "明るさ", - "hexColor": "16進カラー" + "hexColor": "16進カラー", + "expandText": "テキストを展開", + "collapseText": "テキストを折りたたむ" }, "tooltips": { "shufflePlay": "シャッフル再生", @@ -534,6 +539,9 @@ "markedAsWatchedOffline": "視聴済みにしました(オンライン時に同期)", "markedAsUnwatchedOffline": "未視聴にしました(オンライン時に同期)", "autoRemovedWatchedDownload": "自動削除: ${title}", + "autoRemovedWatchedDownloads": { + "other": "視聴済みダウンロードを${n}件自動削除しました" + }, "removedFromContinueWatching": "視聴中から削除しました", "errorLoading": "エラー: ${error}", "streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。", @@ -585,6 +593,11 @@ "backgroundColor": "背景色", "position": "位置", "assOverride": "ASSオーバーライド", + "overrideScale": "拡大縮小", + "overrideForce": "強制", + "overrideStrip": "スタイルを削除", + "positionTop": "上", + "positionBottom": "下", "bold": "太字", "italic": "斜体", "renderResolution": "レンダリング解像度", @@ -710,7 +723,7 @@ "searchFailed": "検索に失敗しました: ${error}", "connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました", "connectionFailed": "メディアサーバーに接続できません", - "failedToLoad": "${context}の読み込みに失敗しました: ${error}", + "unableToLoad": "${context}を読み込めませんでした。もう一度お試しください。", "noClientAvailable": "クライアントが利用できません", "pleaseEnterToken": "トークンを入力してください", "invalidToken": "無効なトークン", @@ -965,7 +978,6 @@ "deleted": "コレクションを削除しました", "deleteFailed": "コレクションの削除に失敗しました", "deleteFailedWithError": "コレクションの削除に失敗しました: ${error}", - "failedToLoadItems": "コレクションアイテムの読み込みに失敗しました: ${error}", "selectCollection": "コレクションを選択", "collectionName": "コレクション名", "enterCollectionName": "コレクション名を入力", @@ -1047,6 +1059,8 @@ "hostingSession": "セッションをホスト中", "inSession": "セッション中", "sessionCode": "セッションコード", + "openSessionControls": "「一緒に見る」セッションのコントロールを開く", + "copySessionCode": "セッションコードをコピー", "hostControlsPlayback": "ホストが再生を制御", "anyoneCanControl": "全員が再生を制御可能", "hostControls": "ホストが制御", @@ -1260,6 +1274,8 @@ }, "videoSettings": { "playbackSpeed": "再生速度", + "normalSpeed": "標準", + "sleepTimerActive": "作動中(${duration})", "zoom": "ズーム", "sleepTimer": "スリープタイマー", "audioSync": "音声同期", @@ -1357,6 +1373,8 @@ "artworkUpdated": "アートワークを更新しました", "artworkUpdateFailed": "アートワークの更新に失敗しました", "noArtworkAvailable": "アートワークがありません", + "artworkOption": "アートワークの選択肢 ${index}", + "selectedArtworkOption": "アートワークの選択肢 ${index}、選択済み", "notSet": "未設定", "libraryDefault": "ライブラリのデフォルト", "accountDefault": "アカウントのデフォルト", @@ -1484,6 +1502,7 @@ "title": "${service} で Plezy を有効化", "body": "${url} にアクセスしてこのコードを入力:", "openToActivate": "${service} を開いて有効化", + "copyCode": "アクティベーションコードをコピー", "waitingForAuthorization": "認証を待っています…", "codeCopied": "コードをコピーしました" }, @@ -1491,6 +1510,7 @@ "title": "${service} にサインイン", "body": "このQRコードをスキャンするか、任意のデバイスでURLを開いてください。", "openToSignIn": "${service} を開いてサインイン", + "copyUrl": "サインインURLをコピー", "urlCopied": "URLをコピーしました" }, "libraryFilter": { diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 7055b519..d176a17e 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "크레딧 마커 패턴", "creditsPatternDescription": "챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴", "invalidRegex": "잘못된 정규식", + "regex": "정규식", "downloads": "다운로드", "downloadLocationDescription": "다운로드 콘텐츠 저장 위치 선택", "downloadLocationDefault": "기본값 (앱 저장소)", @@ -336,6 +337,8 @@ "clearShortcut": "단축키 삭제", "noShortcutSet": "설정된 단축키 없음", "currentShortcut": "현재 단축키:", + "pressToRecord": "단축키를 기록하려면 선택", + "recordingShortcut": "지금 단축키를 누르세요", "actions": { "playPause": "재생/일시정지", "volumeUp": "볼륨 높이기", @@ -434,7 +437,9 @@ "hue": "색조", "saturation": "채도", "brightness": "밝기", - "hexColor": "16진수 색상" + "hexColor": "16진수 색상", + "expandText": "텍스트 펼치기", + "collapseText": "텍스트 접기" }, "tooltips": { "shufflePlay": "무작위 재생", @@ -534,6 +539,9 @@ "markedAsWatchedOffline": "시청 완료로 표시됨 (연결 시 동기화됨)", "markedAsUnwatchedOffline": "미시청으로 표시됨 (연결 시 동기화됨)", "autoRemovedWatchedDownload": "자동 삭제됨: ${title}", + "autoRemovedWatchedDownloads": { + "other": "시청한 다운로드 ${n}개를 자동 삭제했습니다" + }, "removedFromContinueWatching": "계속 시청 목록에서 제거됨", "errorLoading": "오류: ${error}", "streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.", @@ -585,6 +593,11 @@ "backgroundColor": "배경색", "position": "위치", "assOverride": "ASS 오버라이드", + "overrideScale": "크기 조정", + "overrideForce": "강제 적용", + "overrideStrip": "스타일 제거", + "positionTop": "위", + "positionBottom": "아래", "bold": "굵게", "italic": "기울임꼴", "renderResolution": "렌더링 해상도", @@ -710,7 +723,7 @@ "searchFailed": "검색 실패: ${error}", "connectionTimeout": "${context} 로드 중 연결 시간 초과", "connectionFailed": "미디어 서버에 연결할 수 없습니다", - "failedToLoad": "${context} 로드 실패: ${error}", + "unableToLoad": "${context}을(를) 불러올 수 없습니다. 다시 시도하세요.", "noClientAvailable": "사용 가능한 클라이언트가 없습니다", "pleaseEnterToken": "토큰을 입력해 주세요", "invalidToken": "토큰이 유효하지 않습니다", @@ -965,7 +978,6 @@ "deleted": "컬렉션 삭제됨", "deleteFailed": "컬렉션 삭제 실패", "deleteFailedWithError": "컬렉션 삭제 실패: ${error}", - "failedToLoadItems": "컬렉션 항목 로드 실패: ${error}", "selectCollection": "컬렉션 선택", "collectionName": "컬렉션 이름", "enterCollectionName": "컬렉션 이름 입력", @@ -1047,6 +1059,8 @@ "hostingSession": "세션 호스팅", "inSession": "세션 중", "sessionCode": "세션 코드", + "openSessionControls": "함께 보기 세션 제어 열기", + "copySessionCode": "세션 코드 복사", "hostControlsPlayback": "호스트 재생 제어", "anyoneCanControl": "누구나 재생 제어 가능", "hostControls": "호스트 제어", @@ -1260,6 +1274,8 @@ }, "videoSettings": { "playbackSpeed": "재생 속도", + "normalSpeed": "보통", + "sleepTimerActive": "활성 (${duration})", "zoom": "확대/축소", "sleepTimer": "취침 타이머", "audioSync": "오디오 동기화", @@ -1357,6 +1373,8 @@ "artworkUpdated": "아트워크가 업데이트되었습니다", "artworkUpdateFailed": "아트워크 업데이트 실패", "noArtworkAvailable": "사용 가능한 아트워크 없음", + "artworkOption": "아트워크 옵션 ${index}", + "selectedArtworkOption": "아트워크 옵션 ${index}, 선택됨", "notSet": "설정되지 않음", "libraryDefault": "라이브러리 기본값", "accountDefault": "계정 기본값", @@ -1484,6 +1502,7 @@ "title": "${service}에서 Plezy 활성화", "body": "${url}을 방문하여 이 코드를 입력하세요:", "openToActivate": "활성화하려면 ${service} 열기", + "copyCode": "활성화 코드 복사", "waitingForAuthorization": "인증을 기다리는 중…", "codeCopied": "코드가 복사되었습니다" }, @@ -1491,6 +1510,7 @@ "title": "${service}에 로그인", "body": "이 QR 코드를 스캔하거나 아무 기기에서 URL을 여세요.", "openToSignIn": "로그인하려면 ${service} 열기", + "copyUrl": "로그인 URL 복사", "urlCopied": "URL이 복사되었습니다" }, "libraryFilter": { diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 2428a60d..d5971007 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Rulletekstmarkørmønster", "creditsPatternDescription": "Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler", "invalidRegex": "Ugyldig regulært uttrykk", + "regex": "Regulært uttrykk", "downloads": "Nedlastinger", "downloadLocationDescription": "Velg hvor nedlastet innhold skal lagres", "downloadLocationDefault": "Standard (App-lagring)", @@ -336,6 +337,8 @@ "clearShortcut": "Fjern snarvei", "noShortcutSet": "Ingen snarvei satt", "currentShortcut": "Gjeldende snarvei:", + "pressToRecord": "Velg for å registrere en snarvei", + "recordingShortcut": "Trykk på snarveien nå", "actions": { "playPause": "Spill av/Pause", "volumeUp": "Volum opp", @@ -434,7 +437,9 @@ "hue": "Fargetone", "saturation": "Metning", "brightness": "Lysstyrke", - "hexColor": "Heksadesimal farge" + "hexColor": "Heksadesimal farge", + "expandText": "Utvid tekst", + "collapseText": "Fold sammen tekst" }, "tooltips": { "shufflePlay": "Tilfeldig avspilling", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Merket som sett (synkroniseres når tilkoblet)", "markedAsUnwatchedOffline": "Merket som usett (synkroniseres når tilkoblet)", "autoRemovedWatchedDownload": "Automatisk fjernet: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Fjernet automatisk ${n} sett nedlasting", + "other": "Fjernet automatisk ${n} sette nedlastinger" + }, "removedFromContinueWatching": "Fjernet fra Fortsett å se", "errorLoading": "Feil: ${error}", "streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.", @@ -585,6 +594,11 @@ "backgroundColor": "Bakgrunnsfarge", "position": "Posisjon", "assOverride": "ASS-overstyring", + "overrideScale": "Skaler", + "overrideForce": "Tving", + "overrideStrip": "Fjern formatering", + "positionTop": "Øverst", + "positionBottom": "Nederst", "bold": "Fet", "italic": "Kursiv", "renderResolution": "Gjengivelsesoppløsning", @@ -710,7 +724,7 @@ "searchFailed": "Søk mislyktes: ${error}", "connectionTimeout": "Tidsavbrudd ved lasting av ${context}", "connectionFailed": "Kan ikke koble til medieserver", - "failedToLoad": "Kunne ikke laste ${context}: ${error}", + "unableToLoad": "Kunne ikke laste ${context}. Prøv igjen.", "noClientAvailable": "Ingen klient tilgjengelig", "pleaseEnterToken": "Vennligst skriv inn et token", "invalidToken": "Ugyldig token", @@ -966,7 +980,6 @@ "deleted": "Samling slettet", "deleteFailed": "Kunne ikke slette samling", "deleteFailedWithError": "Kunne ikke slette samling: ${error}", - "failedToLoadItems": "Kunne ikke laste samlingselementer: ${error}", "selectCollection": "Velg samling", "collectionName": "Samlingsnavn", "enterCollectionName": "Skriv inn samlingsnavn", @@ -1049,6 +1062,8 @@ "hostingSession": "Er vert for økt", "inSession": "I økt", "sessionCode": "Øktkode", + "openSessionControls": "Åpne øktkontroller for Se sammen", + "copySessionCode": "Kopier øktkode", "hostControlsPlayback": "Verten kontrollerer avspilling", "anyoneCanControl": "Alle kan kontrollere avspilling", "hostControls": "Vertskontroll", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Avspillingshastighet", + "normalSpeed": "Normal", + "sleepTimerActive": "Aktiv (${duration})", "zoom": "Zoom", "sleepTimer": "Søvntimer", "audioSync": "Lydsynkronisering", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Kunstverk oppdatert", "artworkUpdateFailed": "Kunne ikke oppdatere kunstverk", "noArtworkAvailable": "Ingen kunstverk tilgjengelig", + "artworkOption": "Alternativ for kunstverk ${index}", + "selectedArtworkOption": "Alternativ for kunstverk ${index}, valgt", "notSet": "Ikke angitt", "libraryDefault": "Bibliotekstandard", "accountDefault": "Kontostandard", @@ -1486,6 +1505,7 @@ "title": "Aktiver Plezy på ${service}", "body": "Besøk ${url} og skriv inn denne koden:", "openToActivate": "Åpne ${service} for å aktivere", + "copyCode": "Kopier aktiveringskode", "waitingForAuthorization": "Venter på godkjenning…", "codeCopied": "Kode kopiert" }, @@ -1493,6 +1513,7 @@ "title": "Logg inn på ${service}", "body": "Skann denne QR-koden eller åpne URL-en på en enhet.", "openToSignIn": "Åpne ${service} for å logge inn", + "copyUrl": "Kopier URL for pålogging", "urlCopied": "URL kopiert" }, "libraryFilter": { diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index d439d24f..f80a5317 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Aftitelingmarkeringspatroon", "creditsPatternDescription": "Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen", "invalidRegex": "Ongeldige reguliere expressie", + "regex": "Reguliere expressie", "downloads": "Downloads", "downloadLocationDescription": "Kies waar gedownloade content wordt opgeslagen", "downloadLocationDefault": "Standaard (App-opslag)", @@ -336,6 +337,8 @@ "clearShortcut": "Wis sneltoets", "noShortcutSet": "Geen sneltoets ingesteld", "currentShortcut": "Huidige sneltoets:", + "pressToRecord": "Selecteer om een sneltoets op te nemen", + "recordingShortcut": "Druk nu op de sneltoets", "actions": { "playPause": "Afspelen/Pauzeren", "volumeUp": "Volume omhoog", @@ -434,7 +437,9 @@ "hue": "Tint", "saturation": "Verzadiging", "brightness": "Helderheid", - "hexColor": "Hexkleur" + "hexColor": "Hexkleur", + "expandText": "Tekst uitvouwen", + "collapseText": "Tekst samenvouwen" }, "tooltips": { "shufflePlay": "Willekeurig afspelen", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Gemarkeerd als gekeken (sync wanneer online)", "markedAsUnwatchedOffline": "Gemarkeerd als ongekeken (sync wanneer online)", "autoRemovedWatchedDownload": "Automatisch verwijderd: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Automatisch ${n} bekeken download verwijderd", + "other": "Automatisch ${n} bekeken downloads verwijderd" + }, "removedFromContinueWatching": "Verwijderd uit Doorgaan met kijken", "errorLoading": "Fout: ${error}", "streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.", @@ -585,6 +594,11 @@ "backgroundColor": "Achtergrondkleur", "position": "Positie", "assOverride": "ASS-overschrijving", + "overrideScale": "Schalen", + "overrideForce": "Forceren", + "overrideStrip": "Opmaak verwijderen", + "positionTop": "Bovenaan", + "positionBottom": "Onderaan", "bold": "Vet", "italic": "Cursief", "renderResolution": "Renderresolutie", @@ -710,7 +724,7 @@ "searchFailed": "Zoeken mislukt: ${error}", "connectionTimeout": "Verbinding time-out tijdens laden ${context}", "connectionFailed": "Kan geen verbinding maken met mediaserver", - "failedToLoad": "Kon ${context} niet laden: ${error}", + "unableToLoad": "Kan ${context} niet laden. Probeer het opnieuw.", "noClientAvailable": "Geen client beschikbaar", "pleaseEnterToken": "Voer een token in", "invalidToken": "Ongeldig token", @@ -966,7 +980,6 @@ "deleted": "Collectie verwijderd", "deleteFailed": "Collectie verwijderen mislukt", "deleteFailedWithError": "Collectie verwijderen mislukt: ${error}", - "failedToLoadItems": "Collectie-items laden mislukt: ${error}", "selectCollection": "Selecteer collectie", "collectionName": "Collectienaam", "enterCollectionName": "Voer collectienaam in", @@ -1049,6 +1062,8 @@ "hostingSession": "Sessie Hosten", "inSession": "In Sessie", "sessionCode": "Sessiecode", + "openSessionControls": "Sessiebesturing voor Samen Kijken openen", + "copySessionCode": "Sessiecode kopiëren", "hostControlsPlayback": "Host bedient het afspelen", "anyoneCanControl": "Iedereen kan het afspelen bedienen", "hostControls": "Host bedient", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Afspeelsnelheid", + "normalSpeed": "Normaal", + "sleepTimerActive": "Actief (${duration})", "zoom": "Zoom", "sleepTimer": "Slaaptimer", "audioSync": "Audio synchronisatie", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork bijgewerkt", "artworkUpdateFailed": "Artwork bijwerken mislukt", "noArtworkAvailable": "Geen artwork beschikbaar", + "artworkOption": "Artworkoptie ${index}", + "selectedArtworkOption": "Artworkoptie ${index}, geselecteerd", "notSet": "Niet ingesteld", "libraryDefault": "Bibliotheekstandaard", "accountDefault": "Accountstandaard", @@ -1486,6 +1505,7 @@ "title": "Plezy activeren op ${service}", "body": "Ga naar ${url} en voer deze code in:", "openToActivate": "Open ${service} om te activeren", + "copyCode": "Activeringscode kopiëren", "waitingForAuthorization": "Wachten op autorisatie…", "codeCopied": "Code gekopieerd" }, @@ -1493,6 +1513,7 @@ "title": "Aanmelden bij ${service}", "body": "Scan deze QR-code of open de URL op een apparaat.", "openToSignIn": "${service} openen om aan te melden", + "copyUrl": "Aanmeldings-URL kopiëren", "urlCopied": "URL gekopieerd" }, "libraryFilter": { diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 83dd36ce..fd1f6975 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Wzorzec znacznika napisów końcowych", "creditsPatternDescription": "Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów", "invalidRegex": "Nieprawidłowe wyrażenie regularne", + "regex": "Wyrażenie regularne", "downloads": "Pobrania", "downloadLocationDescription": "Wybierz miejsce przechowywania pobranych treści", "downloadLocationDefault": "Domyślne (Pamięć aplikacji)", @@ -336,6 +337,8 @@ "clearShortcut": "Wyczyść skrót", "noShortcutSet": "Brak ustawionego skrótu", "currentShortcut": "Bieżący skrót:", + "pressToRecord": "Wybierz, aby zapisać skrót klawiszowy", + "recordingShortcut": "Naciśnij teraz skrót klawiszowy", "actions": { "playPause": "Odtwórz/Pauza", "volumeUp": "Głośniej", @@ -434,7 +437,9 @@ "hue": "Odcień", "saturation": "Nasycenie", "brightness": "Jasność", - "hexColor": "Kolor szesnastkowy" + "hexColor": "Kolor szesnastkowy", + "expandText": "Rozwiń tekst", + "collapseText": "Zwiń tekst" }, "tooltips": { "shufflePlay": "Odtwarzanie losowe", @@ -534,6 +539,12 @@ "markedAsWatchedOffline": "Oznaczono jako obejrzane (zsynchronizuje się po połączeniu)", "markedAsUnwatchedOffline": "Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)", "autoRemovedWatchedDownload": "Automatycznie usunięto: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Automatycznie usunięto ${n} obejrzane pobranie", + "few": "Automatycznie usunięto ${n} obejrzane pobrania", + "many": "Automatycznie usunięto ${n} obejrzanych pobrań", + "other": "Automatycznie usunięto ${n} obejrzanego pobrania" + }, "removedFromContinueWatching": "Usunięto z kontynuowania oglądania", "errorLoading": "Błąd: ${error}", "streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.", @@ -585,6 +596,11 @@ "backgroundColor": "Kolor tła", "position": "Pozycja", "assOverride": "Nadpisywanie ASS", + "overrideScale": "Skaluj", + "overrideForce": "Wymuś", + "overrideStrip": "Usuń style", + "positionTop": "Góra", + "positionBottom": "Dół", "bold": "Pogrubienie", "italic": "Kursywa", "renderResolution": "Rozdzielczość renderowania", @@ -710,7 +726,7 @@ "searchFailed": "Wyszukiwanie nie powiodło się: ${error}", "connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}", "connectionFailed": "Nie można połączyć się z serwerem multimediów", - "failedToLoad": "Nie udało się załadować ${context}: ${error}", + "unableToLoad": "Nie udało się załadować ${context}. Spróbuj ponownie.", "noClientAvailable": "Brak dostępnego klienta", "pleaseEnterToken": "Wprowadź token", "invalidToken": "Nieprawidłowy token", @@ -968,7 +984,6 @@ "deleted": "Kolekcja usunięta", "deleteFailed": "Nie udało się usunąć kolekcji", "deleteFailedWithError": "Nie udało się usunąć kolekcji: ${error}", - "failedToLoadItems": "Nie udało się załadować elementów kolekcji: ${error}", "selectCollection": "Wybierz kolekcję", "collectionName": "Nazwa kolekcji", "enterCollectionName": "Wprowadź nazwę kolekcji", @@ -1053,6 +1068,8 @@ "hostingSession": "Hostowanie sesji", "inSession": "W sesji", "sessionCode": "Kod sesji", + "openSessionControls": "Otwórz sterowanie sesją „Oglądaj razem”", + "copySessionCode": "Skopiuj kod sesji", "hostControlsPlayback": "Host kontroluje odtwarzanie", "anyoneCanControl": "Każdy może kontrolować odtwarzanie", "hostControls": "Kontrola hosta", @@ -1266,6 +1283,8 @@ }, "videoSettings": { "playbackSpeed": "Prędkość odtwarzania", + "normalSpeed": "Normalna", + "sleepTimerActive": "Aktywny (${duration})", "zoom": "Zoom", "sleepTimer": "Wyłącznik czasowy", "audioSync": "Synchronizacja audio", @@ -1363,6 +1382,8 @@ "artworkUpdated": "Grafika zaktualizowana", "artworkUpdateFailed": "Nie udało się zaktualizować grafiki", "noArtworkAvailable": "Brak dostępnej grafiki", + "artworkOption": "Opcja grafiki ${index}", + "selectedArtworkOption": "Opcja grafiki ${index}, wybrana", "notSet": "Nie ustawiono", "libraryDefault": "Domyślne biblioteki", "accountDefault": "Domyślne konta", @@ -1490,6 +1511,7 @@ "title": "Aktywuj Plezy w ${service}", "body": "Odwiedź ${url} i wpisz ten kod:", "openToActivate": "Otwórz ${service}, aby aktywować", + "copyCode": "Skopiuj kod aktywacyjny", "waitingForAuthorization": "Oczekiwanie na autoryzację…", "codeCopied": "Kod skopiowany" }, @@ -1497,6 +1519,7 @@ "title": "Zaloguj się do ${service}", "body": "Zeskanuj ten kod QR lub otwórz URL na dowolnym urządzeniu.", "openToSignIn": "Otwórz ${service}, aby się zalogować", + "copyUrl": "Skopiuj adres URL logowania", "urlCopied": "URL skopiowany" }, "libraryFilter": { diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index fefb494e..07dd778e 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Padrão de marcador de créditos", "creditsPatternDescription": "Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos", "invalidRegex": "Expressão regular inválida", + "regex": "Expressão regular", "downloads": "Downloads", "downloadLocationDescription": "Escolha onde armazenar conteúdo baixado", "downloadLocationDefault": "Padrão (Armazenamento do App)", @@ -336,6 +337,8 @@ "clearShortcut": "Limpar atalho", "noShortcutSet": "Nenhum atalho definido", "currentShortcut": "Atalho atual:", + "pressToRecord": "Selecionar para gravar um atalho", + "recordingShortcut": "Pressione o atalho agora", "actions": { "playPause": "Reproduzir/Pausar", "volumeUp": "Aumentar Volume", @@ -434,7 +437,9 @@ "hue": "Matiz", "saturation": "Saturação", "brightness": "Brilho", - "hexColor": "Cor hexadecimal" + "hexColor": "Cor hexadecimal", + "expandText": "Expandir texto", + "collapseText": "Recolher texto" }, "tooltips": { "shufflePlay": "Reprodução aleatória", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Marcado como assistido (será sincronizado quando online)", "markedAsUnwatchedOffline": "Marcado como não assistido (será sincronizado quando online)", "autoRemovedWatchedDownload": "Removido automaticamente: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Removido automaticamente ${n} download assistido", + "other": "Removidos automaticamente ${n} downloads assistidos" + }, "removedFromContinueWatching": "Removido de Continuar Assistindo", "errorLoading": "Erro: ${error}", "streamInterrupted": "A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.", @@ -585,6 +594,11 @@ "backgroundColor": "Cor de Fundo", "position": "Posição", "assOverride": "Substituição ASS", + "overrideScale": "Dimensionar", + "overrideForce": "Forçar", + "overrideStrip": "Remover estilo", + "positionTop": "Superior", + "positionBottom": "Inferior", "bold": "Negrito", "italic": "Itálico", "renderResolution": "Resolução de renderização", @@ -710,7 +724,7 @@ "searchFailed": "Falha na busca: ${error}", "connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}", "connectionFailed": "Não foi possível conectar ao servidor de mídia", - "failedToLoad": "Falha ao carregar ${context}: ${error}", + "unableToLoad": "Não foi possível carregar ${context}. Tente novamente.", "noClientAvailable": "Nenhum cliente disponível", "pleaseEnterToken": "Insira um token", "invalidToken": "Token inválido", @@ -966,7 +980,6 @@ "deleted": "Coleção excluída", "deleteFailed": "Falha ao excluir coleção", "deleteFailedWithError": "Falha ao excluir coleção: ${error}", - "failedToLoadItems": "Falha ao carregar itens da coleção: ${error}", "selectCollection": "Selecionar Coleção", "collectionName": "Nome da Coleção", "enterCollectionName": "Insira o nome da coleção", @@ -1049,6 +1062,8 @@ "hostingSession": "Hospedando Sessão", "inSession": "Em Sessão", "sessionCode": "Código da Sessão", + "openSessionControls": "Abrir os controles da sessão Assistir Juntos", + "copySessionCode": "Copiar código da sessão", "hostControlsPlayback": "Anfitrião controla a reprodução", "anyoneCanControl": "Qualquer pessoa pode controlar a reprodução", "hostControls": "Controle do anfitrião", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Velocidade de Reprodução", + "normalSpeed": "Normal", + "sleepTimerActive": "Ativo (${duration})", "zoom": "Zoom", "sleepTimer": "Timer de Sono", "audioSync": "Sincronia de Áudio", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Arte atualizada", "artworkUpdateFailed": "Falha ao atualizar arte", "noArtworkAvailable": "Nenhuma arte disponível", + "artworkOption": "Opção de arte ${index}", + "selectedArtworkOption": "Opção de arte ${index}, selecionada", "notSet": "Não definido", "libraryDefault": "Padrão da biblioteca", "accountDefault": "Padrão da conta", @@ -1486,6 +1505,7 @@ "title": "Ativar Plezy no ${service}", "body": "Acesse ${url} e insira este código:", "openToActivate": "Abrir ${service} para ativar", + "copyCode": "Copiar código de ativação", "waitingForAuthorization": "Aguardando autorização…", "codeCopied": "Código copiado" }, @@ -1493,6 +1513,7 @@ "title": "Entrar no ${service}", "body": "Escaneie este código QR ou abra a URL em qualquer dispositivo.", "openToSignIn": "Abrir ${service} para entrar", + "copyUrl": "Copiar URL de login", "urlCopied": "URL copiado" }, "libraryFilter": { diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 63173b5b..f0708626 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Шаблон маркера титров", "creditsPatternDescription": "Регулярное выражение для распознавания маркеров титров в заголовках глав", "invalidRegex": "Недопустимое регулярное выражение", + "regex": "Регулярное выражение", "downloads": "Загрузки", "downloadLocationDescription": "Выберите место для хранения загруженного контента", "downloadLocationDefault": "По умолчанию (Хранилище приложения)", @@ -336,6 +337,8 @@ "clearShortcut": "Очистить клавишу", "noShortcutSet": "Сочетание не задано", "currentShortcut": "Текущее сочетание:", + "pressToRecord": "Выберите, чтобы записать сочетание клавиш", + "recordingShortcut": "Нажмите сочетание клавиш сейчас", "actions": { "playPause": "Воспроизведение/Пауза", "volumeUp": "Громкость выше", @@ -434,7 +437,9 @@ "hue": "Цветовой тон", "saturation": "Насыщенность", "brightness": "Яркость", - "hexColor": "Шестнадцатеричный цвет" + "hexColor": "Шестнадцатеричный цвет", + "expandText": "Развернуть текст", + "collapseText": "Свернуть текст" }, "tooltips": { "shufflePlay": "Случайное воспроизведение", @@ -534,6 +539,12 @@ "markedAsWatchedOffline": "Отмечено как просмотренное (синхронизируется при подключении)", "markedAsUnwatchedOffline": "Отмечено как непросмотренное (синхронизируется при подключении)", "autoRemovedWatchedDownload": "Автоудалено: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Автоматически удалена ${n} просмотренная загрузка", + "few": "Автоматически удалены ${n} просмотренные загрузки", + "many": "Автоматически удалено ${n} просмотренных загрузок", + "other": "Автоматически удалено ${n} просмотренной загрузки" + }, "removedFromContinueWatching": "Удалено из «Продолжить просмотр»", "errorLoading": "Ошибка: ${error}", "streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.", @@ -585,6 +596,11 @@ "backgroundColor": "Цвет фона", "position": "Позиция", "assOverride": "Переопределение ASS", + "overrideScale": "Масштабировать", + "overrideForce": "Принудительно", + "overrideStrip": "Удалить стили", + "positionTop": "Сверху", + "positionBottom": "Снизу", "bold": "Жирный", "italic": "Курсив", "renderResolution": "Разрешение отрисовки", @@ -710,7 +726,7 @@ "searchFailed": "Ошибка поиска: ${error}", "connectionTimeout": "Таймаут подключения при загрузке ${context}", "connectionFailed": "Не удалось подключиться к медиасерверу", - "failedToLoad": "Не удалось загрузить ${context}: ${error}", + "unableToLoad": "Не удалось загрузить ${context}. Повторите попытку.", "noClientAvailable": "Клиент недоступен", "pleaseEnterToken": "Введите токен", "invalidToken": "Недействительный токен", @@ -968,7 +984,6 @@ "deleted": "Коллекция удалена", "deleteFailed": "Не удалось удалить коллекцию", "deleteFailedWithError": "Не удалось удалить коллекцию: ${error}", - "failedToLoadItems": "Не удалось загрузить элементы коллекции: ${error}", "selectCollection": "Выбрать коллекцию", "collectionName": "Название коллекции", "enterCollectionName": "Введите название коллекции", @@ -1053,6 +1068,8 @@ "hostingSession": "Хостинг сессии", "inSession": "В сессии", "sessionCode": "Код сессии", + "openSessionControls": "Открыть управление сессией «Смотреть вместе»", + "copySessionCode": "Скопировать код сессии", "hostControlsPlayback": "Хост управляет воспроизведением", "anyoneCanControl": "Любой может управлять воспроизведением", "hostControls": "Управление хоста", @@ -1266,6 +1283,8 @@ }, "videoSettings": { "playbackSpeed": "Скорость воспроизведения", + "normalSpeed": "Обычная", + "sleepTimerActive": "Активен (${duration})", "zoom": "Масштаб", "sleepTimer": "Таймер сна", "audioSync": "Синхронизация аудио", @@ -1363,6 +1382,8 @@ "artworkUpdated": "Обложка обновлена", "artworkUpdateFailed": "Не удалось обновить обложку", "noArtworkAvailable": "Обложки недоступны", + "artworkOption": "Вариант обложки ${index}", + "selectedArtworkOption": "Вариант обложки ${index}, выбран", "notSet": "Не задано", "libraryDefault": "По умолчанию библиотеки", "accountDefault": "По умолчанию аккаунта", @@ -1490,6 +1511,7 @@ "title": "Активируйте Plezy в ${service}", "body": "Перейдите на ${url} и введите этот код:", "openToActivate": "Открыть ${service} для активации", + "copyCode": "Скопировать код активации", "waitingForAuthorization": "Ожидание авторизации…", "codeCopied": "Код скопирован" }, @@ -1497,6 +1519,7 @@ "title": "Войти в ${service}", "body": "Отсканируйте этот QR-код или откройте URL на любом устройстве.", "openToSignIn": "Открыть ${service} для входа", + "copyUrl": "Скопировать URL для входа", "urlCopied": "URL скопирован" }, "libraryFilter": { diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index c10b6efd..86d25d46 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 22540 (1408 per locale) +/// Strings: 22835 (1427 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 9aeedcdc..d7ccc5e2 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsBg extends TranslationsSettingsEn { @override String get creditsPattern => 'Шаблон за маркер на финални надписи'; @override String get creditsPatternDescription => 'Шаблон с регулярен израз за намиране на маркери за финални надписи в заглавия на глави'; @override String get invalidRegex => 'Невалиден регулярен израз'; + @override String get regex => 'Регулярен израз'; @override String get downloads => 'Изтегляния'; @override String get downloadLocationDescription => 'Изберете къде да се съхранява изтегленото съдържание'; @override String get downloadLocationDefault => 'По подразбиране (хранилище на приложението)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysBg extends TranslationsHotkeysEn { @override String get clearShortcut => 'Изчисти клавишната комбинация'; @override String get noShortcutSet => 'Няма зададена клавишна комбинация'; @override String get currentShortcut => 'Текуща комбинация:'; + @override String get pressToRecord => 'Избери, за да запишеш клавишна комбинация'; + @override String get recordingShortcut => 'Натисни клавишната комбинация сега'; @override late final _TranslationsHotkeysActionsBg actions = _TranslationsHotkeysActionsBg._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityBg extends TranslationsAccessibilityEn { @override String get saturation => 'Наситеност'; @override String get brightness => 'Яркост'; @override String get hexColor => 'Шестнадесетичен цвят'; + @override String get expandText => 'Разгъни текста'; + @override String get collapseText => 'Свий текста'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesBg extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Маркирано като гледано (ще се синхронизира, когато сте онлайн)'; @override String get markedAsUnwatchedOffline => 'Маркирано като негледано (ще се синхронизира, когато сте онлайн)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Автоматично премахнато: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, + one: 'Автоматично премахнато ${n} гледано изтегляне', + other: 'Автоматично премахнати ${n} гледани изтегляния', + ); @override String get removedFromContinueWatching => 'Премахнато от продължаване на гледането'; @override String errorLoading({required Object error}) => 'Грешка: ${error}'; @override String get streamInterrupted => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingBg extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Цвят на фона'; @override String get position => 'Позиция'; @override String get assOverride => 'ASS презаписване'; + @override String get overrideScale => 'Мащабиране'; + @override String get overrideForce => 'Принудително'; + @override String get overrideStrip => 'Премахване на стиловете'; + @override String get positionTop => 'Горе'; + @override String get positionBottom => 'Долу'; @override String get bold => 'Получер'; @override String get italic => 'Курсив'; @override String get renderResolution => 'Резолюция на изобразяване'; @@ -918,7 +932,7 @@ class _TranslationsErrorsBg extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Търсенето е неуспешно: ${error}'; @override String connectionTimeout({required Object context}) => 'Изтече времето за връзка при зареждане на ${context}'; @override String get connectionFailed => 'Не може да се осъществи връзка с медиен сървър'; - @override String failedToLoad({required Object context, required Object error}) => 'Неуспешно зареждане на ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Не може да се зареди ${context}. Опитайте отново.'; @override String get noClientAvailable => 'Няма наличен клиент'; @override String get pleaseEnterToken => 'Моля, въведете токен'; @override String get invalidToken => 'Невалиден токен'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsBg extends TranslationsCollectionsEn { @override String get deleted => 'Колекцията е изтрита'; @override String get deleteFailed => 'Неуспешно изтриване на колекция'; @override String deleteFailedWithError({required Object error}) => 'Неуспешно изтриване на колекция: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Неуспешно зареждане на елементите в колекцията: ${error}'; @override String get selectCollection => 'Избери колекция'; @override String get collectionName => 'Име на колекция'; @override String get enterCollectionName => 'Въведете име на колекция'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherBg extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Сесия с домакин'; @override String get inSession => 'В сесия'; @override String get sessionCode => 'Код на сесията'; + @override String get openSessionControls => 'Отвори контролите за сесията „Гледане заедно“'; + @override String get copySessionCode => 'Копирай кода на сесията'; @override String get hostControlsPlayback => 'Домакинът управлява възпроизвеждането'; @override String get anyoneCanControl => 'Всеки може да управлява възпроизвеждането'; @override String get hostControls => 'Контроли на домакина'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsBg extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Скорост на възпроизвеждане'; + @override String get normalSpeed => 'Нормална'; + @override String sleepTimerActive({required Object duration}) => 'Активен (${duration})'; @override String get zoom => 'Мащаб'; @override String get sleepTimer => 'Таймер за заспиване'; @override String get audioSync => 'Синхронизация на аудио'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditBg extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Обложката е обновена'; @override String get artworkUpdateFailed => 'Неуспешно обновяване на обложката'; @override String get noArtworkAvailable => 'Няма налична обложка'; + @override String artworkOption({required Object index}) => 'Вариант за обложка ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Вариант за обложка ${index}, избран'; @override String get notSet => 'Не е зададено'; @override String get libraryDefault => 'По подразбиране за библиотеката'; @override String get accountDefault => 'По подразбиране за акаунта'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeBg extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Активиране на Plezy в ${service}'; @override String body({required Object url}) => 'Посетете ${url} и въведете този код:'; @override String openToActivate({required Object service}) => 'Отворете ${service}, за да активирате'; + @override String get copyCode => 'Копирай кода за активиране'; @override String get waitingForAuthorization => 'Изчакване на оторизация…'; @override String get codeCopied => 'Кодът е копиран'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyBg extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Вход в ${service}'; @override String get body => 'Сканирайте този QR код или отворете URL-а на което и да е устройство.'; @override String openToSignIn({required Object service}) => 'Отворете ${service}, за да влезете'; + @override String get copyUrl => 'Копирай URL адреса за вход'; @override String get urlCopied => 'URL адресът е копиран'; } @@ -2318,6 +2339,7 @@ extension on TranslationsBg { 'settings.creditsPattern' => 'Шаблон за маркер на финални надписи', 'settings.creditsPatternDescription' => 'Шаблон с регулярен израз за намиране на маркери за финални надписи в заглавия на глави', 'settings.invalidRegex' => 'Невалиден регулярен израз', + 'settings.regex' => 'Регулярен израз', 'settings.downloads' => 'Изтегляния', 'settings.downloadLocationDescription' => 'Изберете къде да се съхранява изтегленото съдържание', 'settings.downloadLocationDefault' => 'По подразбиране (хранилище на приложението)', @@ -2423,6 +2445,8 @@ extension on TranslationsBg { 'hotkeys.clearShortcut' => 'Изчисти клавишната комбинация', 'hotkeys.noShortcutSet' => 'Няма зададена клавишна комбинация', 'hotkeys.currentShortcut' => 'Текуща комбинация:', + 'hotkeys.pressToRecord' => 'Избери, за да запишеш клавишна комбинация', + 'hotkeys.recordingShortcut' => 'Натисни клавишната комбинация сега', 'hotkeys.actions.playPause' => 'Пускане/пауза', 'hotkeys.actions.volumeUp' => 'Увеличи звука', 'hotkeys.actions.volumeDown' => 'Намали звука', @@ -2512,6 +2536,8 @@ extension on TranslationsBg { 'accessibility.saturation' => 'Наситеност', 'accessibility.brightness' => 'Яркост', 'accessibility.hexColor' => 'Шестнадесетичен цвят', + 'accessibility.expandText' => 'Разгъни текста', + 'accessibility.collapseText' => 'Свий текста', 'tooltips.shufflePlay' => 'Разбъркано възпроизвеждане', 'tooltips.playTrailer' => 'Пусни трейлър', 'tooltips.markAsWatched' => 'Маркирай като гледано', @@ -2602,19 +2628,20 @@ extension on TranslationsBg { 'messages.markedAsWatchedOffline' => 'Маркирано като гледано (ще се синхронизира, когато сте онлайн)', 'messages.markedAsUnwatchedOffline' => 'Маркирано като негледано (ще се синхронизира, когато сте онлайн)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоматично премахнато: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('bg'))(n, one: 'Автоматично премахнато ${n} гледано изтегляне', other: 'Автоматично премахнати ${n} гледани изтегляния', ), 'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането', 'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}', 'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.', 'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.', 'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}', 'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала', 'messages.musicNotSupported' => 'Възпроизвеждането на музика все още не се поддържа', 'messages.noDescriptionAvailable' => 'Няма налично описание', 'messages.noProfilesAvailable' => 'Няма налични профили', 'messages.contactAdminForProfiles' => 'Свържете се с администратора на сървъра, за да добави профили', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Не може да се определи секцията на библиотеката за този елемент', 'messages.logsCleared' => 'Логовете са изчистени', 'messages.logsCopied' => 'Логовете са копирани в клипборда', @@ -2653,6 +2680,11 @@ extension on TranslationsBg { 'subtitlingStyling.backgroundColor' => 'Цвят на фона', 'subtitlingStyling.position' => 'Позиция', 'subtitlingStyling.assOverride' => 'ASS презаписване', + 'subtitlingStyling.overrideScale' => 'Мащабиране', + 'subtitlingStyling.overrideForce' => 'Принудително', + 'subtitlingStyling.overrideStrip' => 'Премахване на стиловете', + 'subtitlingStyling.positionTop' => 'Горе', + 'subtitlingStyling.positionBottom' => 'Долу', 'subtitlingStyling.bold' => 'Получер', 'subtitlingStyling.italic' => 'Курсив', 'subtitlingStyling.renderResolution' => 'Резолюция на изобразяване', @@ -2766,7 +2798,7 @@ extension on TranslationsBg { 'errors.searchFailed' => ({required Object error}) => 'Търсенето е неуспешно: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Изтече времето за връзка при зареждане на ${context}', 'errors.connectionFailed' => 'Не може да се осъществи връзка с медиен сървър', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Неуспешно зареждане на ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Не може да се зареди ${context}. Опитайте отново.', 'errors.noClientAvailable' => 'Няма наличен клиент', 'errors.pleaseEnterToken' => 'Моля, въведете токен', 'errors.invalidToken' => 'Невалиден токен', @@ -2987,7 +3019,6 @@ extension on TranslationsBg { 'collections.deleted' => 'Колекцията е изтрита', 'collections.deleteFailed' => 'Неуспешно изтриване на колекция', 'collections.deleteFailedWithError' => ({required Object error}) => 'Неуспешно изтриване на колекция: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Неуспешно зареждане на елементите в колекцията: ${error}', 'collections.selectCollection' => 'Избери колекция', 'collections.collectionName' => 'Име на колекция', 'collections.enterCollectionName' => 'Въведете име на колекция', @@ -3061,6 +3092,8 @@ extension on TranslationsBg { 'watchTogether.hostingSession' => 'Сесия с домакин', 'watchTogether.inSession' => 'В сесия', 'watchTogether.sessionCode' => 'Код на сесията', + 'watchTogether.openSessionControls' => 'Отвори контролите за сесията „Гледане заедно“', + 'watchTogether.copySessionCode' => 'Копирай кода на сесията', 'watchTogether.hostControlsPlayback' => 'Домакинът управлява възпроизвеждането', 'watchTogether.anyoneCanControl' => 'Всеки може да управлява възпроизвеждането', 'watchTogether.hostControls' => 'Контроли на домакина', @@ -3115,6 +3148,8 @@ extension on TranslationsBg { 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'ТВ сериали', 'downloads.movies' => 'Филми', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Музика', 'downloads.tracksQueued' => ({required Object count}) => '${count} песни в опашката за изтегляне', 'downloads.noDownloads' => 'Все още няма изтегляния', @@ -3127,8 +3162,6 @@ extension on TranslationsBg { 'downloads.serverErrorBitrate' => 'Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт', 'downloads.episodesQueued' => ({required Object count}) => '${count} епизода са добавени в опашката за изтегляне', 'downloads.downloadDeleted' => 'Изтеглянето е изтрито', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?', 'downloads.cancelledDownloadTitle' => 'Отменено изтегляне', 'downloads.cancelledDownloadMessage' => 'Това изтегляне беше отменено. Какво искате да направите?', @@ -3260,6 +3293,8 @@ extension on TranslationsBg { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Връзката е загубена след ${attempts} опита', 'companionRemote.errors.connectionLost' => 'Връзката е загубена', 'videoSettings.playbackSpeed' => 'Скорост на възпроизвеждане', + 'videoSettings.normalSpeed' => 'Нормална', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Активен (${duration})', 'videoSettings.zoom' => 'Мащаб', 'videoSettings.sleepTimer' => 'Таймер за заспиване', 'videoSettings.audioSync' => 'Синхронизация на аудио', @@ -3351,6 +3386,8 @@ extension on TranslationsBg { 'metadataEdit.artworkUpdated' => 'Обложката е обновена', 'metadataEdit.artworkUpdateFailed' => 'Неуспешно обновяване на обложката', 'metadataEdit.noArtworkAvailable' => 'Няма налична обложка', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Вариант за обложка ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Вариант за обложка ${index}, избран', 'metadataEdit.notSet' => 'Не е зададено', 'metadataEdit.libraryDefault' => 'По подразбиране за библиотеката', 'metadataEdit.accountDefault' => 'По подразбиране за акаунта', @@ -3465,11 +3502,13 @@ extension on TranslationsBg { 'services.deviceCode.title' => ({required Object service}) => 'Активиране на Plezy в ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Посетете ${url} и въведете този код:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Отворете ${service}, за да активирате', + 'services.deviceCode.copyCode' => 'Копирай кода за активиране', 'services.deviceCode.waitingForAuthorization' => 'Изчакване на оторизация…', 'services.deviceCode.codeCopied' => 'Кодът е копиран', 'services.oauthProxy.title' => ({required Object service}) => 'Вход в ${service}', 'services.oauthProxy.body' => 'Сканирайте този QR код или отворете URL-а на което и да е устройство.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Отворете ${service}, за да влезете', + 'services.oauthProxy.copyUrl' => 'Копирай URL адреса за вход', 'services.oauthProxy.urlCopied' => 'URL адресът е копиран', 'services.libraryFilter.title' => 'Филтър на библиотеките', 'services.libraryFilter.subtitleAllSyncing' => 'Синхронизират се всички библиотеки', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index b2ebde45..3662eecc 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsDa extends TranslationsSettingsEn { @override String get creditsPattern => 'Rulletekstmarkørmønster'; @override String get creditsPatternDescription => 'Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler'; @override String get invalidRegex => 'Ugyldigt regulært udtryk'; + @override String get regex => 'Regulært udtryk'; @override String get downloads => 'Downloads'; @override String get downloadLocationDescription => 'Vælg hvor downloadet indhold skal gemmes'; @override String get downloadLocationDefault => 'Standard (App-lagring)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysDa extends TranslationsHotkeysEn { @override String get clearShortcut => 'Ryd genvej'; @override String get noShortcutSet => 'Ingen genvej angivet'; @override String get currentShortcut => 'Nuværende genvej:'; + @override String get pressToRecord => 'Vælg for at registrere en genvej'; + @override String get recordingShortcut => 'Tryk på genvejen nu'; @override late final _TranslationsHotkeysActionsDa actions = _TranslationsHotkeysActionsDa._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityDa extends TranslationsAccessibilityEn { @override String get saturation => 'Mætning'; @override String get brightness => 'Lysstyrke'; @override String get hexColor => 'Hexfarve'; + @override String get expandText => 'Udvid tekst'; + @override String get collapseText => 'Fold tekst sammen'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesDa extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Markeret som set (synkroniseres online)'; @override String get markedAsUnwatchedOffline => 'Markeret som uset (synkroniseres online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatisk fjernet: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, + one: 'Fjernede automatisk ${n} set download', + other: 'Fjernede automatisk ${n} sete downloads', + ); @override String get removedFromContinueWatching => 'Fjernet fra Fortsæt med at se'; @override String errorLoading({required Object error}) => 'Fejl: ${error}'; @override String get streamInterrupted => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingDa extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Baggrundsfarve'; @override String get position => 'Position'; @override String get assOverride => 'ASS-tilsidesættelse'; + @override String get overrideScale => 'Skaler'; + @override String get overrideForce => 'Gennemtving'; + @override String get overrideStrip => 'Fjern formatering'; + @override String get positionTop => 'Øverst'; + @override String get positionBottom => 'Nederst'; @override String get bold => 'Fed'; @override String get italic => 'Kursiv'; @override String get renderResolution => 'Gengivelsesopløsning'; @@ -918,7 +932,7 @@ class _TranslationsErrorsDa extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Søgning mislykkedes: ${error}'; @override String connectionTimeout({required Object context}) => 'Forbindelsestimeout ved indlæsning af ${context}'; @override String get connectionFailed => 'Kan ikke oprette forbindelse til medieserver'; - @override String failedToLoad({required Object context, required Object error}) => 'Kunne ikke indlæse ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Kunne ikke indlæse ${context}. Prøv igen.'; @override String get noClientAvailable => 'Ingen klient tilgængelig'; @override String get pleaseEnterToken => 'Indtast et token'; @override String get invalidToken => 'Ugyldigt token'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsDa extends TranslationsCollectionsEn { @override String get deleted => 'Samling slettet'; @override String get deleteFailed => 'Kunne ikke slette samling'; @override String deleteFailedWithError({required Object error}) => 'Kunne ikke slette samling: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Kunne ikke indlæse samlingselementer: ${error}'; @override String get selectCollection => 'Vælg samling'; @override String get collectionName => 'Samlingsnavn'; @override String get enterCollectionName => 'Indtast samlingsnavn'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherDa extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Vært for session'; @override String get inSession => 'I session'; @override String get sessionCode => 'Sessionskode'; + @override String get openSessionControls => 'Åbn sessionsstyring for Se sammen'; + @override String get copySessionCode => 'Kopiér sessionskode'; @override String get hostControlsPlayback => 'Vært styrer afspilning'; @override String get anyoneCanControl => 'Alle kan styre afspilning'; @override String get hostControls => 'Værtskontrol'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsDa extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Afspilningshastighed'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Aktiv (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Sove-timer'; @override String get audioSync => 'Lydsynkronisering'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditDa extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Grafik opdateret'; @override String get artworkUpdateFailed => 'Kunne ikke opdatere grafik'; @override String get noArtworkAvailable => 'Ingen grafik tilgængelig'; + @override String artworkOption({required Object index}) => 'Grafikvalg ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Grafikvalg ${index}, valgt'; @override String get notSet => 'Ikke indstillet'; @override String get libraryDefault => 'Biblioteksstandard'; @override String get accountDefault => 'Kontostandard'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeDa extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Aktiver Plezy på ${service}'; @override String body({required Object url}) => 'Besøg ${url} og indtast denne kode:'; @override String openToActivate({required Object service}) => 'Åbn ${service} for at aktivere'; + @override String get copyCode => 'Kopiér aktiveringskode'; @override String get waitingForAuthorization => 'Venter på godkendelse…'; @override String get codeCopied => 'Kode kopieret'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyDa extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Log ind på ${service}'; @override String get body => 'Scan denne QR-kode, eller åbn URL\'en på en enhed.'; @override String openToSignIn({required Object service}) => 'Åbn ${service} for at logge ind'; + @override String get copyUrl => 'Kopiér login-URL'; @override String get urlCopied => 'URL kopieret'; } @@ -2318,6 +2339,7 @@ extension on TranslationsDa { 'settings.creditsPattern' => 'Rulletekstmarkørmønster', 'settings.creditsPatternDescription' => 'Regulært udtryk til at genkende rulletekstmarkører i kapiteltitler', 'settings.invalidRegex' => 'Ugyldigt regulært udtryk', + 'settings.regex' => 'Regulært udtryk', 'settings.downloads' => 'Downloads', 'settings.downloadLocationDescription' => 'Vælg hvor downloadet indhold skal gemmes', 'settings.downloadLocationDefault' => 'Standard (App-lagring)', @@ -2423,6 +2445,8 @@ extension on TranslationsDa { 'hotkeys.clearShortcut' => 'Ryd genvej', 'hotkeys.noShortcutSet' => 'Ingen genvej angivet', 'hotkeys.currentShortcut' => 'Nuværende genvej:', + 'hotkeys.pressToRecord' => 'Vælg for at registrere en genvej', + 'hotkeys.recordingShortcut' => 'Tryk på genvejen nu', 'hotkeys.actions.playPause' => 'Afspil/Pause', 'hotkeys.actions.volumeUp' => 'Lydstyrke op', 'hotkeys.actions.volumeDown' => 'Lydstyrke ned', @@ -2512,6 +2536,8 @@ extension on TranslationsDa { 'accessibility.saturation' => 'Mætning', 'accessibility.brightness' => 'Lysstyrke', 'accessibility.hexColor' => 'Hexfarve', + 'accessibility.expandText' => 'Udvid tekst', + 'accessibility.collapseText' => 'Fold tekst sammen', 'tooltips.shufflePlay' => 'Afspil tilfældigt', 'tooltips.playTrailer' => 'Afspil trailer', 'tooltips.markAsWatched' => 'Markér som set', @@ -2602,19 +2628,20 @@ extension on TranslationsDa { 'messages.markedAsWatchedOffline' => 'Markeret som set (synkroniseres online)', 'messages.markedAsUnwatchedOffline' => 'Markeret som uset (synkroniseres online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('da'))(n, one: 'Fjernede automatisk ${n} set download', other: 'Fjernede automatisk ${n} sete downloads', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se', 'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}', 'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.', 'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.', 'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}', 'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie', 'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke', 'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgængelig', 'messages.noProfilesAvailable' => 'Ingen profiler tilgængelige', 'messages.contactAdminForProfiles' => 'Kontakt din serveradministrator for at tilføje profiler', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Kan ikke bestemme biblioteksafdeling for dette element', 'messages.logsCleared' => 'Logs ryddet', 'messages.logsCopied' => 'Logs kopieret til udklipsholder', @@ -2653,6 +2680,11 @@ extension on TranslationsDa { 'subtitlingStyling.backgroundColor' => 'Baggrundsfarve', 'subtitlingStyling.position' => 'Position', 'subtitlingStyling.assOverride' => 'ASS-tilsidesættelse', + 'subtitlingStyling.overrideScale' => 'Skaler', + 'subtitlingStyling.overrideForce' => 'Gennemtving', + 'subtitlingStyling.overrideStrip' => 'Fjern formatering', + 'subtitlingStyling.positionTop' => 'Øverst', + 'subtitlingStyling.positionBottom' => 'Nederst', 'subtitlingStyling.bold' => 'Fed', 'subtitlingStyling.italic' => 'Kursiv', 'subtitlingStyling.renderResolution' => 'Gengivelsesopløsning', @@ -2766,7 +2798,7 @@ extension on TranslationsDa { 'errors.searchFailed' => ({required Object error}) => 'Søgning mislykkedes: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Forbindelsestimeout ved indlæsning af ${context}', 'errors.connectionFailed' => 'Kan ikke oprette forbindelse til medieserver', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kunne ikke indlæse ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Kunne ikke indlæse ${context}. Prøv igen.', 'errors.noClientAvailable' => 'Ingen klient tilgængelig', 'errors.pleaseEnterToken' => 'Indtast et token', 'errors.invalidToken' => 'Ugyldigt token', @@ -2987,7 +3019,6 @@ extension on TranslationsDa { 'collections.deleted' => 'Samling slettet', 'collections.deleteFailed' => 'Kunne ikke slette samling', 'collections.deleteFailedWithError' => ({required Object error}) => 'Kunne ikke slette samling: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Kunne ikke indlæse samlingselementer: ${error}', 'collections.selectCollection' => 'Vælg samling', 'collections.collectionName' => 'Samlingsnavn', 'collections.enterCollectionName' => 'Indtast samlingsnavn', @@ -3061,6 +3092,8 @@ extension on TranslationsDa { 'watchTogether.hostingSession' => 'Vært for session', 'watchTogether.inSession' => 'I session', 'watchTogether.sessionCode' => 'Sessionskode', + 'watchTogether.openSessionControls' => 'Åbn sessionsstyring for Se sammen', + 'watchTogether.copySessionCode' => 'Kopiér sessionskode', 'watchTogether.hostControlsPlayback' => 'Vært styrer afspilning', 'watchTogether.anyoneCanControl' => 'Alle kan styre afspilning', 'watchTogether.hostControls' => 'Værtskontrol', @@ -3115,6 +3148,8 @@ extension on TranslationsDa { 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', 'downloads.movies' => 'Film', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musik', 'downloads.tracksQueued' => ({required Object count}) => '${count} numre i kø til download', 'downloads.noDownloads' => 'Ingen downloads endnu', @@ -3127,8 +3162,6 @@ extension on TranslationsDa { 'downloads.serverErrorBitrate' => 'Serverfejl: filen overskrider muligvis grænsen for ekstern bitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i downloadkø', 'downloads.downloadDeleted' => 'Download slettet', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Slet "${title}" fra denne enhed?', 'downloads.cancelledDownloadTitle' => 'Annulleret download', 'downloads.cancelledDownloadMessage' => 'Denne download blev annulleret. Hvad vil du gøre?', @@ -3260,6 +3293,8 @@ extension on TranslationsDa { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Forbindelse mistet efter ${attempts} forsøg', 'companionRemote.errors.connectionLost' => 'Forbindelse mistet', 'videoSettings.playbackSpeed' => 'Afspilningshastighed', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Aktiv (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Sove-timer', 'videoSettings.audioSync' => 'Lydsynkronisering', @@ -3351,6 +3386,8 @@ extension on TranslationsDa { 'metadataEdit.artworkUpdated' => 'Grafik opdateret', 'metadataEdit.artworkUpdateFailed' => 'Kunne ikke opdatere grafik', 'metadataEdit.noArtworkAvailable' => 'Ingen grafik tilgængelig', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Grafikvalg ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Grafikvalg ${index}, valgt', 'metadataEdit.notSet' => 'Ikke indstillet', 'metadataEdit.libraryDefault' => 'Biblioteksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', @@ -3465,11 +3502,13 @@ extension on TranslationsDa { 'services.deviceCode.title' => ({required Object service}) => 'Aktiver Plezy på ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Besøg ${url} og indtast denne kode:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Åbn ${service} for at aktivere', + 'services.deviceCode.copyCode' => 'Kopiér aktiveringskode', 'services.deviceCode.waitingForAuthorization' => 'Venter på godkendelse…', 'services.deviceCode.codeCopied' => 'Kode kopieret', 'services.oauthProxy.title' => ({required Object service}) => 'Log ind på ${service}', 'services.oauthProxy.body' => 'Scan denne QR-kode, eller åbn URL\'en på en enhed.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Åbn ${service} for at logge ind', + 'services.oauthProxy.copyUrl' => 'Kopiér login-URL', 'services.oauthProxy.urlCopied' => 'URL kopieret', 'services.libraryFilter.title' => 'Bibliotekfilter', 'services.libraryFilter.subtitleAllSyncing' => 'Synkroniserer alle biblioteker', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index b4841fa5..aac16dc3 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsDe extends TranslationsSettingsEn { @override String get creditsPattern => 'Abspann-Markierungsmuster'; @override String get creditsPatternDescription => 'Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln'; @override String get invalidRegex => 'Ungültiger regulärer Ausdruck'; + @override String get regex => 'Regulärer Ausdruck'; @override String get downloads => 'Downloads'; @override String get downloadLocationDescription => 'Speicherort für heruntergeladene Inhalte wählen'; @override String get downloadLocationDefault => 'Standard (App-Speicher)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysDe extends TranslationsHotkeysEn { @override String get clearShortcut => 'Kürzel löschen'; @override String get noShortcutSet => 'Keine Tastenkombination festgelegt'; @override String get currentShortcut => 'Aktuelle Tastenkombination:'; + @override String get pressToRecord => 'Auswählen, um eine Tastenkombination aufzuzeichnen'; + @override String get recordingShortcut => 'Jetzt die Tastenkombination drücken'; @override late final _TranslationsHotkeysActionsDe actions = _TranslationsHotkeysActionsDe._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityDe extends TranslationsAccessibilityEn { @override String get saturation => 'Sättigung'; @override String get brightness => 'Helligkeit'; @override String get hexColor => 'Hex-Farbe'; + @override String get expandText => 'Text ausklappen'; + @override String get collapseText => 'Text einklappen'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesDe extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Als gesehen markiert (wird synchronisiert, wenn online)'; @override String get markedAsUnwatchedOffline => 'Als ungesehen markiert (wird synchronisiert, wenn online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatisch entfernt: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, + one: 'Automatisch entfernt: ${n} angesehener Download', + other: 'Automatisch entfernt: ${n} angesehene Downloads', + ); @override String get removedFromContinueWatching => 'Aus ‚Weiterschauen\' entfernt'; @override String errorLoading({required Object error}) => 'Fehler: ${error}'; @override String get streamInterrupted => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingDe extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Hintergrundfarbe'; @override String get position => 'Position'; @override String get assOverride => 'ASS-Überschreibung'; + @override String get overrideScale => 'Skalieren'; + @override String get overrideForce => 'Erzwingen'; + @override String get overrideStrip => 'Formatierung entfernen'; + @override String get positionTop => 'Oben'; + @override String get positionBottom => 'Unten'; @override String get bold => 'Fett'; @override String get italic => 'Kursiv'; @override String get renderResolution => 'Render-Auflösung'; @@ -918,7 +932,7 @@ class _TranslationsErrorsDe extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Suche fehlgeschlagen: ${error}'; @override String connectionTimeout({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}'; @override String get connectionFailed => 'Keine Verbindung zum Medienserver möglich'; - @override String failedToLoad({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}'; + @override String unableToLoad({required Object context}) => '${context} konnte nicht geladen werden. Bitte erneut versuchen.'; @override String get noClientAvailable => 'Kein Client verfügbar'; @override String get pleaseEnterToken => 'Bitte Token eingeben'; @override String get invalidToken => 'Ungültiges Token'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsDe extends TranslationsCollectionsEn { @override String get deleted => 'Sammlung gelöscht'; @override String get deleteFailed => 'Sammlung konnte nicht gelöscht werden'; @override String deleteFailedWithError({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}'; @override String get selectCollection => 'Sammlung auswählen'; @override String get collectionName => 'Sammlungsname'; @override String get enterCollectionName => 'Sammlungsnamen eingeben'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherDe extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Sitzung Hosten'; @override String get inSession => 'In Sitzung'; @override String get sessionCode => 'Sitzungscode'; + @override String get openSessionControls => 'Sitzungssteuerung für Gemeinsam Schauen öffnen'; + @override String get copySessionCode => 'Sitzungscode kopieren'; @override String get hostControlsPlayback => 'Host steuert die Wiedergabe'; @override String get anyoneCanControl => 'Alle können die Wiedergabe steuern'; @override String get hostControls => 'Host steuert'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsDe extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Wiedergabegeschwindigkeit'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Aktiv (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Schlaftimer'; @override String get audioSync => 'Audio-Synchronisation'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditDe extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork aktualisiert'; @override String get artworkUpdateFailed => 'Artwork konnte nicht aktualisiert werden'; @override String get noArtworkAvailable => 'Kein Artwork verfügbar'; + @override String artworkOption({required Object index}) => 'Artwork-Option ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Artwork-Option ${index}, ausgewählt'; @override String get notSet => 'Nicht festgelegt'; @override String get libraryDefault => 'Bibliotheksstandard'; @override String get accountDefault => 'Kontostandard'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeDe extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Plezy auf ${service} aktivieren'; @override String body({required Object url}) => 'Gehe zu ${url} und gib diesen Code ein:'; @override String openToActivate({required Object service}) => '${service} zum Aktivieren öffnen'; + @override String get copyCode => 'Aktivierungscode kopieren'; @override String get waitingForAuthorization => 'Warte auf Autorisierung…'; @override String get codeCopied => 'Code kopiert'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyDe extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Bei ${service} anmelden'; @override String get body => 'Scanne diesen QR-Code oder öffne die URL auf einem beliebigen Gerät.'; @override String openToSignIn({required Object service}) => '${service} zum Anmelden öffnen'; + @override String get copyUrl => 'Anmelde-URL kopieren'; @override String get urlCopied => 'URL kopiert'; } @@ -2318,6 +2339,7 @@ extension on TranslationsDe { 'settings.creditsPattern' => 'Abspann-Markierungsmuster', 'settings.creditsPatternDescription' => 'Regulärer Ausdruck zum Erkennen von Abspann-Markierungen in Kapiteltiteln', 'settings.invalidRegex' => 'Ungültiger regulärer Ausdruck', + 'settings.regex' => 'Regulärer Ausdruck', 'settings.downloads' => 'Downloads', 'settings.downloadLocationDescription' => 'Speicherort für heruntergeladene Inhalte wählen', 'settings.downloadLocationDefault' => 'Standard (App-Speicher)', @@ -2423,6 +2445,8 @@ extension on TranslationsDe { 'hotkeys.clearShortcut' => 'Kürzel löschen', 'hotkeys.noShortcutSet' => 'Keine Tastenkombination festgelegt', 'hotkeys.currentShortcut' => 'Aktuelle Tastenkombination:', + 'hotkeys.pressToRecord' => 'Auswählen, um eine Tastenkombination aufzuzeichnen', + 'hotkeys.recordingShortcut' => 'Jetzt die Tastenkombination drücken', 'hotkeys.actions.playPause' => 'Wiedergabe/Pause', 'hotkeys.actions.volumeUp' => 'Lauter', 'hotkeys.actions.volumeDown' => 'Leiser', @@ -2512,6 +2536,8 @@ extension on TranslationsDe { 'accessibility.saturation' => 'Sättigung', 'accessibility.brightness' => 'Helligkeit', 'accessibility.hexColor' => 'Hex-Farbe', + 'accessibility.expandText' => 'Text ausklappen', + 'accessibility.collapseText' => 'Text einklappen', 'tooltips.shufflePlay' => 'Zufallswiedergabe', 'tooltips.playTrailer' => 'Trailer abspielen', 'tooltips.markAsWatched' => 'Als gesehen markieren', @@ -2602,19 +2628,20 @@ extension on TranslationsDe { 'messages.markedAsWatchedOffline' => 'Als gesehen markiert (wird synchronisiert, wenn online)', 'messages.markedAsUnwatchedOffline' => 'Als ungesehen markiert (wird synchronisiert, wenn online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch entfernt: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('de'))(n, one: 'Automatisch entfernt: ${n} angesehener Download', other: 'Automatisch entfernt: ${n} angesehene Downloads', ), 'messages.removedFromContinueWatching' => 'Aus ‚Weiterschauen\' entfernt', 'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}', 'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.', 'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.', 'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}', 'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie', 'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt', 'messages.noDescriptionAvailable' => 'Keine Beschreibung verfügbar', 'messages.noProfilesAvailable' => 'Keine Profile verfügbar', 'messages.contactAdminForProfiles' => 'Kontaktiere deinen Serveradministrator, um Profile hinzuzufügen', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden', 'messages.logsCleared' => 'Protokolle gelöscht', 'messages.logsCopied' => 'Protokolle in Zwischenablage kopiert', @@ -2653,6 +2680,11 @@ extension on TranslationsDe { 'subtitlingStyling.backgroundColor' => 'Hintergrundfarbe', 'subtitlingStyling.position' => 'Position', 'subtitlingStyling.assOverride' => 'ASS-Überschreibung', + 'subtitlingStyling.overrideScale' => 'Skalieren', + 'subtitlingStyling.overrideForce' => 'Erzwingen', + 'subtitlingStyling.overrideStrip' => 'Formatierung entfernen', + 'subtitlingStyling.positionTop' => 'Oben', + 'subtitlingStyling.positionBottom' => 'Unten', 'subtitlingStyling.bold' => 'Fett', 'subtitlingStyling.italic' => 'Kursiv', 'subtitlingStyling.renderResolution' => 'Render-Auflösung', @@ -2766,7 +2798,7 @@ extension on TranslationsDe { 'errors.searchFailed' => ({required Object error}) => 'Suche fehlgeschlagen: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Zeitüberschreitung beim Laden von ${context}', 'errors.connectionFailed' => 'Keine Verbindung zum Medienserver möglich', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Fehler beim Laden von ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => '${context} konnte nicht geladen werden. Bitte erneut versuchen.', 'errors.noClientAvailable' => 'Kein Client verfügbar', 'errors.pleaseEnterToken' => 'Bitte Token eingeben', 'errors.invalidToken' => 'Ungültiges Token', @@ -2987,7 +3019,6 @@ extension on TranslationsDe { 'collections.deleted' => 'Sammlung gelöscht', 'collections.deleteFailed' => 'Sammlung konnte nicht gelöscht werden', 'collections.deleteFailedWithError' => ({required Object error}) => 'Sammlung konnte nicht gelöscht werden: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Sammlungselemente konnten nicht geladen werden: ${error}', 'collections.selectCollection' => 'Sammlung auswählen', 'collections.collectionName' => 'Sammlungsname', 'collections.enterCollectionName' => 'Sammlungsnamen eingeben', @@ -3061,6 +3092,8 @@ extension on TranslationsDe { 'watchTogether.hostingSession' => 'Sitzung Hosten', 'watchTogether.inSession' => 'In Sitzung', 'watchTogether.sessionCode' => 'Sitzungscode', + 'watchTogether.openSessionControls' => 'Sitzungssteuerung für Gemeinsam Schauen öffnen', + 'watchTogether.copySessionCode' => 'Sitzungscode kopieren', 'watchTogether.hostControlsPlayback' => 'Host steuert die Wiedergabe', 'watchTogether.anyoneCanControl' => 'Alle können die Wiedergabe steuern', 'watchTogether.hostControls' => 'Host steuert', @@ -3115,6 +3148,8 @@ extension on TranslationsDe { 'downloads.manage' => 'Verwalten', 'downloads.tvShows' => 'Serien', 'downloads.movies' => 'Filme', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musik', 'downloads.tracksQueued' => ({required Object count}) => '${count} Titel zum Download in Warteschlange', 'downloads.noDownloads' => 'Noch keine Downloads', @@ -3127,8 +3162,6 @@ extension on TranslationsDe { 'downloads.serverErrorBitrate' => 'Serverfehler: Datei überschreitet möglicherweise das Remote-Bitrate-Limit', 'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt', 'downloads.downloadDeleted' => 'Download gelöscht', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => '"${title}" von diesem Gerät löschen?', 'downloads.cancelledDownloadTitle' => 'Abgebrochener Download', 'downloads.cancelledDownloadMessage' => 'Dieser Download wurde abgebrochen. Was möchtest du tun?', @@ -3260,6 +3293,8 @@ extension on TranslationsDe { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Verbindung nach ${attempts} Versuchen verloren', 'companionRemote.errors.connectionLost' => 'Verbindung verloren', 'videoSettings.playbackSpeed' => 'Wiedergabegeschwindigkeit', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Aktiv (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Schlaftimer', 'videoSettings.audioSync' => 'Audio-Synchronisation', @@ -3351,6 +3386,8 @@ extension on TranslationsDe { 'metadataEdit.artworkUpdated' => 'Artwork aktualisiert', 'metadataEdit.artworkUpdateFailed' => 'Artwork konnte nicht aktualisiert werden', 'metadataEdit.noArtworkAvailable' => 'Kein Artwork verfügbar', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Artwork-Option ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artwork-Option ${index}, ausgewählt', 'metadataEdit.notSet' => 'Nicht festgelegt', 'metadataEdit.libraryDefault' => 'Bibliotheksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', @@ -3465,11 +3502,13 @@ extension on TranslationsDe { 'services.deviceCode.title' => ({required Object service}) => 'Plezy auf ${service} aktivieren', 'services.deviceCode.body' => ({required Object url}) => 'Gehe zu ${url} und gib diesen Code ein:', 'services.deviceCode.openToActivate' => ({required Object service}) => '${service} zum Aktivieren öffnen', + 'services.deviceCode.copyCode' => 'Aktivierungscode kopieren', 'services.deviceCode.waitingForAuthorization' => 'Warte auf Autorisierung…', 'services.deviceCode.codeCopied' => 'Code kopiert', 'services.oauthProxy.title' => ({required Object service}) => 'Bei ${service} anmelden', 'services.oauthProxy.body' => 'Scanne diesen QR-Code oder öffne die URL auf einem beliebigen Gerät.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => '${service} zum Anmelden öffnen', + 'services.oauthProxy.copyUrl' => 'Anmelde-URL kopieren', 'services.oauthProxy.urlCopied' => 'URL kopiert', 'services.libraryFilter.title' => 'Bibliothekfilter', 'services.libraryFilter.subtitleAllSyncing' => 'Alle Bibliotheken werden synchronisiert', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 9734338b..51dca5e8 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -794,6 +794,9 @@ class TranslationsSettingsEn { /// en: 'Invalid regular expression' String get invalidRegex => 'Invalid regular expression'; + /// en: 'Regular expression' + String get regex => 'Regular expression'; + /// en: 'Downloads' String get downloads => 'Downloads'; @@ -1647,6 +1650,12 @@ class TranslationsMessagesEn { /// en: 'Auto-removed: ${title}' String autoRemovedWatchedDownload({required Object title}) => 'Auto-removed: ${title}'; + /// en: '(one) {Auto-removed ${n} watched download} (other) {Auto-removed ${n} watched downloads}' + String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, + one: 'Auto-removed ${n} watched download', + other: 'Auto-removed ${n} watched downloads', + ); + /// en: 'Removed from Continue Watching' String get removedFromContinueWatching => 'Removed from Continue Watching'; @@ -1803,6 +1812,21 @@ class TranslationsSubtitlingStylingEn { /// en: 'ASS Override' String get assOverride => 'ASS Override'; + /// en: 'Scale' + String get overrideScale => 'Scale'; + + /// en: 'Force' + String get overrideForce => 'Force'; + + /// en: 'Remove styling' + String get overrideStrip => 'Remove styling'; + + /// en: 'Top' + String get positionTop => 'Top'; + + /// en: 'Bottom' + String get positionBottom => 'Bottom'; + /// en: 'Bold' String get bold => 'Bold'; @@ -2196,8 +2220,8 @@ class TranslationsErrorsEn { /// en: 'Unable to connect to media server' String get connectionFailed => 'Unable to connect to media server'; - /// en: 'Failed to load ${context}: ${error}' - String failedToLoad({required Object context, required Object error}) => 'Failed to load ${context}: ${error}'; + /// en: 'Unable to load ${context}. Please try again.' + String unableToLoad({required Object context}) => 'Unable to load ${context}. Please try again.'; /// en: 'No client available' String get noClientAvailable => 'No client available'; @@ -2786,9 +2810,6 @@ class TranslationsCollectionsEn { /// en: 'Failed to delete collection: ${error}' String deleteFailedWithError({required Object error}) => 'Failed to delete collection: ${error}'; - /// en: 'Failed to load collection items: ${error}' - String failedToLoadItems({required Object error}) => 'Failed to load collection items: ${error}'; - /// en: 'Select Collection' String get selectCollection => 'Select Collection'; @@ -3493,6 +3514,12 @@ class TranslationsVideoSettingsEn { /// en: 'Playback Speed' String get playbackSpeed => 'Playback Speed'; + /// en: 'Normal' + String get normalSpeed => 'Normal'; + + /// en: 'Active (${duration})' + String sleepTimerActive({required Object duration}) => 'Active (${duration})'; + /// en: 'Zoom' String get zoom => 'Zoom'; @@ -5167,6 +5194,7 @@ extension on Translations { 'settings.creditsPattern' => 'Credits Marker Pattern', 'settings.creditsPatternDescription' => 'Regex pattern to match credits markers in chapter titles', 'settings.invalidRegex' => 'Invalid regular expression', + 'settings.regex' => 'Regular expression', 'settings.downloads' => 'Downloads', 'settings.downloadLocationDescription' => 'Choose where to store downloaded content', 'settings.downloadLocationDefault' => 'Default (App Storage)', @@ -5455,15 +5483,16 @@ extension on Translations { 'messages.markedAsWatchedOffline' => 'Marked as watched (will sync when online)', 'messages.markedAsUnwatchedOffline' => 'Marked as unwatched (will sync when online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Auto-removed: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ), 'messages.removedFromContinueWatching' => 'Removed from Continue Watching', 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', 'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.', 'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.', 'messages.fileInfoNotAvailable' => 'File information not available', - 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}', - 'messages.errorLoadingSeries' => 'Error loading series', _ => null, } ?? switch (path) { + 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}', + 'messages.errorLoadingSeries' => 'Error loading series', 'messages.musicNotSupported' => 'Music playback is not yet supported', 'messages.noDescriptionAvailable' => 'No description available', 'messages.noProfilesAvailable' => 'No profiles available', @@ -5506,6 +5535,11 @@ extension on Translations { 'subtitlingStyling.backgroundColor' => 'Background Color', 'subtitlingStyling.position' => 'Position', 'subtitlingStyling.assOverride' => 'ASS Override', + 'subtitlingStyling.overrideScale' => 'Scale', + 'subtitlingStyling.overrideForce' => 'Force', + 'subtitlingStyling.overrideStrip' => 'Remove styling', + 'subtitlingStyling.positionTop' => 'Top', + 'subtitlingStyling.positionBottom' => 'Bottom', 'subtitlingStyling.bold' => 'Bold', 'subtitlingStyling.italic' => 'Italic', 'subtitlingStyling.renderResolution' => 'Render Resolution', @@ -5619,7 +5653,7 @@ extension on Translations { 'errors.searchFailed' => ({required Object error}) => 'Search failed: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Connection timeout while loading ${context}', 'errors.connectionFailed' => 'Unable to connect to media server', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Failed to load ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Unable to load ${context}. Please try again.', 'errors.noClientAvailable' => 'No client available', 'errors.pleaseEnterToken' => 'Please enter a token', 'errors.invalidToken' => 'Invalid token', @@ -5840,7 +5874,6 @@ extension on Translations { 'collections.deleted' => 'Collection deleted', 'collections.deleteFailed' => 'Failed to delete collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Failed to delete collection: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Failed to load collection items: ${error}', 'collections.selectCollection' => 'Select Collection', 'collections.collectionName' => 'Collection Name', 'collections.enterCollectionName' => 'Enter collection name', @@ -5970,14 +6003,14 @@ extension on Translations { 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', 'downloads.movies' => 'Movies', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Music', 'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download', 'downloads.noDownloads' => 'No downloads yet', 'downloads.noDownloadsDescription' => 'Downloaded content will appear here for offline viewing', 'downloads.downloadNow' => 'Download', 'downloads.deleteDownload' => 'Delete download', - _ => null, - } ?? switch (path) { 'downloads.retryDownload' => 'Retry download', 'downloads.downloadQueued' => 'Download queued', 'downloads.downloadResumed' => 'Download resumed', @@ -6115,6 +6148,8 @@ extension on Translations { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Connection lost after ${attempts} attempts', 'companionRemote.errors.connectionLost' => 'Connection lost', 'videoSettings.playbackSpeed' => 'Playback Speed', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Active (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Sleep Timer', 'videoSettings.audioSync' => 'Audio Sync', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 2ae9a708..8aacb802 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsEs extends TranslationsSettingsEn { @override String get creditsPattern => 'Patrón de marcador de créditos'; @override String get creditsPatternDescription => 'Expresión regular para reconocer marcadores de créditos en los títulos de capítulos'; @override String get invalidRegex => 'Expresión regular no válida'; + @override String get regex => 'Expresión regular'; @override String get downloads => 'Descargas'; @override String get downloadLocationDescription => 'Elegir dónde almacenar el contenido descargado'; @override String get downloadLocationDefault => 'Predeterminado (Almacenamiento de la App)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysEs extends TranslationsHotkeysEn { @override String get clearShortcut => 'Borrar atajo'; @override String get noShortcutSet => 'Sin atajo asignado'; @override String get currentShortcut => 'Atajo actual:'; + @override String get pressToRecord => 'Seleccionar para grabar un atajo'; + @override String get recordingShortcut => 'Presiona el atajo ahora'; @override late final _TranslationsHotkeysActionsEs actions = _TranslationsHotkeysActionsEs._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityEs extends TranslationsAccessibilityEn { @override String get saturation => 'Saturación'; @override String get brightness => 'Brillo'; @override String get hexColor => 'Color hexadecimal'; + @override String get expandText => 'Expandir texto'; + @override String get collapseText => 'Contraer texto'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesEs extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Marcado como visto (se sincronizará al estar en línea)'; @override String get markedAsUnwatchedOffline => 'Marcado como no visto (se sincronizará al estar en línea)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Eliminado automáticamente: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, + one: 'Se eliminó automáticamente ${n} descarga vista', + other: 'Se eliminaron automáticamente ${n} descargas vistas', + ); @override String get removedFromContinueWatching => 'Eliminado de Seguir Viendo'; @override String errorLoading({required Object error}) => 'Error: ${error}'; @override String get streamInterrupted => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingEs extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Color de Fondo'; @override String get position => 'Posición'; @override String get assOverride => 'Sobreescritura ASS'; + @override String get overrideScale => 'Escalar'; + @override String get overrideForce => 'Forzar'; + @override String get overrideStrip => 'Quitar estilos'; + @override String get positionTop => 'Arriba'; + @override String get positionBottom => 'Abajo'; @override String get bold => 'Negrita'; @override String get italic => 'Cursiva'; @override String get renderResolution => 'Resolución de renderizado'; @@ -918,7 +932,7 @@ class _TranslationsErrorsEs extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Error en la búsqueda: ${error}'; @override String connectionTimeout({required Object context}) => 'Tiempo de conexión agotado al cargar ${context}'; @override String get connectionFailed => 'No se puede conectar al servidor multimedia'; - @override String failedToLoad({required Object context, required Object error}) => 'Error al cargar ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'No se pudo cargar ${context}. Inténtalo de nuevo.'; @override String get noClientAvailable => 'No hay cliente disponible'; @override String get pleaseEnterToken => 'Por favor, introduce un token'; @override String get invalidToken => 'Token no válido'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsEs extends TranslationsCollectionsEn { @override String get deleted => 'Colección eliminada'; @override String get deleteFailed => 'Error al eliminar la colección'; @override String deleteFailedWithError({required Object error}) => 'Error al eliminar la colección: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Error al cargar los elementos de la colección: ${error}'; @override String get selectCollection => 'Seleccionar Colección'; @override String get collectionName => 'Nombre de la Colección'; @override String get enterCollectionName => 'Introduce el nombre de la colección'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherEs extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Anfitrión de la Sesión'; @override String get inSession => 'En Sesión'; @override String get sessionCode => 'Código de Sesión'; + @override String get openSessionControls => 'Abrir los controles de la sesión de Ver Juntos'; + @override String get copySessionCode => 'Copiar código de sesión'; @override String get hostControlsPlayback => 'El anfitrión controla la reproducción'; @override String get anyoneCanControl => 'Cualquiera puede controlar la reproducción'; @override String get hostControls => 'Control del anfitrión'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsEs extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Velocidad de reproducción'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Activo (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Temporizador de apagado'; @override String get audioSync => 'Sincronización de audio'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditEs extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork actualizado'; @override String get artworkUpdateFailed => 'Error al actualizar el artwork'; @override String get noArtworkAvailable => 'No hay artwork disponible'; + @override String artworkOption({required Object index}) => 'Opción de imagen ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Opción de imagen ${index}, seleccionada'; @override String get notSet => 'No establecido'; @override String get libraryDefault => 'Predeterminado de biblioteca'; @override String get accountDefault => 'Predeterminado de cuenta'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeEs extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Activar Plezy en ${service}'; @override String body({required Object url}) => 'Visita ${url} e introduce este código:'; @override String openToActivate({required Object service}) => 'Abrir ${service} para activar'; + @override String get copyCode => 'Copiar código de activación'; @override String get waitingForAuthorization => 'Esperando autorización…'; @override String get codeCopied => 'Código copiado'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyEs extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Inicia sesión en ${service}'; @override String get body => 'Escanea este código QR o abre la URL en cualquier dispositivo.'; @override String openToSignIn({required Object service}) => 'Abrir ${service} para iniciar sesión'; + @override String get copyUrl => 'Copiar URL de inicio de sesión'; @override String get urlCopied => 'URL copiada'; } @@ -2318,6 +2339,7 @@ extension on TranslationsEs { 'settings.creditsPattern' => 'Patrón de marcador de créditos', 'settings.creditsPatternDescription' => 'Expresión regular para reconocer marcadores de créditos en los títulos de capítulos', 'settings.invalidRegex' => 'Expresión regular no válida', + 'settings.regex' => 'Expresión regular', 'settings.downloads' => 'Descargas', 'settings.downloadLocationDescription' => 'Elegir dónde almacenar el contenido descargado', 'settings.downloadLocationDefault' => 'Predeterminado (Almacenamiento de la App)', @@ -2423,6 +2445,8 @@ extension on TranslationsEs { 'hotkeys.clearShortcut' => 'Borrar atajo', 'hotkeys.noShortcutSet' => 'Sin atajo asignado', 'hotkeys.currentShortcut' => 'Atajo actual:', + 'hotkeys.pressToRecord' => 'Seleccionar para grabar un atajo', + 'hotkeys.recordingShortcut' => 'Presiona el atajo ahora', 'hotkeys.actions.playPause' => 'Reproducir/Pausar', 'hotkeys.actions.volumeUp' => 'Subir Volumen', 'hotkeys.actions.volumeDown' => 'Bajar Volumen', @@ -2512,6 +2536,8 @@ extension on TranslationsEs { 'accessibility.saturation' => 'Saturación', 'accessibility.brightness' => 'Brillo', 'accessibility.hexColor' => 'Color hexadecimal', + 'accessibility.expandText' => 'Expandir texto', + 'accessibility.collapseText' => 'Contraer texto', 'tooltips.shufflePlay' => 'Reproducción aleatoria', 'tooltips.playTrailer' => 'Reproducir tráiler', 'tooltips.markAsWatched' => 'Marcar como visto', @@ -2602,19 +2628,20 @@ extension on TranslationsEs { 'messages.markedAsWatchedOffline' => 'Marcado como visto (se sincronizará al estar en línea)', 'messages.markedAsUnwatchedOffline' => 'Marcado como no visto (se sincronizará al estar en línea)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Eliminado automáticamente: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('es'))(n, one: 'Se eliminó automáticamente ${n} descarga vista', other: 'Se eliminaron automáticamente ${n} descargas vistas', ), 'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo', 'messages.errorLoading' => ({required Object error}) => 'Error: ${error}', 'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.', 'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.', 'messages.fileInfoNotAvailable' => 'Información de archivo no disponible', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}', 'messages.errorLoadingSeries' => 'Error al cargar la serie', 'messages.musicNotSupported' => 'La reproducción de música aún no está soportada', 'messages.noDescriptionAvailable' => 'No hay descripción disponible', 'messages.noProfilesAvailable' => 'No hay perfiles disponibles', 'messages.contactAdminForProfiles' => 'Contacta a tu administrador del servidor para añadir perfiles', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'No se puede determinar la sección de biblioteca para este elemento', 'messages.logsCleared' => 'Logs borrados', 'messages.logsCopied' => 'Logs copiados al portapapeles', @@ -2653,6 +2680,11 @@ extension on TranslationsEs { 'subtitlingStyling.backgroundColor' => 'Color de Fondo', 'subtitlingStyling.position' => 'Posición', 'subtitlingStyling.assOverride' => 'Sobreescritura ASS', + 'subtitlingStyling.overrideScale' => 'Escalar', + 'subtitlingStyling.overrideForce' => 'Forzar', + 'subtitlingStyling.overrideStrip' => 'Quitar estilos', + 'subtitlingStyling.positionTop' => 'Arriba', + 'subtitlingStyling.positionBottom' => 'Abajo', 'subtitlingStyling.bold' => 'Negrita', 'subtitlingStyling.italic' => 'Cursiva', 'subtitlingStyling.renderResolution' => 'Resolución de renderizado', @@ -2766,7 +2798,7 @@ extension on TranslationsEs { 'errors.searchFailed' => ({required Object error}) => 'Error en la búsqueda: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tiempo de conexión agotado al cargar ${context}', 'errors.connectionFailed' => 'No se puede conectar al servidor multimedia', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Error al cargar ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'No se pudo cargar ${context}. Inténtalo de nuevo.', 'errors.noClientAvailable' => 'No hay cliente disponible', 'errors.pleaseEnterToken' => 'Por favor, introduce un token', 'errors.invalidToken' => 'Token no válido', @@ -2987,7 +3019,6 @@ extension on TranslationsEs { 'collections.deleted' => 'Colección eliminada', 'collections.deleteFailed' => 'Error al eliminar la colección', 'collections.deleteFailedWithError' => ({required Object error}) => 'Error al eliminar la colección: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Error al cargar los elementos de la colección: ${error}', 'collections.selectCollection' => 'Seleccionar Colección', 'collections.collectionName' => 'Nombre de la Colección', 'collections.enterCollectionName' => 'Introduce el nombre de la colección', @@ -3061,6 +3092,8 @@ extension on TranslationsEs { 'watchTogether.hostingSession' => 'Anfitrión de la Sesión', 'watchTogether.inSession' => 'En Sesión', 'watchTogether.sessionCode' => 'Código de Sesión', + 'watchTogether.openSessionControls' => 'Abrir los controles de la sesión de Ver Juntos', + 'watchTogether.copySessionCode' => 'Copiar código de sesión', 'watchTogether.hostControlsPlayback' => 'El anfitrión controla la reproducción', 'watchTogether.anyoneCanControl' => 'Cualquiera puede controlar la reproducción', 'watchTogether.hostControls' => 'Control del anfitrión', @@ -3115,6 +3148,8 @@ extension on TranslationsEs { 'downloads.manage' => 'Gestionar', 'downloads.tvShows' => 'Series de TV', 'downloads.movies' => 'Películas', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Música', 'downloads.tracksQueued' => ({required Object count}) => '${count} canciones en cola para descargar', 'downloads.noDownloads' => 'No hay descargas aún', @@ -3127,8 +3162,6 @@ extension on TranslationsEs { 'downloads.serverErrorBitrate' => 'Error del servidor: el archivo puede superar el límite de bitrate remoto', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodios en cola para descargar', 'downloads.downloadDeleted' => 'Descarga eliminada', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => '¿Eliminar "${title}" de este dispositivo?', 'downloads.cancelledDownloadTitle' => 'Descarga cancelada', 'downloads.cancelledDownloadMessage' => 'Esta descarga se canceló. ¿Qué quieres hacer?', @@ -3260,6 +3293,8 @@ extension on TranslationsEs { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Conexión perdida tras ${attempts} intentos', 'companionRemote.errors.connectionLost' => 'Conexión perdida', 'videoSettings.playbackSpeed' => 'Velocidad de reproducción', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Activo (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Temporizador de apagado', 'videoSettings.audioSync' => 'Sincronización de audio', @@ -3351,6 +3386,8 @@ extension on TranslationsEs { 'metadataEdit.artworkUpdated' => 'Artwork actualizado', 'metadataEdit.artworkUpdateFailed' => 'Error al actualizar el artwork', 'metadataEdit.noArtworkAvailable' => 'No hay artwork disponible', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Opción de imagen ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Opción de imagen ${index}, seleccionada', 'metadataEdit.notSet' => 'No establecido', 'metadataEdit.libraryDefault' => 'Predeterminado de biblioteca', 'metadataEdit.accountDefault' => 'Predeterminado de cuenta', @@ -3465,11 +3502,13 @@ extension on TranslationsEs { 'services.deviceCode.title' => ({required Object service}) => 'Activar Plezy en ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Visita ${url} e introduce este código:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para activar', + 'services.deviceCode.copyCode' => 'Copiar código de activación', 'services.deviceCode.waitingForAuthorization' => 'Esperando autorización…', 'services.deviceCode.codeCopied' => 'Código copiado', 'services.oauthProxy.title' => ({required Object service}) => 'Inicia sesión en ${service}', 'services.oauthProxy.body' => 'Escanea este código QR o abre la URL en cualquier dispositivo.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para iniciar sesión', + 'services.oauthProxy.copyUrl' => 'Copiar URL de inicio de sesión', 'services.oauthProxy.urlCopied' => 'URL copiada', 'services.libraryFilter.title' => 'Filtro de bibliotecas', 'services.libraryFilter.subtitleAllSyncing' => 'Sincronizando todas las bibliotecas', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index a92f5d8c..ec5898f7 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsFr extends TranslationsSettingsEn { @override String get creditsPattern => 'Modèle de marqueur de générique'; @override String get creditsPatternDescription => 'Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres'; @override String get invalidRegex => 'Expression régulière invalide'; + @override String get regex => 'Expression régulière'; @override String get downloads => 'Téléchargement'; @override String get downloadLocationDescription => 'Choisissez où stocker le contenu téléchargé'; @override String get downloadLocationDefault => 'Par défaut (stockage de l\'application)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysFr extends TranslationsHotkeysEn { @override String get clearShortcut => 'Effacer le raccourci'; @override String get noShortcutSet => 'Aucun raccourci défini'; @override String get currentShortcut => 'Raccourci actuel :'; + @override String get pressToRecord => 'Sélectionner pour enregistrer un raccourci'; + @override String get recordingShortcut => 'Appuyez maintenant sur le raccourci'; @override late final _TranslationsHotkeysActionsFr actions = _TranslationsHotkeysActionsFr._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityFr extends TranslationsAccessibilityEn { @override String get saturation => 'Saturation'; @override String get brightness => 'Luminosité'; @override String get hexColor => 'Couleur hexadécimale'; + @override String get expandText => 'Développer le texte'; + @override String get collapseText => 'Replier le texte'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesFr extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Marqué comme vu (se synchronisera lorsque vous serez en ligne)'; @override String get markedAsUnwatchedOffline => 'Marqué comme non vu (sera synchronisé lorsque vous serez en ligne)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Supprimé automatiquement : ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, + one: '${n} téléchargement vu supprimé automatiquement', + other: '${n} téléchargements vus supprimés automatiquement', + ); @override String get removedFromContinueWatching => 'Supprimer de "Continuer à regarder"'; @override String errorLoading({required Object error}) => 'Erreur: ${error}'; @override String get streamInterrupted => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingFr extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Couleur d\'arrière-plan'; @override String get position => 'Position'; @override String get assOverride => 'Remplacement ASS'; + @override String get overrideScale => 'Mettre à l’échelle'; + @override String get overrideForce => 'Forcer'; + @override String get overrideStrip => 'Supprimer le style'; + @override String get positionTop => 'Haut'; + @override String get positionBottom => 'Bas'; @override String get bold => 'Gras'; @override String get italic => 'Italique'; @override String get renderResolution => 'Résolution de rendu'; @@ -918,7 +932,7 @@ class _TranslationsErrorsFr extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Recherche échouée: ${error}'; @override String connectionTimeout({required Object context}) => 'Délai d\'attente de connexion dépassé pendant le chargement ${context}'; @override String get connectionFailed => 'Impossible de se connecter au serveur multimédia'; - @override String failedToLoad({required Object context, required Object error}) => 'Échec du chargement ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Impossible de charger ${context}. Réessayez.'; @override String get noClientAvailable => 'Aucun client disponible'; @override String get pleaseEnterToken => 'Veuillez saisir un token'; @override String get invalidToken => 'Token invalide'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsFr extends TranslationsCollectionsEn { @override String get deleted => 'Collection supprimée'; @override String get deleteFailed => 'Échec de la suppression de la collection'; @override String deleteFailedWithError({required Object error}) => 'Échec de la suppression de la collection: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Échec du chargement des éléments de la collection: ${error}'; @override String get selectCollection => 'Sélectionner une collection'; @override String get collectionName => 'Nom de la collection'; @override String get enterCollectionName => 'Entrez le nom de la collection'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherFr extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Session d\'hébergement'; @override String get inSession => 'En session'; @override String get sessionCode => 'Code de session'; + @override String get openSessionControls => 'Ouvrir les commandes de la session Regarder ensemble'; + @override String get copySessionCode => 'Copier le code de session'; @override String get hostControlsPlayback => 'L\'hôte contrôle la lecture'; @override String get anyoneCanControl => 'Tout le monde peut contrôler la lecture'; @override String get hostControls => 'Commandes de l\'hôte'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsFr extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Vitesse de lecture'; + @override String get normalSpeed => 'Normale'; + @override String sleepTimerActive({required Object duration}) => 'Actif (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Minuterie de mise en veille'; @override String get audioSync => 'Synchronisation audio'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditFr extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork mis à jour'; @override String get artworkUpdateFailed => 'Échec de la mise à jour de l\'artwork'; @override String get noArtworkAvailable => 'Aucun artwork disponible'; + @override String artworkOption({required Object index}) => 'Option d\'illustration ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Option d\'illustration ${index}, sélectionnée'; @override String get notSet => 'Non défini'; @override String get libraryDefault => 'Par défaut de la bibliothèque'; @override String get accountDefault => 'Par défaut du compte'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeFr extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Activer Plezy sur ${service}'; @override String body({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :'; @override String openToActivate({required Object service}) => 'Ouvrir ${service} pour activer'; + @override String get copyCode => 'Copier le code d\'activation'; @override String get waitingForAuthorization => 'En attente d\'autorisation…'; @override String get codeCopied => 'Code copié'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyFr extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Se connecter à ${service}'; @override String get body => 'Scannez ce code QR ou ouvrez l\'URL sur n\'importe quel appareil.'; @override String openToSignIn({required Object service}) => 'Ouvrir ${service} pour se connecter'; + @override String get copyUrl => 'Copier l\'URL de connexion'; @override String get urlCopied => 'URL copiée'; } @@ -2318,6 +2339,7 @@ extension on TranslationsFr { 'settings.creditsPattern' => 'Modèle de marqueur de générique', 'settings.creditsPatternDescription' => 'Expression régulière pour reconnaître les marqueurs de générique dans les titres de chapitres', 'settings.invalidRegex' => 'Expression régulière invalide', + 'settings.regex' => 'Expression régulière', 'settings.downloads' => 'Téléchargement', 'settings.downloadLocationDescription' => 'Choisissez où stocker le contenu téléchargé', 'settings.downloadLocationDefault' => 'Par défaut (stockage de l\'application)', @@ -2423,6 +2445,8 @@ extension on TranslationsFr { 'hotkeys.clearShortcut' => 'Effacer le raccourci', 'hotkeys.noShortcutSet' => 'Aucun raccourci défini', 'hotkeys.currentShortcut' => 'Raccourci actuel :', + 'hotkeys.pressToRecord' => 'Sélectionner pour enregistrer un raccourci', + 'hotkeys.recordingShortcut' => 'Appuyez maintenant sur le raccourci', 'hotkeys.actions.playPause' => 'Lecture/Pause', 'hotkeys.actions.volumeUp' => 'Augmenter le volume', 'hotkeys.actions.volumeDown' => 'Baisser le volume', @@ -2512,6 +2536,8 @@ extension on TranslationsFr { 'accessibility.saturation' => 'Saturation', 'accessibility.brightness' => 'Luminosité', 'accessibility.hexColor' => 'Couleur hexadécimale', + 'accessibility.expandText' => 'Développer le texte', + 'accessibility.collapseText' => 'Replier le texte', 'tooltips.shufflePlay' => 'Lecture aléatoire', 'tooltips.playTrailer' => 'Lire la bande-annonce', 'tooltips.markAsWatched' => 'Marqué comme vu', @@ -2602,19 +2628,20 @@ extension on TranslationsFr { 'messages.markedAsWatchedOffline' => 'Marqué comme vu (se synchronisera lorsque vous serez en ligne)', 'messages.markedAsUnwatchedOffline' => 'Marqué comme non vu (sera synchronisé lorsque vous serez en ligne)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Supprimé automatiquement : ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('fr'))(n, one: '${n} téléchargement vu supprimé automatiquement', other: '${n} téléchargements vus supprimés automatiquement', ), 'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"', 'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}', 'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.', 'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.', 'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}', 'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série', 'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge', 'messages.noDescriptionAvailable' => 'Aucune description disponible', 'messages.noProfilesAvailable' => 'Aucun profil disponible', 'messages.contactAdminForProfiles' => 'Contactez votre administrateur serveur pour ajouter des profils', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Impossible de déterminer la section de la bibliothèque pour cet élément', 'messages.logsCleared' => 'Logs effacés', 'messages.logsCopied' => 'Logs copiés dans le presse-papier', @@ -2653,6 +2680,11 @@ extension on TranslationsFr { 'subtitlingStyling.backgroundColor' => 'Couleur d\'arrière-plan', 'subtitlingStyling.position' => 'Position', 'subtitlingStyling.assOverride' => 'Remplacement ASS', + 'subtitlingStyling.overrideScale' => 'Mettre à l’échelle', + 'subtitlingStyling.overrideForce' => 'Forcer', + 'subtitlingStyling.overrideStrip' => 'Supprimer le style', + 'subtitlingStyling.positionTop' => 'Haut', + 'subtitlingStyling.positionBottom' => 'Bas', 'subtitlingStyling.bold' => 'Gras', 'subtitlingStyling.italic' => 'Italique', 'subtitlingStyling.renderResolution' => 'Résolution de rendu', @@ -2766,7 +2798,7 @@ extension on TranslationsFr { 'errors.searchFailed' => ({required Object error}) => 'Recherche échouée: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Délai d\'attente de connexion dépassé pendant le chargement ${context}', 'errors.connectionFailed' => 'Impossible de se connecter au serveur multimédia', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Échec du chargement ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Impossible de charger ${context}. Réessayez.', 'errors.noClientAvailable' => 'Aucun client disponible', 'errors.pleaseEnterToken' => 'Veuillez saisir un token', 'errors.invalidToken' => 'Token invalide', @@ -2987,7 +3019,6 @@ extension on TranslationsFr { 'collections.deleted' => 'Collection supprimée', 'collections.deleteFailed' => 'Échec de la suppression de la collection', 'collections.deleteFailedWithError' => ({required Object error}) => 'Échec de la suppression de la collection: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Échec du chargement des éléments de la collection: ${error}', 'collections.selectCollection' => 'Sélectionner une collection', 'collections.collectionName' => 'Nom de la collection', 'collections.enterCollectionName' => 'Entrez le nom de la collection', @@ -3061,6 +3092,8 @@ extension on TranslationsFr { 'watchTogether.hostingSession' => 'Session d\'hébergement', 'watchTogether.inSession' => 'En session', 'watchTogether.sessionCode' => 'Code de session', + 'watchTogether.openSessionControls' => 'Ouvrir les commandes de la session Regarder ensemble', + 'watchTogether.copySessionCode' => 'Copier le code de session', 'watchTogether.hostControlsPlayback' => 'L\'hôte contrôle la lecture', 'watchTogether.anyoneCanControl' => 'Tout le monde peut contrôler la lecture', 'watchTogether.hostControls' => 'Commandes de l\'hôte', @@ -3115,6 +3148,8 @@ extension on TranslationsFr { 'downloads.manage' => 'Gérer', 'downloads.tvShows' => 'Show TV', 'downloads.movies' => 'Films', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musique', 'downloads.tracksQueued' => ({required Object count}) => '${count} titres en file d\'attente de téléchargement', 'downloads.noDownloads' => 'Aucun téléchargement pour le moment', @@ -3127,8 +3162,6 @@ extension on TranslationsFr { 'downloads.serverErrorBitrate' => 'Erreur serveur : le fichier peut dépasser la limite de bitrate distant', 'downloads.episodesQueued' => ({required Object count}) => '${count} épisodes en attente de téléchargement', 'downloads.downloadDeleted' => 'Télécharger supprimé', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Supprimer "${title}" de cet appareil ?', 'downloads.cancelledDownloadTitle' => 'Téléchargement annulé', 'downloads.cancelledDownloadMessage' => 'Ce téléchargement a été annulé. Que voulez-vous faire ?', @@ -3260,6 +3293,8 @@ extension on TranslationsFr { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Connexion perdue après ${attempts} tentatives', 'companionRemote.errors.connectionLost' => 'Connexion perdue', 'videoSettings.playbackSpeed' => 'Vitesse de lecture', + 'videoSettings.normalSpeed' => 'Normale', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Actif (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Minuterie de mise en veille', 'videoSettings.audioSync' => 'Synchronisation audio', @@ -3351,6 +3386,8 @@ extension on TranslationsFr { 'metadataEdit.artworkUpdated' => 'Artwork mis à jour', 'metadataEdit.artworkUpdateFailed' => 'Échec de la mise à jour de l\'artwork', 'metadataEdit.noArtworkAvailable' => 'Aucun artwork disponible', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Option d\'illustration ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Option d\'illustration ${index}, sélectionnée', 'metadataEdit.notSet' => 'Non défini', 'metadataEdit.libraryDefault' => 'Par défaut de la bibliothèque', 'metadataEdit.accountDefault' => 'Par défaut du compte', @@ -3465,11 +3502,13 @@ extension on TranslationsFr { 'services.deviceCode.title' => ({required Object service}) => 'Activer Plezy sur ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Rendez-vous sur ${url} et entrez ce code :', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Ouvrir ${service} pour activer', + 'services.deviceCode.copyCode' => 'Copier le code d\'activation', 'services.deviceCode.waitingForAuthorization' => 'En attente d\'autorisation…', 'services.deviceCode.codeCopied' => 'Code copié', 'services.oauthProxy.title' => ({required Object service}) => 'Se connecter à ${service}', 'services.oauthProxy.body' => 'Scannez ce code QR ou ouvrez l\'URL sur n\'importe quel appareil.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Ouvrir ${service} pour se connecter', + 'services.oauthProxy.copyUrl' => 'Copier l\'URL de connexion', 'services.oauthProxy.urlCopied' => 'URL copiée', 'services.libraryFilter.title' => 'Filtre de bibliothèques', 'services.libraryFilter.subtitleAllSyncing' => 'Synchronisation de toutes les bibliothèques', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index b23e6e94..21127840 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsIt extends TranslationsSettingsEn { @override String get creditsPattern => 'Pattern marcatore titoli di coda'; @override String get creditsPatternDescription => 'Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli'; @override String get invalidRegex => 'Espressione regolare non valida'; + @override String get regex => 'Espressione regolare'; @override String get downloads => 'Download'; @override String get downloadLocationDescription => 'Scegli dove salvare i contenuti scaricati'; @override String get downloadLocationDefault => 'Predefinita (Archiviazione App)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysIt extends TranslationsHotkeysEn { @override String get clearShortcut => 'Elimina scorciatoia'; @override String get noShortcutSet => 'Nessuna scorciatoia impostata'; @override String get currentShortcut => 'Scorciatoia attuale:'; + @override String get pressToRecord => 'Seleziona per registrare una scorciatoia'; + @override String get recordingShortcut => 'Premi ora la scorciatoia'; @override late final _TranslationsHotkeysActionsIt actions = _TranslationsHotkeysActionsIt._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityIt extends TranslationsAccessibilityEn { @override String get saturation => 'Saturazione'; @override String get brightness => 'Luminosità'; @override String get hexColor => 'Colore esadecimale'; + @override String get expandText => 'Espandi il testo'; + @override String get collapseText => 'Comprimi il testo'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesIt extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Segnato come visto (sincronizzato online)'; @override String get markedAsUnwatchedOffline => 'Segnato come non visto (sincronizzato online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Rimosso automaticamente: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, + one: 'Rimosso automaticamente ${n} download già visto', + other: 'Rimossi automaticamente ${n} download già visti', + ); @override String get removedFromContinueWatching => 'Rimosso da Continua a guardare'; @override String errorLoading({required Object error}) => 'Errore: ${error}'; @override String get streamInterrupted => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingIt extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Colore sfondo'; @override String get position => 'Posizione'; @override String get assOverride => 'Sovrascrittura ASS'; + @override String get overrideScale => 'Ridimensiona'; + @override String get overrideForce => 'Forza'; + @override String get overrideStrip => 'Rimuovi stile'; + @override String get positionTop => 'In alto'; + @override String get positionBottom => 'In basso'; @override String get bold => 'Grassetto'; @override String get italic => 'Corsivo'; @override String get renderResolution => 'Risoluzione di rendering'; @@ -918,7 +932,7 @@ class _TranslationsErrorsIt extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Ricerca fallita: ${error}'; @override String connectionTimeout({required Object context}) => 'Timeout connessione durante caricamento di ${context}'; @override String get connectionFailed => 'Impossibile connettersi al server multimediale'; - @override String failedToLoad({required Object context, required Object error}) => 'Impossibile caricare ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Impossibile caricare ${context}. Riprova.'; @override String get noClientAvailable => 'Nessun client disponibile'; @override String get pleaseEnterToken => 'Inserisci token'; @override String get invalidToken => 'Token non valido'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsIt extends TranslationsCollectionsEn { @override String get deleted => 'Raccolta eliminata'; @override String get deleteFailed => 'Impossibile eliminare la raccolta'; @override String deleteFailedWithError({required Object error}) => 'Impossibile eliminare la raccolta: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}'; @override String get selectCollection => 'Seleziona raccolta'; @override String get collectionName => 'Nome raccolta'; @override String get enterCollectionName => 'Inserisci nome raccolta'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherIt extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Hosting Sessione'; @override String get inSession => 'In Sessione'; @override String get sessionCode => 'Codice Sessione'; + @override String get openSessionControls => 'Apri i controlli della sessione di Guarda Insieme'; + @override String get copySessionCode => 'Copia il codice della sessione'; @override String get hostControlsPlayback => 'L\'host controlla la riproduzione'; @override String get anyoneCanControl => 'Tutti possono controllare la riproduzione'; @override String get hostControls => 'Controllo host'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsIt extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Velocità di riproduzione'; + @override String get normalSpeed => 'Normale'; + @override String sleepTimerActive({required Object duration}) => 'Attivo (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Timer di spegnimento'; @override String get audioSync => 'Sincronizzazione audio'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditIt extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork aggiornato'; @override String get artworkUpdateFailed => 'Aggiornamento artwork non riuscito'; @override String get noArtworkAvailable => 'Nessun artwork disponibile'; + @override String artworkOption({required Object index}) => 'Opzione artwork ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Opzione artwork ${index}, selezionata'; @override String get notSet => 'Non impostato'; @override String get libraryDefault => 'Predefinito libreria'; @override String get accountDefault => 'Predefinito account'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeIt extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Attiva Plezy su ${service}'; @override String body({required Object url}) => 'Visita ${url} e inserisci questo codice:'; @override String openToActivate({required Object service}) => 'Apri ${service} per attivare'; + @override String get copyCode => 'Copia il codice di attivazione'; @override String get waitingForAuthorization => 'In attesa di autorizzazione…'; @override String get codeCopied => 'Codice copiato'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyIt extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Accedi a ${service}'; @override String get body => 'Scansiona questo codice QR o apri l\'URL su qualsiasi dispositivo.'; @override String openToSignIn({required Object service}) => 'Apri ${service} per accedere'; + @override String get copyUrl => 'Copia l\'URL di accesso'; @override String get urlCopied => 'URL copiato'; } @@ -2318,6 +2339,7 @@ extension on TranslationsIt { 'settings.creditsPattern' => 'Pattern marcatore titoli di coda', 'settings.creditsPatternDescription' => 'Espressione regolare per riconoscere i marcatori dei titoli di coda nei capitoli', 'settings.invalidRegex' => 'Espressione regolare non valida', + 'settings.regex' => 'Espressione regolare', 'settings.downloads' => 'Download', 'settings.downloadLocationDescription' => 'Scegli dove salvare i contenuti scaricati', 'settings.downloadLocationDefault' => 'Predefinita (Archiviazione App)', @@ -2423,6 +2445,8 @@ extension on TranslationsIt { 'hotkeys.clearShortcut' => 'Elimina scorciatoia', 'hotkeys.noShortcutSet' => 'Nessuna scorciatoia impostata', 'hotkeys.currentShortcut' => 'Scorciatoia attuale:', + 'hotkeys.pressToRecord' => 'Seleziona per registrare una scorciatoia', + 'hotkeys.recordingShortcut' => 'Premi ora la scorciatoia', 'hotkeys.actions.playPause' => 'Riproduci/Pausa', 'hotkeys.actions.volumeUp' => 'Alza volume', 'hotkeys.actions.volumeDown' => 'Abbassa volume', @@ -2512,6 +2536,8 @@ extension on TranslationsIt { 'accessibility.saturation' => 'Saturazione', 'accessibility.brightness' => 'Luminosità', 'accessibility.hexColor' => 'Colore esadecimale', + 'accessibility.expandText' => 'Espandi il testo', + 'accessibility.collapseText' => 'Comprimi il testo', 'tooltips.shufflePlay' => 'Riproduzione casuale', 'tooltips.playTrailer' => 'Riproduci trailer', 'tooltips.markAsWatched' => 'Segna come visto', @@ -2602,19 +2628,20 @@ extension on TranslationsIt { 'messages.markedAsWatchedOffline' => 'Segnato come visto (sincronizzato online)', 'messages.markedAsUnwatchedOffline' => 'Segnato come non visto (sincronizzato online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Rimosso automaticamente: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('it'))(n, one: 'Rimosso automaticamente ${n} download già visto', other: 'Rimossi automaticamente ${n} download già visti', ), 'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare', 'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}', 'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.', 'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.', 'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}', 'messages.errorLoadingSeries' => 'Errore caricamento serie', 'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata', 'messages.noDescriptionAvailable' => 'Nessuna descrizione disponibile', 'messages.noProfilesAvailable' => 'Nessun profilo disponibile', 'messages.contactAdminForProfiles' => 'Contatta l\'amministratore del server per aggiungere profili', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Impossibile determinare la sezione della libreria per questo elemento', 'messages.logsCleared' => 'Log eliminati', 'messages.logsCopied' => 'Log copiati negli appunti', @@ -2653,6 +2680,11 @@ extension on TranslationsIt { 'subtitlingStyling.backgroundColor' => 'Colore sfondo', 'subtitlingStyling.position' => 'Posizione', 'subtitlingStyling.assOverride' => 'Sovrascrittura ASS', + 'subtitlingStyling.overrideScale' => 'Ridimensiona', + 'subtitlingStyling.overrideForce' => 'Forza', + 'subtitlingStyling.overrideStrip' => 'Rimuovi stile', + 'subtitlingStyling.positionTop' => 'In alto', + 'subtitlingStyling.positionBottom' => 'In basso', 'subtitlingStyling.bold' => 'Grassetto', 'subtitlingStyling.italic' => 'Corsivo', 'subtitlingStyling.renderResolution' => 'Risoluzione di rendering', @@ -2766,7 +2798,7 @@ extension on TranslationsIt { 'errors.searchFailed' => ({required Object error}) => 'Ricerca fallita: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Timeout connessione durante caricamento di ${context}', 'errors.connectionFailed' => 'Impossibile connettersi al server multimediale', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Impossibile caricare ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Impossibile caricare ${context}. Riprova.', 'errors.noClientAvailable' => 'Nessun client disponibile', 'errors.pleaseEnterToken' => 'Inserisci token', 'errors.invalidToken' => 'Token non valido', @@ -2987,7 +3019,6 @@ extension on TranslationsIt { 'collections.deleted' => 'Raccolta eliminata', 'collections.deleteFailed' => 'Impossibile eliminare la raccolta', 'collections.deleteFailedWithError' => ({required Object error}) => 'Impossibile eliminare la raccolta: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Impossibile caricare gli elementi della raccolta: ${error}', 'collections.selectCollection' => 'Seleziona raccolta', 'collections.collectionName' => 'Nome raccolta', 'collections.enterCollectionName' => 'Inserisci nome raccolta', @@ -3061,6 +3092,8 @@ extension on TranslationsIt { 'watchTogether.hostingSession' => 'Hosting Sessione', 'watchTogether.inSession' => 'In Sessione', 'watchTogether.sessionCode' => 'Codice Sessione', + 'watchTogether.openSessionControls' => 'Apri i controlli della sessione di Guarda Insieme', + 'watchTogether.copySessionCode' => 'Copia il codice della sessione', 'watchTogether.hostControlsPlayback' => 'L\'host controlla la riproduzione', 'watchTogether.anyoneCanControl' => 'Tutti possono controllare la riproduzione', 'watchTogether.hostControls' => 'Controllo host', @@ -3115,6 +3148,8 @@ extension on TranslationsIt { 'downloads.manage' => 'Gestisci', 'downloads.tvShows' => 'Serie TV', 'downloads.movies' => 'Film', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musica', 'downloads.tracksQueued' => ({required Object count}) => '${count} brani in coda per il download', 'downloads.noDownloads' => 'Nessun download', @@ -3127,8 +3162,6 @@ extension on TranslationsIt { 'downloads.serverErrorBitrate' => 'Errore server: il file può superare il limite di bitrate remoto', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download', 'downloads.downloadDeleted' => 'Download eliminato', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Eliminare "${title}" da questo dispositivo?', 'downloads.cancelledDownloadTitle' => 'Download annullato', 'downloads.cancelledDownloadMessage' => 'Questo download è stato annullato. Cosa vuoi fare?', @@ -3260,6 +3293,8 @@ extension on TranslationsIt { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Connessione persa dopo ${attempts} tentativi', 'companionRemote.errors.connectionLost' => 'Connessione persa', 'videoSettings.playbackSpeed' => 'Velocità di riproduzione', + 'videoSettings.normalSpeed' => 'Normale', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Attivo (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Timer di spegnimento', 'videoSettings.audioSync' => 'Sincronizzazione audio', @@ -3351,6 +3386,8 @@ extension on TranslationsIt { 'metadataEdit.artworkUpdated' => 'Artwork aggiornato', 'metadataEdit.artworkUpdateFailed' => 'Aggiornamento artwork non riuscito', 'metadataEdit.noArtworkAvailable' => 'Nessun artwork disponibile', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Opzione artwork ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Opzione artwork ${index}, selezionata', 'metadataEdit.notSet' => 'Non impostato', 'metadataEdit.libraryDefault' => 'Predefinito libreria', 'metadataEdit.accountDefault' => 'Predefinito account', @@ -3465,11 +3502,13 @@ extension on TranslationsIt { 'services.deviceCode.title' => ({required Object service}) => 'Attiva Plezy su ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Visita ${url} e inserisci questo codice:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Apri ${service} per attivare', + 'services.deviceCode.copyCode' => 'Copia il codice di attivazione', 'services.deviceCode.waitingForAuthorization' => 'In attesa di autorizzazione…', 'services.deviceCode.codeCopied' => 'Codice copiato', 'services.oauthProxy.title' => ({required Object service}) => 'Accedi a ${service}', 'services.oauthProxy.body' => 'Scansiona questo codice QR o apri l\'URL su qualsiasi dispositivo.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Apri ${service} per accedere', + 'services.oauthProxy.copyUrl' => 'Copia l\'URL di accesso', 'services.oauthProxy.urlCopied' => 'URL copiato', 'services.libraryFilter.title' => 'Filtro librerie', 'services.libraryFilter.subtitleAllSyncing' => 'Sincronizzazione di tutte le librerie', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 09116585..52152b4d 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsJa extends TranslationsSettingsEn { @override String get creditsPattern => 'クレジットマーカーパターン'; @override String get creditsPatternDescription => 'チャプタータイトルのクレジットマーカーに一致する正規表現パターン'; @override String get invalidRegex => '無効な正規表現'; + @override String get regex => '正規表現'; @override String get downloads => 'ダウンロード'; @override String get downloadLocationDescription => 'ダウンロードコンテンツの保存場所を選択'; @override String get downloadLocationDefault => 'デフォルト(アプリストレージ)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysJa extends TranslationsHotkeysEn { @override String get clearShortcut => 'ショートカットをクリア'; @override String get noShortcutSet => 'ショートカット未設定'; @override String get currentShortcut => '現在のショートカット:'; + @override String get pressToRecord => '選択してショートカットを記録'; + @override String get recordingShortcut => 'ショートカットを押してください'; @override late final _TranslationsHotkeysActionsJa actions = _TranslationsHotkeysActionsJa._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityJa extends TranslationsAccessibilityEn { @override String get saturation => '彩度'; @override String get brightness => '明るさ'; @override String get hexColor => '16進カラー'; + @override String get expandText => 'テキストを展開'; + @override String get collapseText => 'テキストを折りたたむ'; } // Path: tooltips @@ -693,6 +698,9 @@ class _TranslationsMessagesJa extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => '視聴済みにしました(オンライン時に同期)'; @override String get markedAsUnwatchedOffline => '未視聴にしました(オンライン時に同期)'; @override String autoRemovedWatchedDownload({required Object title}) => '自動削除: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, + other: '視聴済みダウンロードを${n}件自動削除しました', + ); @override String get removedFromContinueWatching => '視聴中から削除しました'; @override String errorLoading({required Object error}) => 'エラー: ${error}'; @override String get streamInterrupted => 'ストリームが中断されました。再生を押すかシークして再試行してください。'; @@ -751,6 +759,11 @@ class _TranslationsSubtitlingStylingJa extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => '背景色'; @override String get position => '位置'; @override String get assOverride => 'ASSオーバーライド'; + @override String get overrideScale => '拡大縮小'; + @override String get overrideForce => '強制'; + @override String get overrideStrip => 'スタイルを削除'; + @override String get positionTop => '上'; + @override String get positionBottom => '下'; @override String get bold => '太字'; @override String get italic => '斜体'; @override String get renderResolution => 'レンダリング解像度'; @@ -918,7 +931,7 @@ class _TranslationsErrorsJa extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => '検索に失敗しました: ${error}'; @override String connectionTimeout({required Object context}) => '${context}の読み込み中に接続がタイムアウトしました'; @override String get connectionFailed => 'メディアサーバーに接続できません'; - @override String failedToLoad({required Object context, required Object error}) => '${context}の読み込みに失敗しました: ${error}'; + @override String unableToLoad({required Object context}) => '${context}を読み込めませんでした。もう一度お試しください。'; @override String get noClientAvailable => 'クライアントが利用できません'; @override String get pleaseEnterToken => 'トークンを入力してください'; @override String get invalidToken => '無効なトークン'; @@ -1179,7 +1192,6 @@ class _TranslationsCollectionsJa extends TranslationsCollectionsEn { @override String get deleted => 'コレクションを削除しました'; @override String get deleteFailed => 'コレクションの削除に失敗しました'; @override String deleteFailedWithError({required Object error}) => 'コレクションの削除に失敗しました: ${error}'; - @override String failedToLoadItems({required Object error}) => 'コレクションアイテムの読み込みに失敗しました: ${error}'; @override String get selectCollection => 'コレクションを選択'; @override String get collectionName => 'コレクション名'; @override String get enterCollectionName => 'コレクション名を入力'; @@ -1282,6 +1294,8 @@ class _TranslationsWatchTogetherJa extends TranslationsWatchTogetherEn { @override String get hostingSession => 'セッションをホスト中'; @override String get inSession => 'セッション中'; @override String get sessionCode => 'セッションコード'; + @override String get openSessionControls => '「一緒に見る」セッションのコントロールを開く'; + @override String get copySessionCode => 'セッションコードをコピー'; @override String get hostControlsPlayback => 'ホストが再生を制御'; @override String get anyoneCanControl => '全員が再生を制御可能'; @override String get hostControls => 'ホストが制御'; @@ -1458,6 +1472,8 @@ class _TranslationsVideoSettingsJa extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => '再生速度'; + @override String get normalSpeed => '標準'; + @override String sleepTimerActive({required Object duration}) => '作動中(${duration})'; @override String get zoom => 'ズーム'; @override String get sleepTimer => 'スリープタイマー'; @override String get audioSync => '音声同期'; @@ -1576,6 +1592,8 @@ class _TranslationsMetadataEditJa extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'アートワークを更新しました'; @override String get artworkUpdateFailed => 'アートワークの更新に失敗しました'; @override String get noArtworkAvailable => 'アートワークがありません'; + @override String artworkOption({required Object index}) => 'アートワークの選択肢 ${index}'; + @override String selectedArtworkOption({required Object index}) => 'アートワークの選択肢 ${index}、選択済み'; @override String get notSet => '未設定'; @override String get libraryDefault => 'ライブラリのデフォルト'; @override String get accountDefault => 'アカウントのデフォルト'; @@ -2053,6 +2071,7 @@ class _TranslationsServicesDeviceCodeJa extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => '${service} で Plezy を有効化'; @override String body({required Object url}) => '${url} にアクセスしてこのコードを入力:'; @override String openToActivate({required Object service}) => '${service} を開いて有効化'; + @override String get copyCode => 'アクティベーションコードをコピー'; @override String get waitingForAuthorization => '認証を待っています…'; @override String get codeCopied => 'コードをコピーしました'; } @@ -2067,6 +2086,7 @@ class _TranslationsServicesOauthProxyJa extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => '${service} にサインイン'; @override String get body => 'このQRコードをスキャンするか、任意のデバイスでURLを開いてください。'; @override String openToSignIn({required Object service}) => '${service} を開いてサインイン'; + @override String get copyUrl => 'サインインURLをコピー'; @override String get urlCopied => 'URLをコピーしました'; } @@ -2316,6 +2336,7 @@ extension on TranslationsJa { 'settings.creditsPattern' => 'クレジットマーカーパターン', 'settings.creditsPatternDescription' => 'チャプタータイトルのクレジットマーカーに一致する正規表現パターン', 'settings.invalidRegex' => '無効な正規表現', + 'settings.regex' => '正規表現', 'settings.downloads' => 'ダウンロード', 'settings.downloadLocationDescription' => 'ダウンロードコンテンツの保存場所を選択', 'settings.downloadLocationDefault' => 'デフォルト(アプリストレージ)', @@ -2421,6 +2442,8 @@ extension on TranslationsJa { 'hotkeys.clearShortcut' => 'ショートカットをクリア', 'hotkeys.noShortcutSet' => 'ショートカット未設定', 'hotkeys.currentShortcut' => '現在のショートカット:', + 'hotkeys.pressToRecord' => '選択してショートカットを記録', + 'hotkeys.recordingShortcut' => 'ショートカットを押してください', 'hotkeys.actions.playPause' => '再生/一時停止', 'hotkeys.actions.volumeUp' => '音量を上げる', 'hotkeys.actions.volumeDown' => '音量を下げる', @@ -2510,6 +2533,8 @@ extension on TranslationsJa { 'accessibility.saturation' => '彩度', 'accessibility.brightness' => '明るさ', 'accessibility.hexColor' => '16進カラー', + 'accessibility.expandText' => 'テキストを展開', + 'accessibility.collapseText' => 'テキストを折りたたむ', 'tooltips.shufflePlay' => 'シャッフル再生', 'tooltips.playTrailer' => '予告編を再生', 'tooltips.markAsWatched' => '視聴済みにする', @@ -2600,19 +2625,20 @@ extension on TranslationsJa { 'messages.markedAsWatchedOffline' => '視聴済みにしました(オンライン時に同期)', 'messages.markedAsUnwatchedOffline' => '未視聴にしました(オンライン時に同期)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '自動削除: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ja'))(n, other: '視聴済みダウンロードを${n}件自動削除しました', ), 'messages.removedFromContinueWatching' => '視聴中から削除しました', 'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}', 'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。', 'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。', 'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}', 'messages.errorLoadingSeries' => 'シリーズの読み込みエラー', 'messages.musicNotSupported' => '音楽の再生はまだサポートされていません', 'messages.noDescriptionAvailable' => '説明はありません', 'messages.noProfilesAvailable' => '利用可能なプロフィールがありません', 'messages.contactAdminForProfiles' => 'プロファイルを追加するにはサーバー管理者に連絡してください', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'このアイテムのライブラリセクションを判別できません', 'messages.logsCleared' => 'ログをクリアしました', 'messages.logsCopied' => 'ログをクリップボードにコピーしました', @@ -2651,6 +2677,11 @@ extension on TranslationsJa { 'subtitlingStyling.backgroundColor' => '背景色', 'subtitlingStyling.position' => '位置', 'subtitlingStyling.assOverride' => 'ASSオーバーライド', + 'subtitlingStyling.overrideScale' => '拡大縮小', + 'subtitlingStyling.overrideForce' => '強制', + 'subtitlingStyling.overrideStrip' => 'スタイルを削除', + 'subtitlingStyling.positionTop' => '上', + 'subtitlingStyling.positionBottom' => '下', 'subtitlingStyling.bold' => '太字', 'subtitlingStyling.italic' => '斜体', 'subtitlingStyling.renderResolution' => 'レンダリング解像度', @@ -2764,7 +2795,7 @@ extension on TranslationsJa { 'errors.searchFailed' => ({required Object error}) => '検索に失敗しました: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '${context}の読み込み中に接続がタイムアウトしました', 'errors.connectionFailed' => 'メディアサーバーに接続できません', - 'errors.failedToLoad' => ({required Object context, required Object error}) => '${context}の読み込みに失敗しました: ${error}', + 'errors.unableToLoad' => ({required Object context}) => '${context}を読み込めませんでした。もう一度お試しください。', 'errors.noClientAvailable' => 'クライアントが利用できません', 'errors.pleaseEnterToken' => 'トークンを入力してください', 'errors.invalidToken' => '無効なトークン', @@ -2985,7 +3016,6 @@ extension on TranslationsJa { 'collections.deleted' => 'コレクションを削除しました', 'collections.deleteFailed' => 'コレクションの削除に失敗しました', 'collections.deleteFailedWithError' => ({required Object error}) => 'コレクションの削除に失敗しました: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'コレクションアイテムの読み込みに失敗しました: ${error}', 'collections.selectCollection' => 'コレクションを選択', 'collections.collectionName' => 'コレクション名', 'collections.enterCollectionName' => 'コレクション名を入力', @@ -3059,6 +3089,8 @@ extension on TranslationsJa { 'watchTogether.hostingSession' => 'セッションをホスト中', 'watchTogether.inSession' => 'セッション中', 'watchTogether.sessionCode' => 'セッションコード', + 'watchTogether.openSessionControls' => '「一緒に見る」セッションのコントロールを開く', + 'watchTogether.copySessionCode' => 'セッションコードをコピー', 'watchTogether.hostControlsPlayback' => 'ホストが再生を制御', 'watchTogether.anyoneCanControl' => '全員が再生を制御可能', 'watchTogether.hostControls' => 'ホストが制御', @@ -3113,6 +3145,8 @@ extension on TranslationsJa { 'downloads.manage' => '管理', 'downloads.tvShows' => 'テレビ番組', 'downloads.movies' => '映画', + _ => null, + } ?? switch (path) { 'downloads.music' => '音楽', 'downloads.tracksQueued' => ({required Object count}) => '${count} 曲をダウンロード待機中', 'downloads.noDownloads' => 'ダウンロードなし', @@ -3125,8 +3159,6 @@ extension on TranslationsJa { 'downloads.serverErrorBitrate' => 'サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります', 'downloads.episodesQueued' => ({required Object count}) => '${count}エピソードをダウンロードキューに追加しました', 'downloads.downloadDeleted' => 'ダウンロードを削除しました', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'このデバイスから「${title}」を削除しますか?', 'downloads.cancelledDownloadTitle' => 'キャンセルされたダウンロード', 'downloads.cancelledDownloadMessage' => 'このダウンロードはキャンセルされました。どうしますか?', @@ -3258,6 +3290,8 @@ extension on TranslationsJa { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => '${attempts}回試行後に接続が切断されました', 'companionRemote.errors.connectionLost' => '接続が切断されました', 'videoSettings.playbackSpeed' => '再生速度', + 'videoSettings.normalSpeed' => '標準', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => '作動中(${duration})', 'videoSettings.zoom' => 'ズーム', 'videoSettings.sleepTimer' => 'スリープタイマー', 'videoSettings.audioSync' => '音声同期', @@ -3349,6 +3383,8 @@ extension on TranslationsJa { 'metadataEdit.artworkUpdated' => 'アートワークを更新しました', 'metadataEdit.artworkUpdateFailed' => 'アートワークの更新に失敗しました', 'metadataEdit.noArtworkAvailable' => 'アートワークがありません', + 'metadataEdit.artworkOption' => ({required Object index}) => 'アートワークの選択肢 ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'アートワークの選択肢 ${index}、選択済み', 'metadataEdit.notSet' => '未設定', 'metadataEdit.libraryDefault' => 'ライブラリのデフォルト', 'metadataEdit.accountDefault' => 'アカウントのデフォルト', @@ -3463,11 +3499,13 @@ extension on TranslationsJa { 'services.deviceCode.title' => ({required Object service}) => '${service} で Plezy を有効化', 'services.deviceCode.body' => ({required Object url}) => '${url} にアクセスしてこのコードを入力:', 'services.deviceCode.openToActivate' => ({required Object service}) => '${service} を開いて有効化', + 'services.deviceCode.copyCode' => 'アクティベーションコードをコピー', 'services.deviceCode.waitingForAuthorization' => '認証を待っています…', 'services.deviceCode.codeCopied' => 'コードをコピーしました', 'services.oauthProxy.title' => ({required Object service}) => '${service} にサインイン', 'services.oauthProxy.body' => 'このQRコードをスキャンするか、任意のデバイスでURLを開いてください。', 'services.oauthProxy.openToSignIn' => ({required Object service}) => '${service} を開いてサインイン', + 'services.oauthProxy.copyUrl' => 'サインインURLをコピー', 'services.oauthProxy.urlCopied' => 'URLをコピーしました', 'services.libraryFilter.title' => 'ライブラリフィルター', 'services.libraryFilter.subtitleAllSyncing' => 'すべてのライブラリを同期中', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index c37cc02b..bb83b8fe 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsKo extends TranslationsSettingsEn { @override String get creditsPattern => '크레딧 마커 패턴'; @override String get creditsPatternDescription => '챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴'; @override String get invalidRegex => '잘못된 정규식'; + @override String get regex => '정규식'; @override String get downloads => '다운로드'; @override String get downloadLocationDescription => '다운로드 콘텐츠 저장 위치 선택'; @override String get downloadLocationDefault => '기본값 (앱 저장소)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysKo extends TranslationsHotkeysEn { @override String get clearShortcut => '단축키 삭제'; @override String get noShortcutSet => '설정된 단축키 없음'; @override String get currentShortcut => '현재 단축키:'; + @override String get pressToRecord => '단축키를 기록하려면 선택'; + @override String get recordingShortcut => '지금 단축키를 누르세요'; @override late final _TranslationsHotkeysActionsKo actions = _TranslationsHotkeysActionsKo._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityKo extends TranslationsAccessibilityEn { @override String get saturation => '채도'; @override String get brightness => '밝기'; @override String get hexColor => '16진수 색상'; + @override String get expandText => '텍스트 펼치기'; + @override String get collapseText => '텍스트 접기'; } // Path: tooltips @@ -693,6 +698,9 @@ class _TranslationsMessagesKo extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => '시청 완료로 표시됨 (연결 시 동기화됨)'; @override String get markedAsUnwatchedOffline => '미시청으로 표시됨 (연결 시 동기화됨)'; @override String autoRemovedWatchedDownload({required Object title}) => '자동 삭제됨: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, + other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', + ); @override String get removedFromContinueWatching => '계속 시청 목록에서 제거됨'; @override String errorLoading({required Object error}) => '오류: ${error}'; @override String get streamInterrupted => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.'; @@ -751,6 +759,11 @@ class _TranslationsSubtitlingStylingKo extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => '배경색'; @override String get position => '위치'; @override String get assOverride => 'ASS 오버라이드'; + @override String get overrideScale => '크기 조정'; + @override String get overrideForce => '강제 적용'; + @override String get overrideStrip => '스타일 제거'; + @override String get positionTop => '위'; + @override String get positionBottom => '아래'; @override String get bold => '굵게'; @override String get italic => '기울임꼴'; @override String get renderResolution => '렌더링 해상도'; @@ -918,7 +931,7 @@ class _TranslationsErrorsKo extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => '검색 실패: ${error}'; @override String connectionTimeout({required Object context}) => '${context} 로드 중 연결 시간 초과'; @override String get connectionFailed => '미디어 서버에 연결할 수 없습니다'; - @override String failedToLoad({required Object context, required Object error}) => '${context} 로드 실패: ${error}'; + @override String unableToLoad({required Object context}) => '${context}을(를) 불러올 수 없습니다. 다시 시도하세요.'; @override String get noClientAvailable => '사용 가능한 클라이언트가 없습니다'; @override String get pleaseEnterToken => '토큰을 입력해 주세요'; @override String get invalidToken => '토큰이 유효하지 않습니다'; @@ -1179,7 +1192,6 @@ class _TranslationsCollectionsKo extends TranslationsCollectionsEn { @override String get deleted => '컬렉션 삭제됨'; @override String get deleteFailed => '컬렉션 삭제 실패'; @override String deleteFailedWithError({required Object error}) => '컬렉션 삭제 실패: ${error}'; - @override String failedToLoadItems({required Object error}) => '컬렉션 항목 로드 실패: ${error}'; @override String get selectCollection => '컬렉션 선택'; @override String get collectionName => '컬렉션 이름'; @override String get enterCollectionName => '컬렉션 이름 입력'; @@ -1282,6 +1294,8 @@ class _TranslationsWatchTogetherKo extends TranslationsWatchTogetherEn { @override String get hostingSession => '세션 호스팅'; @override String get inSession => '세션 중'; @override String get sessionCode => '세션 코드'; + @override String get openSessionControls => '함께 보기 세션 제어 열기'; + @override String get copySessionCode => '세션 코드 복사'; @override String get hostControlsPlayback => '호스트 재생 제어'; @override String get anyoneCanControl => '누구나 재생 제어 가능'; @override String get hostControls => '호스트 제어'; @@ -1458,6 +1472,8 @@ class _TranslationsVideoSettingsKo extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => '재생 속도'; + @override String get normalSpeed => '보통'; + @override String sleepTimerActive({required Object duration}) => '활성 (${duration})'; @override String get zoom => '확대/축소'; @override String get sleepTimer => '취침 타이머'; @override String get audioSync => '오디오 동기화'; @@ -1576,6 +1592,8 @@ class _TranslationsMetadataEditKo extends TranslationsMetadataEditEn { @override String get artworkUpdated => '아트워크가 업데이트되었습니다'; @override String get artworkUpdateFailed => '아트워크 업데이트 실패'; @override String get noArtworkAvailable => '사용 가능한 아트워크 없음'; + @override String artworkOption({required Object index}) => '아트워크 옵션 ${index}'; + @override String selectedArtworkOption({required Object index}) => '아트워크 옵션 ${index}, 선택됨'; @override String get notSet => '설정되지 않음'; @override String get libraryDefault => '라이브러리 기본값'; @override String get accountDefault => '계정 기본값'; @@ -2053,6 +2071,7 @@ class _TranslationsServicesDeviceCodeKo extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => '${service}에서 Plezy 활성화'; @override String body({required Object url}) => '${url}을 방문하여 이 코드를 입력하세요:'; @override String openToActivate({required Object service}) => '활성화하려면 ${service} 열기'; + @override String get copyCode => '활성화 코드 복사'; @override String get waitingForAuthorization => '인증을 기다리는 중…'; @override String get codeCopied => '코드가 복사되었습니다'; } @@ -2067,6 +2086,7 @@ class _TranslationsServicesOauthProxyKo extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => '${service}에 로그인'; @override String get body => '이 QR 코드를 스캔하거나 아무 기기에서 URL을 여세요.'; @override String openToSignIn({required Object service}) => '로그인하려면 ${service} 열기'; + @override String get copyUrl => '로그인 URL 복사'; @override String get urlCopied => 'URL이 복사되었습니다'; } @@ -2316,6 +2336,7 @@ extension on TranslationsKo { 'settings.creditsPattern' => '크레딧 마커 패턴', 'settings.creditsPatternDescription' => '챕터 제목에서 크레딧 마커를 인식하는 정규식 패턴', 'settings.invalidRegex' => '잘못된 정규식', + 'settings.regex' => '정규식', 'settings.downloads' => '다운로드', 'settings.downloadLocationDescription' => '다운로드 콘텐츠 저장 위치 선택', 'settings.downloadLocationDefault' => '기본값 (앱 저장소)', @@ -2421,6 +2442,8 @@ extension on TranslationsKo { 'hotkeys.clearShortcut' => '단축키 삭제', 'hotkeys.noShortcutSet' => '설정된 단축키 없음', 'hotkeys.currentShortcut' => '현재 단축키:', + 'hotkeys.pressToRecord' => '단축키를 기록하려면 선택', + 'hotkeys.recordingShortcut' => '지금 단축키를 누르세요', 'hotkeys.actions.playPause' => '재생/일시정지', 'hotkeys.actions.volumeUp' => '볼륨 높이기', 'hotkeys.actions.volumeDown' => '볼륨 낮추기', @@ -2510,6 +2533,8 @@ extension on TranslationsKo { 'accessibility.saturation' => '채도', 'accessibility.brightness' => '밝기', 'accessibility.hexColor' => '16진수 색상', + 'accessibility.expandText' => '텍스트 펼치기', + 'accessibility.collapseText' => '텍스트 접기', 'tooltips.shufflePlay' => '무작위 재생', 'tooltips.playTrailer' => '예고편 재생', 'tooltips.markAsWatched' => '시청 완료로 표시', @@ -2600,19 +2625,20 @@ extension on TranslationsKo { 'messages.markedAsWatchedOffline' => '시청 완료로 표시됨 (연결 시 동기화됨)', 'messages.markedAsUnwatchedOffline' => '미시청으로 표시됨 (연결 시 동기화됨)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '자동 삭제됨: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ko'))(n, other: '시청한 다운로드 ${n}개를 자동 삭제했습니다', ), 'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨', 'messages.errorLoading' => ({required Object error}) => '오류: ${error}', 'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.', 'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.', 'messages.fileInfoNotAvailable' => '파일 정보가 없습니다', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}', 'messages.errorLoadingSeries' => '시리즈 로딩 중 오류', 'messages.musicNotSupported' => '음악 재생 미지원', 'messages.noDescriptionAvailable' => '설명이 없습니다', 'messages.noProfilesAvailable' => '사용 가능한 프로필이 없습니다', 'messages.contactAdminForProfiles' => '프로필을 추가하려면 서버 관리자에게 문의하세요', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => '이 항목의 라이브러리 섹션을 확인할 수 없습니다', 'messages.logsCleared' => '로그가 삭제 되었습니다', 'messages.logsCopied' => '로그가 클립보드에 복사 되었습니다', @@ -2651,6 +2677,11 @@ extension on TranslationsKo { 'subtitlingStyling.backgroundColor' => '배경색', 'subtitlingStyling.position' => '위치', 'subtitlingStyling.assOverride' => 'ASS 오버라이드', + 'subtitlingStyling.overrideScale' => '크기 조정', + 'subtitlingStyling.overrideForce' => '강제 적용', + 'subtitlingStyling.overrideStrip' => '스타일 제거', + 'subtitlingStyling.positionTop' => '위', + 'subtitlingStyling.positionBottom' => '아래', 'subtitlingStyling.bold' => '굵게', 'subtitlingStyling.italic' => '기울임꼴', 'subtitlingStyling.renderResolution' => '렌더링 해상도', @@ -2764,7 +2795,7 @@ extension on TranslationsKo { 'errors.searchFailed' => ({required Object error}) => '검색 실패: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '${context} 로드 중 연결 시간 초과', 'errors.connectionFailed' => '미디어 서버에 연결할 수 없습니다', - 'errors.failedToLoad' => ({required Object context, required Object error}) => '${context} 로드 실패: ${error}', + 'errors.unableToLoad' => ({required Object context}) => '${context}을(를) 불러올 수 없습니다. 다시 시도하세요.', 'errors.noClientAvailable' => '사용 가능한 클라이언트가 없습니다', 'errors.pleaseEnterToken' => '토큰을 입력해 주세요', 'errors.invalidToken' => '토큰이 유효하지 않습니다', @@ -2985,7 +3016,6 @@ extension on TranslationsKo { 'collections.deleted' => '컬렉션 삭제됨', 'collections.deleteFailed' => '컬렉션 삭제 실패', 'collections.deleteFailedWithError' => ({required Object error}) => '컬렉션 삭제 실패: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => '컬렉션 항목 로드 실패: ${error}', 'collections.selectCollection' => '컬렉션 선택', 'collections.collectionName' => '컬렉션 이름', 'collections.enterCollectionName' => '컬렉션 이름 입력', @@ -3059,6 +3089,8 @@ extension on TranslationsKo { 'watchTogether.hostingSession' => '세션 호스팅', 'watchTogether.inSession' => '세션 중', 'watchTogether.sessionCode' => '세션 코드', + 'watchTogether.openSessionControls' => '함께 보기 세션 제어 열기', + 'watchTogether.copySessionCode' => '세션 코드 복사', 'watchTogether.hostControlsPlayback' => '호스트 재생 제어', 'watchTogether.anyoneCanControl' => '누구나 재생 제어 가능', 'watchTogether.hostControls' => '호스트 제어', @@ -3113,6 +3145,8 @@ extension on TranslationsKo { 'downloads.manage' => '관리', 'downloads.tvShows' => 'TV 프로그램', 'downloads.movies' => '영화', + _ => null, + } ?? switch (path) { 'downloads.music' => '음악', 'downloads.tracksQueued' => ({required Object count}) => '${count}곡 다운로드 대기 중', 'downloads.noDownloads' => '다운로드 없음', @@ -3125,8 +3159,6 @@ extension on TranslationsKo { 'downloads.serverErrorBitrate' => '서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다', 'downloads.episodesQueued' => ({required Object count}) => '${count} 에피소드가 다운로드 대기열에 추가 되었습니다', 'downloads.downloadDeleted' => '다운로드 삭제됨', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?', 'downloads.cancelledDownloadTitle' => '취소된 다운로드', 'downloads.cancelledDownloadMessage' => '이 다운로드가 취소되었습니다. 어떻게 하시겠습니까?', @@ -3258,6 +3290,8 @@ extension on TranslationsKo { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => '${attempts}회 시도 후 연결이 끊어졌습니다', 'companionRemote.errors.connectionLost' => '연결이 끊어졌습니다', 'videoSettings.playbackSpeed' => '재생 속도', + 'videoSettings.normalSpeed' => '보통', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => '활성 (${duration})', 'videoSettings.zoom' => '확대/축소', 'videoSettings.sleepTimer' => '취침 타이머', 'videoSettings.audioSync' => '오디오 동기화', @@ -3349,6 +3383,8 @@ extension on TranslationsKo { 'metadataEdit.artworkUpdated' => '아트워크가 업데이트되었습니다', 'metadataEdit.artworkUpdateFailed' => '아트워크 업데이트 실패', 'metadataEdit.noArtworkAvailable' => '사용 가능한 아트워크 없음', + 'metadataEdit.artworkOption' => ({required Object index}) => '아트워크 옵션 ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => '아트워크 옵션 ${index}, 선택됨', 'metadataEdit.notSet' => '설정되지 않음', 'metadataEdit.libraryDefault' => '라이브러리 기본값', 'metadataEdit.accountDefault' => '계정 기본값', @@ -3463,11 +3499,13 @@ extension on TranslationsKo { 'services.deviceCode.title' => ({required Object service}) => '${service}에서 Plezy 활성화', 'services.deviceCode.body' => ({required Object url}) => '${url}을 방문하여 이 코드를 입력하세요:', 'services.deviceCode.openToActivate' => ({required Object service}) => '활성화하려면 ${service} 열기', + 'services.deviceCode.copyCode' => '활성화 코드 복사', 'services.deviceCode.waitingForAuthorization' => '인증을 기다리는 중…', 'services.deviceCode.codeCopied' => '코드가 복사되었습니다', 'services.oauthProxy.title' => ({required Object service}) => '${service}에 로그인', 'services.oauthProxy.body' => '이 QR 코드를 스캔하거나 아무 기기에서 URL을 여세요.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => '로그인하려면 ${service} 열기', + 'services.oauthProxy.copyUrl' => '로그인 URL 복사', 'services.oauthProxy.urlCopied' => 'URL이 복사되었습니다', 'services.libraryFilter.title' => '라이브러리 필터', 'services.libraryFilter.subtitleAllSyncing' => '모든 라이브러리 동기화 중', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 7af00168..4921bf96 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsNb extends TranslationsSettingsEn { @override String get creditsPattern => 'Rulletekstmarkørmønster'; @override String get creditsPatternDescription => 'Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler'; @override String get invalidRegex => 'Ugyldig regulært uttrykk'; + @override String get regex => 'Regulært uttrykk'; @override String get downloads => 'Nedlastinger'; @override String get downloadLocationDescription => 'Velg hvor nedlastet innhold skal lagres'; @override String get downloadLocationDefault => 'Standard (App-lagring)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysNb extends TranslationsHotkeysEn { @override String get clearShortcut => 'Fjern snarvei'; @override String get noShortcutSet => 'Ingen snarvei satt'; @override String get currentShortcut => 'Gjeldende snarvei:'; + @override String get pressToRecord => 'Velg for å registrere en snarvei'; + @override String get recordingShortcut => 'Trykk på snarveien nå'; @override late final _TranslationsHotkeysActionsNb actions = _TranslationsHotkeysActionsNb._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityNb extends TranslationsAccessibilityEn { @override String get saturation => 'Metning'; @override String get brightness => 'Lysstyrke'; @override String get hexColor => 'Heksadesimal farge'; + @override String get expandText => 'Utvid tekst'; + @override String get collapseText => 'Fold sammen tekst'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesNb extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Merket som sett (synkroniseres når tilkoblet)'; @override String get markedAsUnwatchedOffline => 'Merket som usett (synkroniseres når tilkoblet)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatisk fjernet: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, + one: 'Fjernet automatisk ${n} sett nedlasting', + other: 'Fjernet automatisk ${n} sette nedlastinger', + ); @override String get removedFromContinueWatching => 'Fjernet fra Fortsett å se'; @override String errorLoading({required Object error}) => 'Feil: ${error}'; @override String get streamInterrupted => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingNb extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Bakgrunnsfarge'; @override String get position => 'Posisjon'; @override String get assOverride => 'ASS-overstyring'; + @override String get overrideScale => 'Skaler'; + @override String get overrideForce => 'Tving'; + @override String get overrideStrip => 'Fjern formatering'; + @override String get positionTop => 'Øverst'; + @override String get positionBottom => 'Nederst'; @override String get bold => 'Fet'; @override String get italic => 'Kursiv'; @override String get renderResolution => 'Gjengivelsesoppløsning'; @@ -918,7 +932,7 @@ class _TranslationsErrorsNb extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Søk mislyktes: ${error}'; @override String connectionTimeout({required Object context}) => 'Tidsavbrudd ved lasting av ${context}'; @override String get connectionFailed => 'Kan ikke koble til medieserver'; - @override String failedToLoad({required Object context, required Object error}) => 'Kunne ikke laste ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Kunne ikke laste ${context}. Prøv igjen.'; @override String get noClientAvailable => 'Ingen klient tilgjengelig'; @override String get pleaseEnterToken => 'Vennligst skriv inn et token'; @override String get invalidToken => 'Ugyldig token'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsNb extends TranslationsCollectionsEn { @override String get deleted => 'Samling slettet'; @override String get deleteFailed => 'Kunne ikke slette samling'; @override String deleteFailedWithError({required Object error}) => 'Kunne ikke slette samling: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Kunne ikke laste samlingselementer: ${error}'; @override String get selectCollection => 'Velg samling'; @override String get collectionName => 'Samlingsnavn'; @override String get enterCollectionName => 'Skriv inn samlingsnavn'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherNb extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Er vert for økt'; @override String get inSession => 'I økt'; @override String get sessionCode => 'Øktkode'; + @override String get openSessionControls => 'Åpne øktkontroller for Se sammen'; + @override String get copySessionCode => 'Kopier øktkode'; @override String get hostControlsPlayback => 'Verten kontrollerer avspilling'; @override String get anyoneCanControl => 'Alle kan kontrollere avspilling'; @override String get hostControls => 'Vertskontroll'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsNb extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Avspillingshastighet'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Aktiv (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Søvntimer'; @override String get audioSync => 'Lydsynkronisering'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditNb extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Kunstverk oppdatert'; @override String get artworkUpdateFailed => 'Kunne ikke oppdatere kunstverk'; @override String get noArtworkAvailable => 'Ingen kunstverk tilgjengelig'; + @override String artworkOption({required Object index}) => 'Alternativ for kunstverk ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Alternativ for kunstverk ${index}, valgt'; @override String get notSet => 'Ikke angitt'; @override String get libraryDefault => 'Bibliotekstandard'; @override String get accountDefault => 'Kontostandard'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeNb extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Aktiver Plezy på ${service}'; @override String body({required Object url}) => 'Besøk ${url} og skriv inn denne koden:'; @override String openToActivate({required Object service}) => 'Åpne ${service} for å aktivere'; + @override String get copyCode => 'Kopier aktiveringskode'; @override String get waitingForAuthorization => 'Venter på godkjenning…'; @override String get codeCopied => 'Kode kopiert'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyNb extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Logg inn på ${service}'; @override String get body => 'Skann denne QR-koden eller åpne URL-en på en enhet.'; @override String openToSignIn({required Object service}) => 'Åpne ${service} for å logge inn'; + @override String get copyUrl => 'Kopier URL for pålogging'; @override String get urlCopied => 'URL kopiert'; } @@ -2318,6 +2339,7 @@ extension on TranslationsNb { 'settings.creditsPattern' => 'Rulletekstmarkørmønster', 'settings.creditsPatternDescription' => 'Regulært uttrykk for å gjenkjenne rulletekstmarkører i kapitteltitler', 'settings.invalidRegex' => 'Ugyldig regulært uttrykk', + 'settings.regex' => 'Regulært uttrykk', 'settings.downloads' => 'Nedlastinger', 'settings.downloadLocationDescription' => 'Velg hvor nedlastet innhold skal lagres', 'settings.downloadLocationDefault' => 'Standard (App-lagring)', @@ -2423,6 +2445,8 @@ extension on TranslationsNb { 'hotkeys.clearShortcut' => 'Fjern snarvei', 'hotkeys.noShortcutSet' => 'Ingen snarvei satt', 'hotkeys.currentShortcut' => 'Gjeldende snarvei:', + 'hotkeys.pressToRecord' => 'Velg for å registrere en snarvei', + 'hotkeys.recordingShortcut' => 'Trykk på snarveien nå', 'hotkeys.actions.playPause' => 'Spill av/Pause', 'hotkeys.actions.volumeUp' => 'Volum opp', 'hotkeys.actions.volumeDown' => 'Volum ned', @@ -2512,6 +2536,8 @@ extension on TranslationsNb { 'accessibility.saturation' => 'Metning', 'accessibility.brightness' => 'Lysstyrke', 'accessibility.hexColor' => 'Heksadesimal farge', + 'accessibility.expandText' => 'Utvid tekst', + 'accessibility.collapseText' => 'Fold sammen tekst', 'tooltips.shufflePlay' => 'Tilfeldig avspilling', 'tooltips.playTrailer' => 'Spill trailer', 'tooltips.markAsWatched' => 'Merk som sett', @@ -2602,19 +2628,20 @@ extension on TranslationsNb { 'messages.markedAsWatchedOffline' => 'Merket som sett (synkroniseres når tilkoblet)', 'messages.markedAsUnwatchedOffline' => 'Merket som usett (synkroniseres når tilkoblet)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisk fjernet: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nb'))(n, one: 'Fjernet automatisk ${n} sett nedlasting', other: 'Fjernet automatisk ${n} sette nedlastinger', ), 'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se', 'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}', 'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.', 'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.', 'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}', 'messages.errorLoadingSeries' => 'Feil ved lasting av serie', 'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå', 'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgjengelig', 'messages.noProfilesAvailable' => 'Ingen profiler tilgjengelige', 'messages.contactAdminForProfiles' => 'Kontakt serveradministratoren din for å legge til profiler', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Kan ikke fastslå bibliotekseksjonen for dette elementet', 'messages.logsCleared' => 'Logger tømt', 'messages.logsCopied' => 'Logger kopiert til utklippstavle', @@ -2653,6 +2680,11 @@ extension on TranslationsNb { 'subtitlingStyling.backgroundColor' => 'Bakgrunnsfarge', 'subtitlingStyling.position' => 'Posisjon', 'subtitlingStyling.assOverride' => 'ASS-overstyring', + 'subtitlingStyling.overrideScale' => 'Skaler', + 'subtitlingStyling.overrideForce' => 'Tving', + 'subtitlingStyling.overrideStrip' => 'Fjern formatering', + 'subtitlingStyling.positionTop' => 'Øverst', + 'subtitlingStyling.positionBottom' => 'Nederst', 'subtitlingStyling.bold' => 'Fet', 'subtitlingStyling.italic' => 'Kursiv', 'subtitlingStyling.renderResolution' => 'Gjengivelsesoppløsning', @@ -2766,7 +2798,7 @@ extension on TranslationsNb { 'errors.searchFailed' => ({required Object error}) => 'Søk mislyktes: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tidsavbrudd ved lasting av ${context}', 'errors.connectionFailed' => 'Kan ikke koble til medieserver', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kunne ikke laste ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Kunne ikke laste ${context}. Prøv igjen.', 'errors.noClientAvailable' => 'Ingen klient tilgjengelig', 'errors.pleaseEnterToken' => 'Vennligst skriv inn et token', 'errors.invalidToken' => 'Ugyldig token', @@ -2987,7 +3019,6 @@ extension on TranslationsNb { 'collections.deleted' => 'Samling slettet', 'collections.deleteFailed' => 'Kunne ikke slette samling', 'collections.deleteFailedWithError' => ({required Object error}) => 'Kunne ikke slette samling: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Kunne ikke laste samlingselementer: ${error}', 'collections.selectCollection' => 'Velg samling', 'collections.collectionName' => 'Samlingsnavn', 'collections.enterCollectionName' => 'Skriv inn samlingsnavn', @@ -3061,6 +3092,8 @@ extension on TranslationsNb { 'watchTogether.hostingSession' => 'Er vert for økt', 'watchTogether.inSession' => 'I økt', 'watchTogether.sessionCode' => 'Øktkode', + 'watchTogether.openSessionControls' => 'Åpne øktkontroller for Se sammen', + 'watchTogether.copySessionCode' => 'Kopier øktkode', 'watchTogether.hostControlsPlayback' => 'Verten kontrollerer avspilling', 'watchTogether.anyoneCanControl' => 'Alle kan kontrollere avspilling', 'watchTogether.hostControls' => 'Vertskontroll', @@ -3115,6 +3148,8 @@ extension on TranslationsNb { 'downloads.manage' => 'Administrer', 'downloads.tvShows' => 'TV-serier', 'downloads.movies' => 'Filmer', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musikk', 'downloads.tracksQueued' => ({required Object count}) => '${count} spor i nedlastingskø', 'downloads.noDownloads' => 'Ingen nedlastinger ennå', @@ -3127,8 +3162,6 @@ extension on TranslationsNb { 'downloads.serverErrorBitrate' => 'Serverfeil: filen kan overskride grensen for ekstern bitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i nedlastingskø', 'downloads.downloadDeleted' => 'Nedlasting slettet', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Slette "${title}" fra denne enheten?', 'downloads.cancelledDownloadTitle' => 'Avbrutt nedlasting', 'downloads.cancelledDownloadMessage' => 'Denne nedlastingen ble avbrutt. Hva vil du gjøre?', @@ -3260,6 +3293,8 @@ extension on TranslationsNb { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Tilkobling mistet etter ${attempts} forsøk', 'companionRemote.errors.connectionLost' => 'Tilkobling mistet', 'videoSettings.playbackSpeed' => 'Avspillingshastighet', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Aktiv (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Søvntimer', 'videoSettings.audioSync' => 'Lydsynkronisering', @@ -3351,6 +3386,8 @@ extension on TranslationsNb { 'metadataEdit.artworkUpdated' => 'Kunstverk oppdatert', 'metadataEdit.artworkUpdateFailed' => 'Kunne ikke oppdatere kunstverk', 'metadataEdit.noArtworkAvailable' => 'Ingen kunstverk tilgjengelig', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Alternativ for kunstverk ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Alternativ for kunstverk ${index}, valgt', 'metadataEdit.notSet' => 'Ikke angitt', 'metadataEdit.libraryDefault' => 'Bibliotekstandard', 'metadataEdit.accountDefault' => 'Kontostandard', @@ -3465,11 +3502,13 @@ extension on TranslationsNb { 'services.deviceCode.title' => ({required Object service}) => 'Aktiver Plezy på ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Besøk ${url} og skriv inn denne koden:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Åpne ${service} for å aktivere', + 'services.deviceCode.copyCode' => 'Kopier aktiveringskode', 'services.deviceCode.waitingForAuthorization' => 'Venter på godkjenning…', 'services.deviceCode.codeCopied' => 'Kode kopiert', 'services.oauthProxy.title' => ({required Object service}) => 'Logg inn på ${service}', 'services.oauthProxy.body' => 'Skann denne QR-koden eller åpne URL-en på en enhet.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Åpne ${service} for å logge inn', + 'services.oauthProxy.copyUrl' => 'Kopier URL for pålogging', 'services.oauthProxy.urlCopied' => 'URL kopiert', 'services.libraryFilter.title' => 'Biblioteksfilter', 'services.libraryFilter.subtitleAllSyncing' => 'Synkroniserer alle biblioteker', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 26752419..ffb19f61 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsNl extends TranslationsSettingsEn { @override String get creditsPattern => 'Aftitelingmarkeringspatroon'; @override String get creditsPatternDescription => 'Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen'; @override String get invalidRegex => 'Ongeldige reguliere expressie'; + @override String get regex => 'Reguliere expressie'; @override String get downloads => 'Downloads'; @override String get downloadLocationDescription => 'Kies waar gedownloade content wordt opgeslagen'; @override String get downloadLocationDefault => 'Standaard (App-opslag)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysNl extends TranslationsHotkeysEn { @override String get clearShortcut => 'Wis sneltoets'; @override String get noShortcutSet => 'Geen sneltoets ingesteld'; @override String get currentShortcut => 'Huidige sneltoets:'; + @override String get pressToRecord => 'Selecteer om een sneltoets op te nemen'; + @override String get recordingShortcut => 'Druk nu op de sneltoets'; @override late final _TranslationsHotkeysActionsNl actions = _TranslationsHotkeysActionsNl._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityNl extends TranslationsAccessibilityEn { @override String get saturation => 'Verzadiging'; @override String get brightness => 'Helderheid'; @override String get hexColor => 'Hexkleur'; + @override String get expandText => 'Tekst uitvouwen'; + @override String get collapseText => 'Tekst samenvouwen'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesNl extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Gemarkeerd als gekeken (sync wanneer online)'; @override String get markedAsUnwatchedOffline => 'Gemarkeerd als ongekeken (sync wanneer online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatisch verwijderd: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, + one: 'Automatisch ${n} bekeken download verwijderd', + other: 'Automatisch ${n} bekeken downloads verwijderd', + ); @override String get removedFromContinueWatching => 'Verwijderd uit Doorgaan met kijken'; @override String errorLoading({required Object error}) => 'Fout: ${error}'; @override String get streamInterrupted => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingNl extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Achtergrondkleur'; @override String get position => 'Positie'; @override String get assOverride => 'ASS-overschrijving'; + @override String get overrideScale => 'Schalen'; + @override String get overrideForce => 'Forceren'; + @override String get overrideStrip => 'Opmaak verwijderen'; + @override String get positionTop => 'Bovenaan'; + @override String get positionBottom => 'Onderaan'; @override String get bold => 'Vet'; @override String get italic => 'Cursief'; @override String get renderResolution => 'Renderresolutie'; @@ -918,7 +932,7 @@ class _TranslationsErrorsNl extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Zoeken mislukt: ${error}'; @override String connectionTimeout({required Object context}) => 'Verbinding time-out tijdens laden ${context}'; @override String get connectionFailed => 'Kan geen verbinding maken met mediaserver'; - @override String failedToLoad({required Object context, required Object error}) => 'Kon ${context} niet laden: ${error}'; + @override String unableToLoad({required Object context}) => 'Kan ${context} niet laden. Probeer het opnieuw.'; @override String get noClientAvailable => 'Geen client beschikbaar'; @override String get pleaseEnterToken => 'Voer een token in'; @override String get invalidToken => 'Ongeldig token'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsNl extends TranslationsCollectionsEn { @override String get deleted => 'Collectie verwijderd'; @override String get deleteFailed => 'Collectie verwijderen mislukt'; @override String deleteFailedWithError({required Object error}) => 'Collectie verwijderen mislukt: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Collectie-items laden mislukt: ${error}'; @override String get selectCollection => 'Selecteer collectie'; @override String get collectionName => 'Collectienaam'; @override String get enterCollectionName => 'Voer collectienaam in'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherNl extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Sessie Hosten'; @override String get inSession => 'In Sessie'; @override String get sessionCode => 'Sessiecode'; + @override String get openSessionControls => 'Sessiebesturing voor Samen Kijken openen'; + @override String get copySessionCode => 'Sessiecode kopiëren'; @override String get hostControlsPlayback => 'Host bedient het afspelen'; @override String get anyoneCanControl => 'Iedereen kan het afspelen bedienen'; @override String get hostControls => 'Host bedient'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsNl extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Afspeelsnelheid'; + @override String get normalSpeed => 'Normaal'; + @override String sleepTimerActive({required Object duration}) => 'Actief (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Slaaptimer'; @override String get audioSync => 'Audio synchronisatie'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditNl extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork bijgewerkt'; @override String get artworkUpdateFailed => 'Artwork bijwerken mislukt'; @override String get noArtworkAvailable => 'Geen artwork beschikbaar'; + @override String artworkOption({required Object index}) => 'Artworkoptie ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Artworkoptie ${index}, geselecteerd'; @override String get notSet => 'Niet ingesteld'; @override String get libraryDefault => 'Bibliotheekstandaard'; @override String get accountDefault => 'Accountstandaard'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeNl extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Plezy activeren op ${service}'; @override String body({required Object url}) => 'Ga naar ${url} en voer deze code in:'; @override String openToActivate({required Object service}) => 'Open ${service} om te activeren'; + @override String get copyCode => 'Activeringscode kopiëren'; @override String get waitingForAuthorization => 'Wachten op autorisatie…'; @override String get codeCopied => 'Code gekopieerd'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyNl extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Aanmelden bij ${service}'; @override String get body => 'Scan deze QR-code of open de URL op een apparaat.'; @override String openToSignIn({required Object service}) => '${service} openen om aan te melden'; + @override String get copyUrl => 'Aanmeldings-URL kopiëren'; @override String get urlCopied => 'URL gekopieerd'; } @@ -2318,6 +2339,7 @@ extension on TranslationsNl { 'settings.creditsPattern' => 'Aftitelingmarkeringspatroon', 'settings.creditsPatternDescription' => 'Reguliere expressie om aftitelingmarkeringen in hoofdstuktitels te herkennen', 'settings.invalidRegex' => 'Ongeldige reguliere expressie', + 'settings.regex' => 'Reguliere expressie', 'settings.downloads' => 'Downloads', 'settings.downloadLocationDescription' => 'Kies waar gedownloade content wordt opgeslagen', 'settings.downloadLocationDefault' => 'Standaard (App-opslag)', @@ -2423,6 +2445,8 @@ extension on TranslationsNl { 'hotkeys.clearShortcut' => 'Wis sneltoets', 'hotkeys.noShortcutSet' => 'Geen sneltoets ingesteld', 'hotkeys.currentShortcut' => 'Huidige sneltoets:', + 'hotkeys.pressToRecord' => 'Selecteer om een sneltoets op te nemen', + 'hotkeys.recordingShortcut' => 'Druk nu op de sneltoets', 'hotkeys.actions.playPause' => 'Afspelen/Pauzeren', 'hotkeys.actions.volumeUp' => 'Volume omhoog', 'hotkeys.actions.volumeDown' => 'Volume omlaag', @@ -2512,6 +2536,8 @@ extension on TranslationsNl { 'accessibility.saturation' => 'Verzadiging', 'accessibility.brightness' => 'Helderheid', 'accessibility.hexColor' => 'Hexkleur', + 'accessibility.expandText' => 'Tekst uitvouwen', + 'accessibility.collapseText' => 'Tekst samenvouwen', 'tooltips.shufflePlay' => 'Willekeurig afspelen', 'tooltips.playTrailer' => 'Trailer afspelen', 'tooltips.markAsWatched' => 'Markeer als gekeken', @@ -2602,19 +2628,20 @@ extension on TranslationsNl { 'messages.markedAsWatchedOffline' => 'Gemarkeerd als gekeken (sync wanneer online)', 'messages.markedAsUnwatchedOffline' => 'Gemarkeerd als ongekeken (sync wanneer online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatisch verwijderd: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('nl'))(n, one: 'Automatisch ${n} bekeken download verwijderd', other: 'Automatisch ${n} bekeken downloads verwijderd', ), 'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken', 'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}', 'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.', 'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.', 'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}', 'messages.errorLoadingSeries' => 'Fout bij laden serie', 'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund', 'messages.noDescriptionAvailable' => 'Geen beschrijving beschikbaar', 'messages.noProfilesAvailable' => 'Geen profielen beschikbaar', 'messages.contactAdminForProfiles' => 'Neem contact op met je serverbeheerder om profielen toe te voegen', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Kan bibliotheeksectie voor dit item niet bepalen', 'messages.logsCleared' => 'Logs gewist', 'messages.logsCopied' => 'Logs gekopieerd naar klembord', @@ -2653,6 +2680,11 @@ extension on TranslationsNl { 'subtitlingStyling.backgroundColor' => 'Achtergrondkleur', 'subtitlingStyling.position' => 'Positie', 'subtitlingStyling.assOverride' => 'ASS-overschrijving', + 'subtitlingStyling.overrideScale' => 'Schalen', + 'subtitlingStyling.overrideForce' => 'Forceren', + 'subtitlingStyling.overrideStrip' => 'Opmaak verwijderen', + 'subtitlingStyling.positionTop' => 'Bovenaan', + 'subtitlingStyling.positionBottom' => 'Onderaan', 'subtitlingStyling.bold' => 'Vet', 'subtitlingStyling.italic' => 'Cursief', 'subtitlingStyling.renderResolution' => 'Renderresolutie', @@ -2766,7 +2798,7 @@ extension on TranslationsNl { 'errors.searchFailed' => ({required Object error}) => 'Zoeken mislukt: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Verbinding time-out tijdens laden ${context}', 'errors.connectionFailed' => 'Kan geen verbinding maken met mediaserver', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Kon ${context} niet laden: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Kan ${context} niet laden. Probeer het opnieuw.', 'errors.noClientAvailable' => 'Geen client beschikbaar', 'errors.pleaseEnterToken' => 'Voer een token in', 'errors.invalidToken' => 'Ongeldig token', @@ -2987,7 +3019,6 @@ extension on TranslationsNl { 'collections.deleted' => 'Collectie verwijderd', 'collections.deleteFailed' => 'Collectie verwijderen mislukt', 'collections.deleteFailedWithError' => ({required Object error}) => 'Collectie verwijderen mislukt: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Collectie-items laden mislukt: ${error}', 'collections.selectCollection' => 'Selecteer collectie', 'collections.collectionName' => 'Collectienaam', 'collections.enterCollectionName' => 'Voer collectienaam in', @@ -3061,6 +3092,8 @@ extension on TranslationsNl { 'watchTogether.hostingSession' => 'Sessie Hosten', 'watchTogether.inSession' => 'In Sessie', 'watchTogether.sessionCode' => 'Sessiecode', + 'watchTogether.openSessionControls' => 'Sessiebesturing voor Samen Kijken openen', + 'watchTogether.copySessionCode' => 'Sessiecode kopiëren', 'watchTogether.hostControlsPlayback' => 'Host bedient het afspelen', 'watchTogether.anyoneCanControl' => 'Iedereen kan het afspelen bedienen', 'watchTogether.hostControls' => 'Host bedient', @@ -3115,6 +3148,8 @@ extension on TranslationsNl { 'downloads.manage' => 'Beheren', 'downloads.tvShows' => 'Series', 'downloads.movies' => 'Films', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Muziek', 'downloads.tracksQueued' => ({required Object count}) => '${count} nummers in wachtrij voor download', 'downloads.noDownloads' => 'Nog geen downloads', @@ -3127,8 +3162,6 @@ extension on TranslationsNl { 'downloads.serverErrorBitrate' => 'Serverfout: bestand overschrijdt mogelijk de externe bitrate-limiet', 'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download', 'downloads.downloadDeleted' => 'Download verwijderd', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => '"${title}" van dit apparaat verwijderen?', 'downloads.cancelledDownloadTitle' => 'Geannuleerde download', 'downloads.cancelledDownloadMessage' => 'Deze download is geannuleerd. Wat wil je doen?', @@ -3260,6 +3293,8 @@ extension on TranslationsNl { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Verbinding verloren na ${attempts} pogingen', 'companionRemote.errors.connectionLost' => 'Verbinding verloren', 'videoSettings.playbackSpeed' => 'Afspeelsnelheid', + 'videoSettings.normalSpeed' => 'Normaal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Actief (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Slaaptimer', 'videoSettings.audioSync' => 'Audio synchronisatie', @@ -3351,6 +3386,8 @@ extension on TranslationsNl { 'metadataEdit.artworkUpdated' => 'Artwork bijgewerkt', 'metadataEdit.artworkUpdateFailed' => 'Artwork bijwerken mislukt', 'metadataEdit.noArtworkAvailable' => 'Geen artwork beschikbaar', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Artworkoptie ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artworkoptie ${index}, geselecteerd', 'metadataEdit.notSet' => 'Niet ingesteld', 'metadataEdit.libraryDefault' => 'Bibliotheekstandaard', 'metadataEdit.accountDefault' => 'Accountstandaard', @@ -3465,11 +3502,13 @@ extension on TranslationsNl { 'services.deviceCode.title' => ({required Object service}) => 'Plezy activeren op ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Ga naar ${url} en voer deze code in:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} om te activeren', + 'services.deviceCode.copyCode' => 'Activeringscode kopiëren', 'services.deviceCode.waitingForAuthorization' => 'Wachten op autorisatie…', 'services.deviceCode.codeCopied' => 'Code gekopieerd', 'services.oauthProxy.title' => ({required Object service}) => 'Aanmelden bij ${service}', 'services.oauthProxy.body' => 'Scan deze QR-code of open de URL op een apparaat.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => '${service} openen om aan te melden', + 'services.oauthProxy.copyUrl' => 'Aanmeldings-URL kopiëren', 'services.oauthProxy.urlCopied' => 'URL gekopieerd', 'services.libraryFilter.title' => 'Bibliotheekfilter', 'services.libraryFilter.subtitleAllSyncing' => 'Alle bibliotheken synchroniseren', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 3695e16a..e258a089 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsPl extends TranslationsSettingsEn { @override String get creditsPattern => 'Wzorzec znacznika napisów końcowych'; @override String get creditsPatternDescription => 'Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów'; @override String get invalidRegex => 'Nieprawidłowe wyrażenie regularne'; + @override String get regex => 'Wyrażenie regularne'; @override String get downloads => 'Pobrania'; @override String get downloadLocationDescription => 'Wybierz miejsce przechowywania pobranych treści'; @override String get downloadLocationDefault => 'Domyślne (Pamięć aplikacji)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysPl extends TranslationsHotkeysEn { @override String get clearShortcut => 'Wyczyść skrót'; @override String get noShortcutSet => 'Brak ustawionego skrótu'; @override String get currentShortcut => 'Bieżący skrót:'; + @override String get pressToRecord => 'Wybierz, aby zapisać skrót klawiszowy'; + @override String get recordingShortcut => 'Naciśnij teraz skrót klawiszowy'; @override late final _TranslationsHotkeysActionsPl actions = _TranslationsHotkeysActionsPl._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityPl extends TranslationsAccessibilityEn { @override String get saturation => 'Nasycenie'; @override String get brightness => 'Jasność'; @override String get hexColor => 'Kolor szesnastkowy'; + @override String get expandText => 'Rozwiń tekst'; + @override String get collapseText => 'Zwiń tekst'; } // Path: tooltips @@ -693,6 +698,12 @@ class _TranslationsMessagesPl extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Oznaczono jako obejrzane (zsynchronizuje się po połączeniu)'; @override String get markedAsUnwatchedOffline => 'Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatycznie usunięto: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, + one: 'Automatycznie usunięto ${n} obejrzane pobranie', + few: 'Automatycznie usunięto ${n} obejrzane pobrania', + many: 'Automatycznie usunięto ${n} obejrzanych pobrań', + other: 'Automatycznie usunięto ${n} obejrzanego pobrania', + ); @override String get removedFromContinueWatching => 'Usunięto z kontynuowania oglądania'; @override String errorLoading({required Object error}) => 'Błąd: ${error}'; @override String get streamInterrupted => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.'; @@ -751,6 +762,11 @@ class _TranslationsSubtitlingStylingPl extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Kolor tła'; @override String get position => 'Pozycja'; @override String get assOverride => 'Nadpisywanie ASS'; + @override String get overrideScale => 'Skaluj'; + @override String get overrideForce => 'Wymuś'; + @override String get overrideStrip => 'Usuń style'; + @override String get positionTop => 'Góra'; + @override String get positionBottom => 'Dół'; @override String get bold => 'Pogrubienie'; @override String get italic => 'Kursywa'; @override String get renderResolution => 'Rozdzielczość renderowania'; @@ -918,7 +934,7 @@ class _TranslationsErrorsPl extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Wyszukiwanie nie powiodło się: ${error}'; @override String connectionTimeout({required Object context}) => 'Limit czasu połączenia przy ładowaniu ${context}'; @override String get connectionFailed => 'Nie można połączyć się z serwerem multimediów'; - @override String failedToLoad({required Object context, required Object error}) => 'Nie udało się załadować ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Nie udało się załadować ${context}. Spróbuj ponownie.'; @override String get noClientAvailable => 'Brak dostępnego klienta'; @override String get pleaseEnterToken => 'Wprowadź token'; @override String get invalidToken => 'Nieprawidłowy token'; @@ -1182,7 +1198,6 @@ class _TranslationsCollectionsPl extends TranslationsCollectionsEn { @override String get deleted => 'Kolekcja usunięta'; @override String get deleteFailed => 'Nie udało się usunąć kolekcji'; @override String deleteFailedWithError({required Object error}) => 'Nie udało się usunąć kolekcji: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Nie udało się załadować elementów kolekcji: ${error}'; @override String get selectCollection => 'Wybierz kolekcję'; @override String get collectionName => 'Nazwa kolekcji'; @override String get enterCollectionName => 'Wprowadź nazwę kolekcji'; @@ -1288,6 +1303,8 @@ class _TranslationsWatchTogetherPl extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Hostowanie sesji'; @override String get inSession => 'W sesji'; @override String get sessionCode => 'Kod sesji'; + @override String get openSessionControls => 'Otwórz sterowanie sesją „Oglądaj razem”'; + @override String get copySessionCode => 'Skopiuj kod sesji'; @override String get hostControlsPlayback => 'Host kontroluje odtwarzanie'; @override String get anyoneCanControl => 'Każdy może kontrolować odtwarzanie'; @override String get hostControls => 'Kontrola hosta'; @@ -1464,6 +1481,8 @@ class _TranslationsVideoSettingsPl extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Prędkość odtwarzania'; + @override String get normalSpeed => 'Normalna'; + @override String sleepTimerActive({required Object duration}) => 'Aktywny (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Wyłącznik czasowy'; @override String get audioSync => 'Synchronizacja audio'; @@ -1582,6 +1601,8 @@ class _TranslationsMetadataEditPl extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Grafika zaktualizowana'; @override String get artworkUpdateFailed => 'Nie udało się zaktualizować grafiki'; @override String get noArtworkAvailable => 'Brak dostępnej grafiki'; + @override String artworkOption({required Object index}) => 'Opcja grafiki ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Opcja grafiki ${index}, wybrana'; @override String get notSet => 'Nie ustawiono'; @override String get libraryDefault => 'Domyślne biblioteki'; @override String get accountDefault => 'Domyślne konta'; @@ -2059,6 +2080,7 @@ class _TranslationsServicesDeviceCodePl extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Aktywuj Plezy w ${service}'; @override String body({required Object url}) => 'Odwiedź ${url} i wpisz ten kod:'; @override String openToActivate({required Object service}) => 'Otwórz ${service}, aby aktywować'; + @override String get copyCode => 'Skopiuj kod aktywacyjny'; @override String get waitingForAuthorization => 'Oczekiwanie na autoryzację…'; @override String get codeCopied => 'Kod skopiowany'; } @@ -2073,6 +2095,7 @@ class _TranslationsServicesOauthProxyPl extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Zaloguj się do ${service}'; @override String get body => 'Zeskanuj ten kod QR lub otwórz URL na dowolnym urządzeniu.'; @override String openToSignIn({required Object service}) => 'Otwórz ${service}, aby się zalogować'; + @override String get copyUrl => 'Skopiuj adres URL logowania'; @override String get urlCopied => 'URL skopiowany'; } @@ -2322,6 +2345,7 @@ extension on TranslationsPl { 'settings.creditsPattern' => 'Wzorzec znacznika napisów końcowych', 'settings.creditsPatternDescription' => 'Wyrażenie regularne do rozpoznawania znaczników napisów końcowych w tytułach rozdziałów', 'settings.invalidRegex' => 'Nieprawidłowe wyrażenie regularne', + 'settings.regex' => 'Wyrażenie regularne', 'settings.downloads' => 'Pobrania', 'settings.downloadLocationDescription' => 'Wybierz miejsce przechowywania pobranych treści', 'settings.downloadLocationDefault' => 'Domyślne (Pamięć aplikacji)', @@ -2427,6 +2451,8 @@ extension on TranslationsPl { 'hotkeys.clearShortcut' => 'Wyczyść skrót', 'hotkeys.noShortcutSet' => 'Brak ustawionego skrótu', 'hotkeys.currentShortcut' => 'Bieżący skrót:', + 'hotkeys.pressToRecord' => 'Wybierz, aby zapisać skrót klawiszowy', + 'hotkeys.recordingShortcut' => 'Naciśnij teraz skrót klawiszowy', 'hotkeys.actions.playPause' => 'Odtwórz/Pauza', 'hotkeys.actions.volumeUp' => 'Głośniej', 'hotkeys.actions.volumeDown' => 'Ciszej', @@ -2516,6 +2542,8 @@ extension on TranslationsPl { 'accessibility.saturation' => 'Nasycenie', 'accessibility.brightness' => 'Jasność', 'accessibility.hexColor' => 'Kolor szesnastkowy', + 'accessibility.expandText' => 'Rozwiń tekst', + 'accessibility.collapseText' => 'Zwiń tekst', 'tooltips.shufflePlay' => 'Odtwarzanie losowe', 'tooltips.playTrailer' => 'Odtwórz zwiastun', 'tooltips.markAsWatched' => 'Oznacz jako obejrzane', @@ -2606,19 +2634,20 @@ extension on TranslationsPl { 'messages.markedAsWatchedOffline' => 'Oznaczono jako obejrzane (zsynchronizuje się po połączeniu)', 'messages.markedAsUnwatchedOffline' => 'Oznaczono jako nieobejrzane (zsynchronizuje się po połączeniu)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatycznie usunięto: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pl'))(n, one: 'Automatycznie usunięto ${n} obejrzane pobranie', few: 'Automatycznie usunięto ${n} obejrzane pobrania', many: 'Automatycznie usunięto ${n} obejrzanych pobrań', other: 'Automatycznie usunięto ${n} obejrzanego pobrania', ), 'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania', 'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}', 'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.', 'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.', 'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}', 'messages.errorLoadingSeries' => 'Błąd ładowania serialu', 'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane', 'messages.noDescriptionAvailable' => 'Brak dostępnego opisu', 'messages.noProfilesAvailable' => 'Brak dostępnych profili', 'messages.contactAdminForProfiles' => 'Skontaktuj się z administratorem serwera, aby dodać profile', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Nie można określić sekcji biblioteki dla tego elementu', 'messages.logsCleared' => 'Logi wyczyszczone', 'messages.logsCopied' => 'Logi skopiowane do schowka', @@ -2657,6 +2686,11 @@ extension on TranslationsPl { 'subtitlingStyling.backgroundColor' => 'Kolor tła', 'subtitlingStyling.position' => 'Pozycja', 'subtitlingStyling.assOverride' => 'Nadpisywanie ASS', + 'subtitlingStyling.overrideScale' => 'Skaluj', + 'subtitlingStyling.overrideForce' => 'Wymuś', + 'subtitlingStyling.overrideStrip' => 'Usuń style', + 'subtitlingStyling.positionTop' => 'Góra', + 'subtitlingStyling.positionBottom' => 'Dół', 'subtitlingStyling.bold' => 'Pogrubienie', 'subtitlingStyling.italic' => 'Kursywa', 'subtitlingStyling.renderResolution' => 'Rozdzielczość renderowania', @@ -2770,7 +2804,7 @@ extension on TranslationsPl { 'errors.searchFailed' => ({required Object error}) => 'Wyszukiwanie nie powiodło się: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Limit czasu połączenia przy ładowaniu ${context}', 'errors.connectionFailed' => 'Nie można połączyć się z serwerem multimediów', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Nie udało się załadować ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Nie udało się załadować ${context}. Spróbuj ponownie.', 'errors.noClientAvailable' => 'Brak dostępnego klienta', 'errors.pleaseEnterToken' => 'Wprowadź token', 'errors.invalidToken' => 'Nieprawidłowy token', @@ -2991,7 +3025,6 @@ extension on TranslationsPl { 'collections.deleted' => 'Kolekcja usunięta', 'collections.deleteFailed' => 'Nie udało się usunąć kolekcji', 'collections.deleteFailedWithError' => ({required Object error}) => 'Nie udało się usunąć kolekcji: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Nie udało się załadować elementów kolekcji: ${error}', 'collections.selectCollection' => 'Wybierz kolekcję', 'collections.collectionName' => 'Nazwa kolekcji', 'collections.enterCollectionName' => 'Wprowadź nazwę kolekcji', @@ -3065,6 +3098,8 @@ extension on TranslationsPl { 'watchTogether.hostingSession' => 'Hostowanie sesji', 'watchTogether.inSession' => 'W sesji', 'watchTogether.sessionCode' => 'Kod sesji', + 'watchTogether.openSessionControls' => 'Otwórz sterowanie sesją „Oglądaj razem”', + 'watchTogether.copySessionCode' => 'Skopiuj kod sesji', 'watchTogether.hostControlsPlayback' => 'Host kontroluje odtwarzanie', 'watchTogether.anyoneCanControl' => 'Każdy może kontrolować odtwarzanie', 'watchTogether.hostControls' => 'Kontrola hosta', @@ -3119,6 +3154,8 @@ extension on TranslationsPl { 'downloads.manage' => 'Zarządzaj', 'downloads.tvShows' => 'Seriale TV', 'downloads.movies' => 'Filmy', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Muzyka', 'downloads.tracksQueued' => ({required Object count}) => '${count} utworów w kolejce do pobrania', 'downloads.noDownloads' => 'Brak pobrań', @@ -3131,8 +3168,6 @@ extension on TranslationsPl { 'downloads.serverErrorBitrate' => 'Błąd serwera: plik może przekraczać zdalny limit bitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} odcinków w kolejce pobierania', 'downloads.downloadDeleted' => 'Pobranie usunięte', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Usunąć "${title}" z tego urządzenia?', 'downloads.cancelledDownloadTitle' => 'Anulowane pobieranie', 'downloads.cancelledDownloadMessage' => 'To pobieranie zostało anulowane. Co chcesz zrobić?', @@ -3264,6 +3299,8 @@ extension on TranslationsPl { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Połączenie utracone po ${attempts} próbach', 'companionRemote.errors.connectionLost' => 'Połączenie utracone', 'videoSettings.playbackSpeed' => 'Prędkość odtwarzania', + 'videoSettings.normalSpeed' => 'Normalna', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Aktywny (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Wyłącznik czasowy', 'videoSettings.audioSync' => 'Synchronizacja audio', @@ -3355,6 +3392,8 @@ extension on TranslationsPl { 'metadataEdit.artworkUpdated' => 'Grafika zaktualizowana', 'metadataEdit.artworkUpdateFailed' => 'Nie udało się zaktualizować grafiki', 'metadataEdit.noArtworkAvailable' => 'Brak dostępnej grafiki', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Opcja grafiki ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Opcja grafiki ${index}, wybrana', 'metadataEdit.notSet' => 'Nie ustawiono', 'metadataEdit.libraryDefault' => 'Domyślne biblioteki', 'metadataEdit.accountDefault' => 'Domyślne konta', @@ -3469,11 +3508,13 @@ extension on TranslationsPl { 'services.deviceCode.title' => ({required Object service}) => 'Aktywuj Plezy w ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Odwiedź ${url} i wpisz ten kod:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Otwórz ${service}, aby aktywować', + 'services.deviceCode.copyCode' => 'Skopiuj kod aktywacyjny', 'services.deviceCode.waitingForAuthorization' => 'Oczekiwanie na autoryzację…', 'services.deviceCode.codeCopied' => 'Kod skopiowany', 'services.oauthProxy.title' => ({required Object service}) => 'Zaloguj się do ${service}', 'services.oauthProxy.body' => 'Zeskanuj ten kod QR lub otwórz URL na dowolnym urządzeniu.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Otwórz ${service}, aby się zalogować', + 'services.oauthProxy.copyUrl' => 'Skopiuj adres URL logowania', 'services.oauthProxy.urlCopied' => 'URL skopiowany', 'services.libraryFilter.title' => 'Filtr bibliotek', 'services.libraryFilter.subtitleAllSyncing' => 'Synchronizowanie wszystkich bibliotek', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 705b6028..26026ea7 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsPt extends TranslationsSettingsEn { @override String get creditsPattern => 'Padrão de marcador de créditos'; @override String get creditsPatternDescription => 'Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos'; @override String get invalidRegex => 'Expressão regular inválida'; + @override String get regex => 'Expressão regular'; @override String get downloads => 'Downloads'; @override String get downloadLocationDescription => 'Escolha onde armazenar conteúdo baixado'; @override String get downloadLocationDefault => 'Padrão (Armazenamento do App)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysPt extends TranslationsHotkeysEn { @override String get clearShortcut => 'Limpar atalho'; @override String get noShortcutSet => 'Nenhum atalho definido'; @override String get currentShortcut => 'Atalho atual:'; + @override String get pressToRecord => 'Selecionar para gravar um atalho'; + @override String get recordingShortcut => 'Pressione o atalho agora'; @override late final _TranslationsHotkeysActionsPt actions = _TranslationsHotkeysActionsPt._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityPt extends TranslationsAccessibilityEn { @override String get saturation => 'Saturação'; @override String get brightness => 'Brilho'; @override String get hexColor => 'Cor hexadecimal'; + @override String get expandText => 'Expandir texto'; + @override String get collapseText => 'Recolher texto'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesPt extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Marcado como assistido (será sincronizado quando online)'; @override String get markedAsUnwatchedOffline => 'Marcado como não assistido (será sincronizado quando online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Removido automaticamente: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, + one: 'Removido automaticamente ${n} download assistido', + other: 'Removidos automaticamente ${n} downloads assistidos', + ); @override String get removedFromContinueWatching => 'Removido de Continuar Assistindo'; @override String errorLoading({required Object error}) => 'Erro: ${error}'; @override String get streamInterrupted => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingPt extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Cor de Fundo'; @override String get position => 'Posição'; @override String get assOverride => 'Substituição ASS'; + @override String get overrideScale => 'Dimensionar'; + @override String get overrideForce => 'Forçar'; + @override String get overrideStrip => 'Remover estilo'; + @override String get positionTop => 'Superior'; + @override String get positionBottom => 'Inferior'; @override String get bold => 'Negrito'; @override String get italic => 'Itálico'; @override String get renderResolution => 'Resolução de renderização'; @@ -918,7 +932,7 @@ class _TranslationsErrorsPt extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Falha na busca: ${error}'; @override String connectionTimeout({required Object context}) => 'Tempo de conexão esgotado ao carregar ${context}'; @override String get connectionFailed => 'Não foi possível conectar ao servidor de mídia'; - @override String failedToLoad({required Object context, required Object error}) => 'Falha ao carregar ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Não foi possível carregar ${context}. Tente novamente.'; @override String get noClientAvailable => 'Nenhum cliente disponível'; @override String get pleaseEnterToken => 'Insira um token'; @override String get invalidToken => 'Token inválido'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsPt extends TranslationsCollectionsEn { @override String get deleted => 'Coleção excluída'; @override String get deleteFailed => 'Falha ao excluir coleção'; @override String deleteFailedWithError({required Object error}) => 'Falha ao excluir coleção: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Falha ao carregar itens da coleção: ${error}'; @override String get selectCollection => 'Selecionar Coleção'; @override String get collectionName => 'Nome da Coleção'; @override String get enterCollectionName => 'Insira o nome da coleção'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherPt extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Hospedando Sessão'; @override String get inSession => 'Em Sessão'; @override String get sessionCode => 'Código da Sessão'; + @override String get openSessionControls => 'Abrir os controles da sessão Assistir Juntos'; + @override String get copySessionCode => 'Copiar código da sessão'; @override String get hostControlsPlayback => 'Anfitrião controla a reprodução'; @override String get anyoneCanControl => 'Qualquer pessoa pode controlar a reprodução'; @override String get hostControls => 'Controle do anfitrião'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsPt extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Velocidade de Reprodução'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Ativo (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Timer de Sono'; @override String get audioSync => 'Sincronia de Áudio'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditPt extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Arte atualizada'; @override String get artworkUpdateFailed => 'Falha ao atualizar arte'; @override String get noArtworkAvailable => 'Nenhuma arte disponível'; + @override String artworkOption({required Object index}) => 'Opção de arte ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Opção de arte ${index}, selecionada'; @override String get notSet => 'Não definido'; @override String get libraryDefault => 'Padrão da biblioteca'; @override String get accountDefault => 'Padrão da conta'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodePt extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Ativar Plezy no ${service}'; @override String body({required Object url}) => 'Acesse ${url} e insira este código:'; @override String openToActivate({required Object service}) => 'Abrir ${service} para ativar'; + @override String get copyCode => 'Copiar código de ativação'; @override String get waitingForAuthorization => 'Aguardando autorização…'; @override String get codeCopied => 'Código copiado'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxyPt extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Entrar no ${service}'; @override String get body => 'Escaneie este código QR ou abra a URL em qualquer dispositivo.'; @override String openToSignIn({required Object service}) => 'Abrir ${service} para entrar'; + @override String get copyUrl => 'Copiar URL de login'; @override String get urlCopied => 'URL copiado'; } @@ -2318,6 +2339,7 @@ extension on TranslationsPt { 'settings.creditsPattern' => 'Padrão de marcador de créditos', 'settings.creditsPatternDescription' => 'Expressão regular para corresponder marcadores de créditos nos títulos dos capítulos', 'settings.invalidRegex' => 'Expressão regular inválida', + 'settings.regex' => 'Expressão regular', 'settings.downloads' => 'Downloads', 'settings.downloadLocationDescription' => 'Escolha onde armazenar conteúdo baixado', 'settings.downloadLocationDefault' => 'Padrão (Armazenamento do App)', @@ -2423,6 +2445,8 @@ extension on TranslationsPt { 'hotkeys.clearShortcut' => 'Limpar atalho', 'hotkeys.noShortcutSet' => 'Nenhum atalho definido', 'hotkeys.currentShortcut' => 'Atalho atual:', + 'hotkeys.pressToRecord' => 'Selecionar para gravar um atalho', + 'hotkeys.recordingShortcut' => 'Pressione o atalho agora', 'hotkeys.actions.playPause' => 'Reproduzir/Pausar', 'hotkeys.actions.volumeUp' => 'Aumentar Volume', 'hotkeys.actions.volumeDown' => 'Diminuir Volume', @@ -2512,6 +2536,8 @@ extension on TranslationsPt { 'accessibility.saturation' => 'Saturação', 'accessibility.brightness' => 'Brilho', 'accessibility.hexColor' => 'Cor hexadecimal', + 'accessibility.expandText' => 'Expandir texto', + 'accessibility.collapseText' => 'Recolher texto', 'tooltips.shufflePlay' => 'Reprodução aleatória', 'tooltips.playTrailer' => 'Reproduzir trailer', 'tooltips.markAsWatched' => 'Marcar como assistido', @@ -2602,19 +2628,20 @@ extension on TranslationsPt { 'messages.markedAsWatchedOffline' => 'Marcado como assistido (será sincronizado quando online)', 'messages.markedAsUnwatchedOffline' => 'Marcado como não assistido (será sincronizado quando online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Removido automaticamente: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('pt'))(n, one: 'Removido automaticamente ${n} download assistido', other: 'Removidos automaticamente ${n} downloads assistidos', ), 'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo', 'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}', 'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.', 'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.', 'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}', 'messages.errorLoadingSeries' => 'Erro ao carregar série', 'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada', 'messages.noDescriptionAvailable' => 'Nenhuma descrição disponível', 'messages.noProfilesAvailable' => 'Nenhum perfil disponível', 'messages.contactAdminForProfiles' => 'Contate o administrador do servidor para adicionar perfis', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Não é possível determinar a secção da biblioteca para este item', 'messages.logsCleared' => 'Logs limpos', 'messages.logsCopied' => 'Logs copiados para a área de transferência', @@ -2653,6 +2680,11 @@ extension on TranslationsPt { 'subtitlingStyling.backgroundColor' => 'Cor de Fundo', 'subtitlingStyling.position' => 'Posição', 'subtitlingStyling.assOverride' => 'Substituição ASS', + 'subtitlingStyling.overrideScale' => 'Dimensionar', + 'subtitlingStyling.overrideForce' => 'Forçar', + 'subtitlingStyling.overrideStrip' => 'Remover estilo', + 'subtitlingStyling.positionTop' => 'Superior', + 'subtitlingStyling.positionBottom' => 'Inferior', 'subtitlingStyling.bold' => 'Negrito', 'subtitlingStyling.italic' => 'Itálico', 'subtitlingStyling.renderResolution' => 'Resolução de renderização', @@ -2766,7 +2798,7 @@ extension on TranslationsPt { 'errors.searchFailed' => ({required Object error}) => 'Falha na busca: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Tempo de conexão esgotado ao carregar ${context}', 'errors.connectionFailed' => 'Não foi possível conectar ao servidor de mídia', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Falha ao carregar ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Não foi possível carregar ${context}. Tente novamente.', 'errors.noClientAvailable' => 'Nenhum cliente disponível', 'errors.pleaseEnterToken' => 'Insira um token', 'errors.invalidToken' => 'Token inválido', @@ -2987,7 +3019,6 @@ extension on TranslationsPt { 'collections.deleted' => 'Coleção excluída', 'collections.deleteFailed' => 'Falha ao excluir coleção', 'collections.deleteFailedWithError' => ({required Object error}) => 'Falha ao excluir coleção: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Falha ao carregar itens da coleção: ${error}', 'collections.selectCollection' => 'Selecionar Coleção', 'collections.collectionName' => 'Nome da Coleção', 'collections.enterCollectionName' => 'Insira o nome da coleção', @@ -3061,6 +3092,8 @@ extension on TranslationsPt { 'watchTogether.hostingSession' => 'Hospedando Sessão', 'watchTogether.inSession' => 'Em Sessão', 'watchTogether.sessionCode' => 'Código da Sessão', + 'watchTogether.openSessionControls' => 'Abrir os controles da sessão Assistir Juntos', + 'watchTogether.copySessionCode' => 'Copiar código da sessão', 'watchTogether.hostControlsPlayback' => 'Anfitrião controla a reprodução', 'watchTogether.anyoneCanControl' => 'Qualquer pessoa pode controlar a reprodução', 'watchTogether.hostControls' => 'Controle do anfitrião', @@ -3115,6 +3148,8 @@ extension on TranslationsPt { 'downloads.manage' => 'Gerenciar', 'downloads.tvShows' => 'Séries de TV', 'downloads.movies' => 'Filmes', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Música', 'downloads.tracksQueued' => ({required Object count}) => '${count} faixas na fila para download', 'downloads.noDownloads' => 'Nenhum download ainda', @@ -3127,8 +3162,6 @@ extension on TranslationsPt { 'downloads.serverErrorBitrate' => 'Erro do servidor: o arquivo pode exceder o limite remoto de bitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} episódios na fila de download', 'downloads.downloadDeleted' => 'Download excluído', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Excluir "${title}" deste dispositivo?', 'downloads.cancelledDownloadTitle' => 'Download cancelado', 'downloads.cancelledDownloadMessage' => 'Este download foi cancelado. O que você deseja fazer?', @@ -3260,6 +3293,8 @@ extension on TranslationsPt { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Conexão perdida após ${attempts} tentativas', 'companionRemote.errors.connectionLost' => 'Conexão perdida', 'videoSettings.playbackSpeed' => 'Velocidade de Reprodução', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Ativo (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Timer de Sono', 'videoSettings.audioSync' => 'Sincronia de Áudio', @@ -3351,6 +3386,8 @@ extension on TranslationsPt { 'metadataEdit.artworkUpdated' => 'Arte atualizada', 'metadataEdit.artworkUpdateFailed' => 'Falha ao atualizar arte', 'metadataEdit.noArtworkAvailable' => 'Nenhuma arte disponível', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Opção de arte ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Opção de arte ${index}, selecionada', 'metadataEdit.notSet' => 'Não definido', 'metadataEdit.libraryDefault' => 'Padrão da biblioteca', 'metadataEdit.accountDefault' => 'Padrão da conta', @@ -3465,11 +3502,13 @@ extension on TranslationsPt { 'services.deviceCode.title' => ({required Object service}) => 'Ativar Plezy no ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Acesse ${url} e insira este código:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para ativar', + 'services.deviceCode.copyCode' => 'Copiar código de ativação', 'services.deviceCode.waitingForAuthorization' => 'Aguardando autorização…', 'services.deviceCode.codeCopied' => 'Código copiado', 'services.oauthProxy.title' => ({required Object service}) => 'Entrar no ${service}', 'services.oauthProxy.body' => 'Escaneie este código QR ou abra a URL em qualquer dispositivo.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para entrar', + 'services.oauthProxy.copyUrl' => 'Copiar URL de login', 'services.oauthProxy.urlCopied' => 'URL copiado', 'services.libraryFilter.title' => 'Filtro de bibliotecas', 'services.libraryFilter.subtitleAllSyncing' => 'Sincronizando todas as bibliotecas', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 7ecb42ef..644083b0 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsRu extends TranslationsSettingsEn { @override String get creditsPattern => 'Шаблон маркера титров'; @override String get creditsPatternDescription => 'Регулярное выражение для распознавания маркеров титров в заголовках глав'; @override String get invalidRegex => 'Недопустимое регулярное выражение'; + @override String get regex => 'Регулярное выражение'; @override String get downloads => 'Загрузки'; @override String get downloadLocationDescription => 'Выберите место для хранения загруженного контента'; @override String get downloadLocationDefault => 'По умолчанию (Хранилище приложения)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysRu extends TranslationsHotkeysEn { @override String get clearShortcut => 'Очистить клавишу'; @override String get noShortcutSet => 'Сочетание не задано'; @override String get currentShortcut => 'Текущее сочетание:'; + @override String get pressToRecord => 'Выберите, чтобы записать сочетание клавиш'; + @override String get recordingShortcut => 'Нажмите сочетание клавиш сейчас'; @override late final _TranslationsHotkeysActionsRu actions = _TranslationsHotkeysActionsRu._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityRu extends TranslationsAccessibilityEn { @override String get saturation => 'Насыщенность'; @override String get brightness => 'Яркость'; @override String get hexColor => 'Шестнадцатеричный цвет'; + @override String get expandText => 'Развернуть текст'; + @override String get collapseText => 'Свернуть текст'; } // Path: tooltips @@ -693,6 +698,12 @@ class _TranslationsMessagesRu extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Отмечено как просмотренное (синхронизируется при подключении)'; @override String get markedAsUnwatchedOffline => 'Отмечено как непросмотренное (синхронизируется при подключении)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Автоудалено: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, + one: 'Автоматически удалена ${n} просмотренная загрузка', + few: 'Автоматически удалены ${n} просмотренные загрузки', + many: 'Автоматически удалено ${n} просмотренных загрузок', + other: 'Автоматически удалено ${n} просмотренной загрузки', + ); @override String get removedFromContinueWatching => 'Удалено из «Продолжить просмотр»'; @override String errorLoading({required Object error}) => 'Ошибка: ${error}'; @override String get streamInterrupted => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.'; @@ -751,6 +762,11 @@ class _TranslationsSubtitlingStylingRu extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Цвет фона'; @override String get position => 'Позиция'; @override String get assOverride => 'Переопределение ASS'; + @override String get overrideScale => 'Масштабировать'; + @override String get overrideForce => 'Принудительно'; + @override String get overrideStrip => 'Удалить стили'; + @override String get positionTop => 'Сверху'; + @override String get positionBottom => 'Снизу'; @override String get bold => 'Жирный'; @override String get italic => 'Курсив'; @override String get renderResolution => 'Разрешение отрисовки'; @@ -918,7 +934,7 @@ class _TranslationsErrorsRu extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Ошибка поиска: ${error}'; @override String connectionTimeout({required Object context}) => 'Таймаут подключения при загрузке ${context}'; @override String get connectionFailed => 'Не удалось подключиться к медиасерверу'; - @override String failedToLoad({required Object context, required Object error}) => 'Не удалось загрузить ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Не удалось загрузить ${context}. Повторите попытку.'; @override String get noClientAvailable => 'Клиент недоступен'; @override String get pleaseEnterToken => 'Введите токен'; @override String get invalidToken => 'Недействительный токен'; @@ -1182,7 +1198,6 @@ class _TranslationsCollectionsRu extends TranslationsCollectionsEn { @override String get deleted => 'Коллекция удалена'; @override String get deleteFailed => 'Не удалось удалить коллекцию'; @override String deleteFailedWithError({required Object error}) => 'Не удалось удалить коллекцию: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Не удалось загрузить элементы коллекции: ${error}'; @override String get selectCollection => 'Выбрать коллекцию'; @override String get collectionName => 'Название коллекции'; @override String get enterCollectionName => 'Введите название коллекции'; @@ -1288,6 +1303,8 @@ class _TranslationsWatchTogetherRu extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Хостинг сессии'; @override String get inSession => 'В сессии'; @override String get sessionCode => 'Код сессии'; + @override String get openSessionControls => 'Открыть управление сессией «Смотреть вместе»'; + @override String get copySessionCode => 'Скопировать код сессии'; @override String get hostControlsPlayback => 'Хост управляет воспроизведением'; @override String get anyoneCanControl => 'Любой может управлять воспроизведением'; @override String get hostControls => 'Управление хоста'; @@ -1464,6 +1481,8 @@ class _TranslationsVideoSettingsRu extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Скорость воспроизведения'; + @override String get normalSpeed => 'Обычная'; + @override String sleepTimerActive({required Object duration}) => 'Активен (${duration})'; @override String get zoom => 'Масштаб'; @override String get sleepTimer => 'Таймер сна'; @override String get audioSync => 'Синхронизация аудио'; @@ -1582,6 +1601,8 @@ class _TranslationsMetadataEditRu extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Обложка обновлена'; @override String get artworkUpdateFailed => 'Не удалось обновить обложку'; @override String get noArtworkAvailable => 'Обложки недоступны'; + @override String artworkOption({required Object index}) => 'Вариант обложки ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Вариант обложки ${index}, выбран'; @override String get notSet => 'Не задано'; @override String get libraryDefault => 'По умолчанию библиотеки'; @override String get accountDefault => 'По умолчанию аккаунта'; @@ -2059,6 +2080,7 @@ class _TranslationsServicesDeviceCodeRu extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Активируйте Plezy в ${service}'; @override String body({required Object url}) => 'Перейдите на ${url} и введите этот код:'; @override String openToActivate({required Object service}) => 'Открыть ${service} для активации'; + @override String get copyCode => 'Скопировать код активации'; @override String get waitingForAuthorization => 'Ожидание авторизации…'; @override String get codeCopied => 'Код скопирован'; } @@ -2073,6 +2095,7 @@ class _TranslationsServicesOauthProxyRu extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Войти в ${service}'; @override String get body => 'Отсканируйте этот QR-код или откройте URL на любом устройстве.'; @override String openToSignIn({required Object service}) => 'Открыть ${service} для входа'; + @override String get copyUrl => 'Скопировать URL для входа'; @override String get urlCopied => 'URL скопирован'; } @@ -2322,6 +2345,7 @@ extension on TranslationsRu { 'settings.creditsPattern' => 'Шаблон маркера титров', 'settings.creditsPatternDescription' => 'Регулярное выражение для распознавания маркеров титров в заголовках глав', 'settings.invalidRegex' => 'Недопустимое регулярное выражение', + 'settings.regex' => 'Регулярное выражение', 'settings.downloads' => 'Загрузки', 'settings.downloadLocationDescription' => 'Выберите место для хранения загруженного контента', 'settings.downloadLocationDefault' => 'По умолчанию (Хранилище приложения)', @@ -2427,6 +2451,8 @@ extension on TranslationsRu { 'hotkeys.clearShortcut' => 'Очистить клавишу', 'hotkeys.noShortcutSet' => 'Сочетание не задано', 'hotkeys.currentShortcut' => 'Текущее сочетание:', + 'hotkeys.pressToRecord' => 'Выберите, чтобы записать сочетание клавиш', + 'hotkeys.recordingShortcut' => 'Нажмите сочетание клавиш сейчас', 'hotkeys.actions.playPause' => 'Воспроизведение/Пауза', 'hotkeys.actions.volumeUp' => 'Громкость выше', 'hotkeys.actions.volumeDown' => 'Громкость ниже', @@ -2516,6 +2542,8 @@ extension on TranslationsRu { 'accessibility.saturation' => 'Насыщенность', 'accessibility.brightness' => 'Яркость', 'accessibility.hexColor' => 'Шестнадцатеричный цвет', + 'accessibility.expandText' => 'Развернуть текст', + 'accessibility.collapseText' => 'Свернуть текст', 'tooltips.shufflePlay' => 'Случайное воспроизведение', 'tooltips.playTrailer' => 'Воспроизвести трейлер', 'tooltips.markAsWatched' => 'Отметить как просмотренное', @@ -2606,19 +2634,20 @@ extension on TranslationsRu { 'messages.markedAsWatchedOffline' => 'Отмечено как просмотренное (синхронизируется при подключении)', 'messages.markedAsUnwatchedOffline' => 'Отмечено как непросмотренное (синхронизируется при подключении)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Автоудалено: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('ru'))(n, one: 'Автоматически удалена ${n} просмотренная загрузка', few: 'Автоматически удалены ${n} просмотренные загрузки', many: 'Автоматически удалено ${n} просмотренных загрузок', other: 'Автоматически удалено ${n} просмотренной загрузки', ), 'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»', 'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}', 'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.', 'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.', 'messages.fileInfoNotAvailable' => 'Информация о файле недоступна', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}', 'messages.errorLoadingSeries' => 'Ошибка загрузки сериала', 'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается', 'messages.noDescriptionAvailable' => 'Описание недоступно', 'messages.noProfilesAvailable' => 'Профили недоступны', 'messages.contactAdminForProfiles' => 'Обратитесь к администратору сервера для добавления профилей', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Не удаётся определить раздел библиотеки для этого элемента', 'messages.logsCleared' => 'Логи очищены', 'messages.logsCopied' => 'Логи скопированы в буфер обмена', @@ -2657,6 +2686,11 @@ extension on TranslationsRu { 'subtitlingStyling.backgroundColor' => 'Цвет фона', 'subtitlingStyling.position' => 'Позиция', 'subtitlingStyling.assOverride' => 'Переопределение ASS', + 'subtitlingStyling.overrideScale' => 'Масштабировать', + 'subtitlingStyling.overrideForce' => 'Принудительно', + 'subtitlingStyling.overrideStrip' => 'Удалить стили', + 'subtitlingStyling.positionTop' => 'Сверху', + 'subtitlingStyling.positionBottom' => 'Снизу', 'subtitlingStyling.bold' => 'Жирный', 'subtitlingStyling.italic' => 'Курсив', 'subtitlingStyling.renderResolution' => 'Разрешение отрисовки', @@ -2770,7 +2804,7 @@ extension on TranslationsRu { 'errors.searchFailed' => ({required Object error}) => 'Ошибка поиска: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Таймаут подключения при загрузке ${context}', 'errors.connectionFailed' => 'Не удалось подключиться к медиасерверу', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Не удалось загрузить ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Не удалось загрузить ${context}. Повторите попытку.', 'errors.noClientAvailable' => 'Клиент недоступен', 'errors.pleaseEnterToken' => 'Введите токен', 'errors.invalidToken' => 'Недействительный токен', @@ -2991,7 +3025,6 @@ extension on TranslationsRu { 'collections.deleted' => 'Коллекция удалена', 'collections.deleteFailed' => 'Не удалось удалить коллекцию', 'collections.deleteFailedWithError' => ({required Object error}) => 'Не удалось удалить коллекцию: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Не удалось загрузить элементы коллекции: ${error}', 'collections.selectCollection' => 'Выбрать коллекцию', 'collections.collectionName' => 'Название коллекции', 'collections.enterCollectionName' => 'Введите название коллекции', @@ -3065,6 +3098,8 @@ extension on TranslationsRu { 'watchTogether.hostingSession' => 'Хостинг сессии', 'watchTogether.inSession' => 'В сессии', 'watchTogether.sessionCode' => 'Код сессии', + 'watchTogether.openSessionControls' => 'Открыть управление сессией «Смотреть вместе»', + 'watchTogether.copySessionCode' => 'Скопировать код сессии', 'watchTogether.hostControlsPlayback' => 'Хост управляет воспроизведением', 'watchTogether.anyoneCanControl' => 'Любой может управлять воспроизведением', 'watchTogether.hostControls' => 'Управление хоста', @@ -3119,6 +3154,8 @@ extension on TranslationsRu { 'downloads.manage' => 'Управление', 'downloads.tvShows' => 'Сериалы', 'downloads.movies' => 'Фильмы', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Музыка', 'downloads.tracksQueued' => ({required Object count}) => '${count} треков в очереди на загрузку', 'downloads.noDownloads' => 'Загрузок пока нет', @@ -3131,8 +3168,6 @@ extension on TranslationsRu { 'downloads.serverErrorBitrate' => 'Ошибка сервера: файл может превышать удаленный лимит bitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} эпизодов поставлено в очередь загрузки', 'downloads.downloadDeleted' => 'Загрузка удалена', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Удалить "${title}" с этого устройства?', 'downloads.cancelledDownloadTitle' => 'Загрузка отменена', 'downloads.cancelledDownloadMessage' => 'Эта загрузка была отменена. Что вы хотите сделать?', @@ -3264,6 +3299,8 @@ extension on TranslationsRu { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Соединение потеряно после ${attempts} попыток', 'companionRemote.errors.connectionLost' => 'Соединение потеряно', 'videoSettings.playbackSpeed' => 'Скорость воспроизведения', + 'videoSettings.normalSpeed' => 'Обычная', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Активен (${duration})', 'videoSettings.zoom' => 'Масштаб', 'videoSettings.sleepTimer' => 'Таймер сна', 'videoSettings.audioSync' => 'Синхронизация аудио', @@ -3355,6 +3392,8 @@ extension on TranslationsRu { 'metadataEdit.artworkUpdated' => 'Обложка обновлена', 'metadataEdit.artworkUpdateFailed' => 'Не удалось обновить обложку', 'metadataEdit.noArtworkAvailable' => 'Обложки недоступны', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Вариант обложки ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Вариант обложки ${index}, выбран', 'metadataEdit.notSet' => 'Не задано', 'metadataEdit.libraryDefault' => 'По умолчанию библиотеки', 'metadataEdit.accountDefault' => 'По умолчанию аккаунта', @@ -3469,11 +3508,13 @@ extension on TranslationsRu { 'services.deviceCode.title' => ({required Object service}) => 'Активируйте Plezy в ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Перейдите на ${url} и введите этот код:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Открыть ${service} для активации', + 'services.deviceCode.copyCode' => 'Скопировать код активации', 'services.deviceCode.waitingForAuthorization' => 'Ожидание авторизации…', 'services.deviceCode.codeCopied' => 'Код скопирован', 'services.oauthProxy.title' => ({required Object service}) => 'Войти в ${service}', 'services.oauthProxy.body' => 'Отсканируйте этот QR-код или откройте URL на любом устройстве.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Открыть ${service} для входа', + 'services.oauthProxy.copyUrl' => 'Скопировать URL для входа', 'services.oauthProxy.urlCopied' => 'URL скопирован', 'services.libraryFilter.title' => 'Фильтр библиотек', 'services.libraryFilter.subtitleAllSyncing' => 'Синхронизация всех библиотек', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 1d9069a2..3ee27f58 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsSv extends TranslationsSettingsEn { @override String get creditsPattern => 'Eftertextmarkörsmönster'; @override String get creditsPatternDescription => 'Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar'; @override String get invalidRegex => 'Ogiltigt reguljärt uttryck'; + @override String get regex => 'Reguljärt uttryck'; @override String get downloads => 'Nedladdningar'; @override String get downloadLocationDescription => 'Välj var nedladdat innehåll ska lagras'; @override String get downloadLocationDefault => 'Standard (App-lagring)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysSv extends TranslationsHotkeysEn { @override String get clearShortcut => 'Rensa genväg'; @override String get noShortcutSet => 'Ingen genväg angiven'; @override String get currentShortcut => 'Aktuell genväg:'; + @override String get pressToRecord => 'Välj för att registrera en genväg'; + @override String get recordingShortcut => 'Tryck på genvägen nu'; @override late final _TranslationsHotkeysActionsSv actions = _TranslationsHotkeysActionsSv._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilitySv extends TranslationsAccessibilityEn { @override String get saturation => 'Mättnad'; @override String get brightness => 'Ljusstyrka'; @override String get hexColor => 'Hexfärg'; + @override String get expandText => 'Expandera text'; + @override String get collapseText => 'Fäll ihop text'; } // Path: tooltips @@ -693,6 +698,10 @@ class _TranslationsMessagesSv extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => 'Markerad som sedd (synkroniseras när online)'; @override String get markedAsUnwatchedOffline => 'Markerad som osedd (synkroniseras när online)'; @override String autoRemovedWatchedDownload({required Object title}) => 'Automatiskt borttagen: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, + one: 'Tog automatiskt bort ${n} sedd nedladdning', + other: 'Tog automatiskt bort ${n} sedda nedladdningar', + ); @override String get removedFromContinueWatching => 'Borttagen från Fortsätt titta'; @override String errorLoading({required Object error}) => 'Fel: ${error}'; @override String get streamInterrupted => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.'; @@ -751,6 +760,11 @@ class _TranslationsSubtitlingStylingSv extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => 'Bakgrundsfärg'; @override String get position => 'Position'; @override String get assOverride => 'ASS-åsidosättning'; + @override String get overrideScale => 'Skala'; + @override String get overrideForce => 'Tvinga'; + @override String get overrideStrip => 'Ta bort formatering'; + @override String get positionTop => 'Överst'; + @override String get positionBottom => 'Nederst'; @override String get bold => 'Fet'; @override String get italic => 'Kursiv'; @override String get renderResolution => 'Renderingsupplösning'; @@ -918,7 +932,7 @@ class _TranslationsErrorsSv extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => 'Sökning misslyckades: ${error}'; @override String connectionTimeout({required Object context}) => 'Anslutnings-timeout vid laddning ${context}'; @override String get connectionFailed => 'Kan inte ansluta till mediaserver'; - @override String failedToLoad({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}'; + @override String unableToLoad({required Object context}) => 'Det gick inte att läsa in ${context}. Försök igen.'; @override String get noClientAvailable => 'Ingen klient tillgänglig'; @override String get pleaseEnterToken => 'Vänligen ange en token'; @override String get invalidToken => 'Ogiltig token'; @@ -1180,7 +1194,6 @@ class _TranslationsCollectionsSv extends TranslationsCollectionsEn { @override String get deleted => 'Samling borttagen'; @override String get deleteFailed => 'Det gick inte att ta bort samlingen'; @override String deleteFailedWithError({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}'; - @override String failedToLoadItems({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}'; @override String get selectCollection => 'Välj samling'; @override String get collectionName => 'Samlingsnamn'; @override String get enterCollectionName => 'Ange samlingsnamn'; @@ -1284,6 +1297,8 @@ class _TranslationsWatchTogetherSv extends TranslationsWatchTogetherEn { @override String get hostingSession => 'Värd för Session'; @override String get inSession => 'I Session'; @override String get sessionCode => 'Sessionskod'; + @override String get openSessionControls => 'Öppna sessionskontroller för Titta Tillsammans'; + @override String get copySessionCode => 'Kopiera sessionskod'; @override String get hostControlsPlayback => 'Värden styr uppspelningen'; @override String get anyoneCanControl => 'Alla kan styra uppspelningen'; @override String get hostControls => 'Värd styr'; @@ -1460,6 +1475,8 @@ class _TranslationsVideoSettingsSv extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => 'Uppspelningshastighet'; + @override String get normalSpeed => 'Normal'; + @override String sleepTimerActive({required Object duration}) => 'Aktiv (${duration})'; @override String get zoom => 'Zoom'; @override String get sleepTimer => 'Sovtimer'; @override String get audioSync => 'Ljudsynkronisering'; @@ -1578,6 +1595,8 @@ class _TranslationsMetadataEditSv extends TranslationsMetadataEditEn { @override String get artworkUpdated => 'Artwork uppdaterad'; @override String get artworkUpdateFailed => 'Kunde inte uppdatera artwork'; @override String get noArtworkAvailable => 'Ingen artwork tillgänglig'; + @override String artworkOption({required Object index}) => 'Bildalternativ ${index}'; + @override String selectedArtworkOption({required Object index}) => 'Bildalternativ ${index}, valt'; @override String get notSet => 'Inte angiven'; @override String get libraryDefault => 'Biblioteksstandard'; @override String get accountDefault => 'Kontostandard'; @@ -2055,6 +2074,7 @@ class _TranslationsServicesDeviceCodeSv extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => 'Aktivera Plezy på ${service}'; @override String body({required Object url}) => 'Besök ${url} och ange den här koden:'; @override String openToActivate({required Object service}) => 'Öppna ${service} för att aktivera'; + @override String get copyCode => 'Kopiera aktiveringskod'; @override String get waitingForAuthorization => 'Väntar på auktorisering…'; @override String get codeCopied => 'Kod kopierad'; } @@ -2069,6 +2089,7 @@ class _TranslationsServicesOauthProxySv extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => 'Logga in på ${service}'; @override String get body => 'Skanna den här QR-koden eller öppna URL:en på valfri enhet.'; @override String openToSignIn({required Object service}) => 'Öppna ${service} för att logga in'; + @override String get copyUrl => 'Kopiera inloggnings-URL'; @override String get urlCopied => 'URL kopierad'; } @@ -2318,6 +2339,7 @@ extension on TranslationsSv { 'settings.creditsPattern' => 'Eftertextmarkörsmönster', 'settings.creditsPatternDescription' => 'Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar', 'settings.invalidRegex' => 'Ogiltigt reguljärt uttryck', + 'settings.regex' => 'Reguljärt uttryck', 'settings.downloads' => 'Nedladdningar', 'settings.downloadLocationDescription' => 'Välj var nedladdat innehåll ska lagras', 'settings.downloadLocationDefault' => 'Standard (App-lagring)', @@ -2423,6 +2445,8 @@ extension on TranslationsSv { 'hotkeys.clearShortcut' => 'Rensa genväg', 'hotkeys.noShortcutSet' => 'Ingen genväg angiven', 'hotkeys.currentShortcut' => 'Aktuell genväg:', + 'hotkeys.pressToRecord' => 'Välj för att registrera en genväg', + 'hotkeys.recordingShortcut' => 'Tryck på genvägen nu', 'hotkeys.actions.playPause' => 'Spela/Pausa', 'hotkeys.actions.volumeUp' => 'Höj volym', 'hotkeys.actions.volumeDown' => 'Sänk volym', @@ -2512,6 +2536,8 @@ extension on TranslationsSv { 'accessibility.saturation' => 'Mättnad', 'accessibility.brightness' => 'Ljusstyrka', 'accessibility.hexColor' => 'Hexfärg', + 'accessibility.expandText' => 'Expandera text', + 'accessibility.collapseText' => 'Fäll ihop text', 'tooltips.shufflePlay' => 'Blanda uppspelning', 'tooltips.playTrailer' => 'Spela trailer', 'tooltips.markAsWatched' => 'Markera som sedd', @@ -2602,19 +2628,20 @@ extension on TranslationsSv { 'messages.markedAsWatchedOffline' => 'Markerad som sedd (synkroniseras när online)', 'messages.markedAsUnwatchedOffline' => 'Markerad som osedd (synkroniseras när online)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => 'Automatiskt borttagen: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('sv'))(n, one: 'Tog automatiskt bort ${n} sedd nedladdning', other: 'Tog automatiskt bort ${n} sedda nedladdningar', ), 'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta', 'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}', 'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.', 'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.', 'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}', 'messages.errorLoadingSeries' => 'Fel vid laddning av serie', 'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu', 'messages.noDescriptionAvailable' => 'Ingen beskrivning tillgänglig', 'messages.noProfilesAvailable' => 'Inga profiler tillgängliga', 'messages.contactAdminForProfiles' => 'Kontakta din serveradministratör för att lägga till profiler', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => 'Kan inte avgöra biblioteksavdelningen för detta objekt', 'messages.logsCleared' => 'Loggar rensade', 'messages.logsCopied' => 'Loggar kopierade till urklipp', @@ -2653,6 +2680,11 @@ extension on TranslationsSv { 'subtitlingStyling.backgroundColor' => 'Bakgrundsfärg', 'subtitlingStyling.position' => 'Position', 'subtitlingStyling.assOverride' => 'ASS-åsidosättning', + 'subtitlingStyling.overrideScale' => 'Skala', + 'subtitlingStyling.overrideForce' => 'Tvinga', + 'subtitlingStyling.overrideStrip' => 'Ta bort formatering', + 'subtitlingStyling.positionTop' => 'Överst', + 'subtitlingStyling.positionBottom' => 'Nederst', 'subtitlingStyling.bold' => 'Fet', 'subtitlingStyling.italic' => 'Kursiv', 'subtitlingStyling.renderResolution' => 'Renderingsupplösning', @@ -2766,7 +2798,7 @@ extension on TranslationsSv { 'errors.searchFailed' => ({required Object error}) => 'Sökning misslyckades: ${error}', 'errors.connectionTimeout' => ({required Object context}) => 'Anslutnings-timeout vid laddning ${context}', 'errors.connectionFailed' => 'Kan inte ansluta till mediaserver', - 'errors.failedToLoad' => ({required Object context, required Object error}) => 'Misslyckades att ladda ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => 'Det gick inte att läsa in ${context}. Försök igen.', 'errors.noClientAvailable' => 'Ingen klient tillgänglig', 'errors.pleaseEnterToken' => 'Vänligen ange en token', 'errors.invalidToken' => 'Ogiltig token', @@ -2987,7 +3019,6 @@ extension on TranslationsSv { 'collections.deleted' => 'Samling borttagen', 'collections.deleteFailed' => 'Det gick inte att ta bort samlingen', 'collections.deleteFailedWithError' => ({required Object error}) => 'Det gick inte att ta bort samlingen: ${error}', - 'collections.failedToLoadItems' => ({required Object error}) => 'Det gick inte att läsa in samlingsobjekt: ${error}', 'collections.selectCollection' => 'Välj samling', 'collections.collectionName' => 'Samlingsnamn', 'collections.enterCollectionName' => 'Ange samlingsnamn', @@ -3061,6 +3092,8 @@ extension on TranslationsSv { 'watchTogether.hostingSession' => 'Värd för Session', 'watchTogether.inSession' => 'I Session', 'watchTogether.sessionCode' => 'Sessionskod', + 'watchTogether.openSessionControls' => 'Öppna sessionskontroller för Titta Tillsammans', + 'watchTogether.copySessionCode' => 'Kopiera sessionskod', 'watchTogether.hostControlsPlayback' => 'Värden styr uppspelningen', 'watchTogether.anyoneCanControl' => 'Alla kan styra uppspelningen', 'watchTogether.hostControls' => 'Värd styr', @@ -3115,6 +3148,8 @@ extension on TranslationsSv { 'downloads.manage' => 'Hantera', 'downloads.tvShows' => 'TV-serier', 'downloads.movies' => 'Filmer', + _ => null, + } ?? switch (path) { 'downloads.music' => 'Musik', 'downloads.tracksQueued' => ({required Object count}) => '${count} låtar i nedladdningskö', 'downloads.noDownloads' => 'Inga nedladdningar ännu', @@ -3127,8 +3162,6 @@ extension on TranslationsSv { 'downloads.serverErrorBitrate' => 'Serverfel: filen kan överskrida gränsen för fjärrbitrate', 'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning', 'downloads.downloadDeleted' => 'Nedladdning borttagen', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => 'Ta bort "${title}" från den här enheten?', 'downloads.cancelledDownloadTitle' => 'Avbruten nedladdning', 'downloads.cancelledDownloadMessage' => 'Den här nedladdningen avbröts. Vad vill du göra?', @@ -3260,6 +3293,8 @@ extension on TranslationsSv { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => 'Anslutning förlorad efter ${attempts} försök', 'companionRemote.errors.connectionLost' => 'Anslutning förlorad', 'videoSettings.playbackSpeed' => 'Uppspelningshastighet', + 'videoSettings.normalSpeed' => 'Normal', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => 'Aktiv (${duration})', 'videoSettings.zoom' => 'Zoom', 'videoSettings.sleepTimer' => 'Sovtimer', 'videoSettings.audioSync' => 'Ljudsynkronisering', @@ -3351,6 +3386,8 @@ extension on TranslationsSv { 'metadataEdit.artworkUpdated' => 'Artwork uppdaterad', 'metadataEdit.artworkUpdateFailed' => 'Kunde inte uppdatera artwork', 'metadataEdit.noArtworkAvailable' => 'Ingen artwork tillgänglig', + 'metadataEdit.artworkOption' => ({required Object index}) => 'Bildalternativ ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Bildalternativ ${index}, valt', 'metadataEdit.notSet' => 'Inte angiven', 'metadataEdit.libraryDefault' => 'Biblioteksstandard', 'metadataEdit.accountDefault' => 'Kontostandard', @@ -3465,11 +3502,13 @@ extension on TranslationsSv { 'services.deviceCode.title' => ({required Object service}) => 'Aktivera Plezy på ${service}', 'services.deviceCode.body' => ({required Object url}) => 'Besök ${url} och ange den här koden:', 'services.deviceCode.openToActivate' => ({required Object service}) => 'Öppna ${service} för att aktivera', + 'services.deviceCode.copyCode' => 'Kopiera aktiveringskod', 'services.deviceCode.waitingForAuthorization' => 'Väntar på auktorisering…', 'services.deviceCode.codeCopied' => 'Kod kopierad', 'services.oauthProxy.title' => ({required Object service}) => 'Logga in på ${service}', 'services.oauthProxy.body' => 'Skanna den här QR-koden eller öppna URL:en på valfri enhet.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Öppna ${service} för att logga in', + 'services.oauthProxy.copyUrl' => 'Kopiera inloggnings-URL', 'services.oauthProxy.urlCopied' => 'URL kopierad', 'services.libraryFilter.title' => 'Biblioteksfilter', 'services.libraryFilter.subtitleAllSyncing' => 'Synkroniserar alla bibliotek', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 7100e476..42449c25 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -358,6 +358,7 @@ class _TranslationsSettingsZh extends TranslationsSettingsEn { @override String get creditsPattern => '片尾标记模式'; @override String get creditsPatternDescription => '用于匹配章节标题中片尾标记的正则表达式'; @override String get invalidRegex => '无效的正则表达式'; + @override String get regex => '正则表达式'; @override String get downloads => '下载'; @override String get downloadLocationDescription => '选择下载内容的存储位置'; @override String get downloadLocationDefault => '默认(应用存储)'; @@ -481,6 +482,8 @@ class _TranslationsHotkeysZh extends TranslationsHotkeysEn { @override String get clearShortcut => '清除快捷键'; @override String get noShortcutSet => '未设置快捷键'; @override String get currentShortcut => '当前快捷键:'; + @override String get pressToRecord => '选择以录制快捷键'; + @override String get recordingShortcut => '现在请按下快捷键'; @override late final _TranslationsHotkeysActionsZh actions = _TranslationsHotkeysActionsZh._(_root); } @@ -582,6 +585,8 @@ class _TranslationsAccessibilityZh extends TranslationsAccessibilityEn { @override String get saturation => '饱和度'; @override String get brightness => '亮度'; @override String get hexColor => '十六进制颜色'; + @override String get expandText => '展开文本'; + @override String get collapseText => '折叠文本'; } // Path: tooltips @@ -693,6 +698,9 @@ class _TranslationsMessagesZh extends TranslationsMessagesEn { @override String get markedAsWatchedOffline => '已标记为已观看 (将在联网时同步)'; @override String get markedAsUnwatchedOffline => '已标记为未观看 (将在联网时同步)'; @override String autoRemovedWatchedDownload({required Object title}) => '已自动移除: ${title}'; + @override String autoRemovedWatchedDownloads({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, + other: '已自动移除 ${n} 个看过的下载', + ); @override String get removedFromContinueWatching => '已从继续观看中移除'; @override String errorLoading({required Object error}) => '错误: ${error}'; @override String get streamInterrupted => '视频流已中断。按播放键或拖动进度条重试。'; @@ -751,6 +759,11 @@ class _TranslationsSubtitlingStylingZh extends TranslationsSubtitlingStylingEn { @override String get backgroundColor => '背景颜色'; @override String get position => '位置'; @override String get assOverride => 'ASS 样式覆盖'; + @override String get overrideScale => '缩放'; + @override String get overrideForce => '强制'; + @override String get overrideStrip => '移除样式'; + @override String get positionTop => '顶部'; + @override String get positionBottom => '底部'; @override String get bold => '粗体'; @override String get italic => '斜体'; @override String get renderResolution => '渲染分辨率'; @@ -918,7 +931,7 @@ class _TranslationsErrorsZh extends TranslationsErrorsEn { @override String searchFailed({required Object error}) => '搜索失败: ${error}'; @override String connectionTimeout({required Object context}) => '加载 ${context} 时连接超时'; @override String get connectionFailed => '无法连接到媒体服务器'; - @override String failedToLoad({required Object context, required Object error}) => '无法加载 ${context}: ${error}'; + @override String unableToLoad({required Object context}) => '无法加载${context}。请重试。'; @override String get noClientAvailable => '没有可用客户端'; @override String get pleaseEnterToken => '请输入一个令牌'; @override String get invalidToken => '令牌无效'; @@ -1179,7 +1192,6 @@ class _TranslationsCollectionsZh extends TranslationsCollectionsEn { @override String get deleted => '已删除合集'; @override String get deleteFailed => '删除合集失败'; @override String deleteFailedWithError({required Object error}) => '删除合集失败:${error}'; - @override String failedToLoadItems({required Object error}) => '加载合集项目失败:${error}'; @override String get selectCollection => '选择合集'; @override String get collectionName => '合集名称'; @override String get enterCollectionName => '输入合集名称'; @@ -1282,6 +1294,8 @@ class _TranslationsWatchTogetherZh extends TranslationsWatchTogetherEn { @override String get hostingSession => '主持会话'; @override String get inSession => '在会话中'; @override String get sessionCode => '会话代码'; + @override String get openSessionControls => '打开“一起看”会话控制'; + @override String get copySessionCode => '复制会话代码'; @override String get hostControlsPlayback => '主持人控制播放'; @override String get anyoneCanControl => '任何人都可以控制播放'; @override String get hostControls => '主持人控制'; @@ -1458,6 +1472,8 @@ class _TranslationsVideoSettingsZh extends TranslationsVideoSettingsEn { // Translations @override String get playbackSpeed => '播放速度'; + @override String get normalSpeed => '正常'; + @override String sleepTimerActive({required Object duration}) => '运行中(${duration})'; @override String get zoom => '缩放'; @override String get sleepTimer => '睡眠定时器'; @override String get audioSync => '音频同步'; @@ -1576,6 +1592,8 @@ class _TranslationsMetadataEditZh extends TranslationsMetadataEditEn { @override String get artworkUpdated => '封面图片已更新'; @override String get artworkUpdateFailed => '封面图片更新失败'; @override String get noArtworkAvailable => '没有可用的封面图片'; + @override String artworkOption({required Object index}) => '封面图片选项 ${index}'; + @override String selectedArtworkOption({required Object index}) => '封面图片选项 ${index},已选择'; @override String get notSet => '未设置'; @override String get libraryDefault => '媒体库默认'; @override String get accountDefault => '账户默认'; @@ -2053,6 +2071,7 @@ class _TranslationsServicesDeviceCodeZh extends TranslationsServicesDeviceCodeEn @override String title({required Object service}) => '在 ${service} 上激活 Plezy'; @override String body({required Object url}) => '访问 ${url} 并输入此代码:'; @override String openToActivate({required Object service}) => '打开 ${service} 以激活'; + @override String get copyCode => '复制激活代码'; @override String get waitingForAuthorization => '等待授权…'; @override String get codeCopied => '代码已复制'; } @@ -2067,6 +2086,7 @@ class _TranslationsServicesOauthProxyZh extends TranslationsServicesOauthProxyEn @override String title({required Object service}) => '登录 ${service}'; @override String get body => '扫描此二维码,或在任意设备上打开 URL。'; @override String openToSignIn({required Object service}) => '打开 ${service} 以登录'; + @override String get copyUrl => '复制登录 URL'; @override String get urlCopied => 'URL 已复制'; } @@ -2316,6 +2336,7 @@ extension on TranslationsZh { 'settings.creditsPattern' => '片尾标记模式', 'settings.creditsPatternDescription' => '用于匹配章节标题中片尾标记的正则表达式', 'settings.invalidRegex' => '无效的正则表达式', + 'settings.regex' => '正则表达式', 'settings.downloads' => '下载', 'settings.downloadLocationDescription' => '选择下载内容的存储位置', 'settings.downloadLocationDefault' => '默认(应用存储)', @@ -2421,6 +2442,8 @@ extension on TranslationsZh { 'hotkeys.clearShortcut' => '清除快捷键', 'hotkeys.noShortcutSet' => '未设置快捷键', 'hotkeys.currentShortcut' => '当前快捷键:', + 'hotkeys.pressToRecord' => '选择以录制快捷键', + 'hotkeys.recordingShortcut' => '现在请按下快捷键', 'hotkeys.actions.playPause' => '播放/暂停', 'hotkeys.actions.volumeUp' => '增大音量', 'hotkeys.actions.volumeDown' => '减小音量', @@ -2510,6 +2533,8 @@ extension on TranslationsZh { 'accessibility.saturation' => '饱和度', 'accessibility.brightness' => '亮度', 'accessibility.hexColor' => '十六进制颜色', + 'accessibility.expandText' => '展开文本', + 'accessibility.collapseText' => '折叠文本', 'tooltips.shufflePlay' => '随机播放', 'tooltips.playTrailer' => '播放预告片', 'tooltips.markAsWatched' => '标记为已观看', @@ -2600,19 +2625,20 @@ extension on TranslationsZh { 'messages.markedAsWatchedOffline' => '已标记为已观看 (将在联网时同步)', 'messages.markedAsUnwatchedOffline' => '已标记为未观看 (将在联网时同步)', 'messages.autoRemovedWatchedDownload' => ({required Object title}) => '已自动移除: ${title}', + 'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('zh'))(n, other: '已自动移除 ${n} 个看过的下载', ), 'messages.removedFromContinueWatching' => '已从继续观看中移除', 'messages.errorLoading' => ({required Object error}) => '错误: ${error}', 'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。', 'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。', 'messages.fileInfoNotAvailable' => '文件信息不可用', + _ => null, + } ?? switch (path) { 'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}', 'messages.errorLoadingSeries' => '加载系列时出错', 'messages.musicNotSupported' => '尚不支持播放音乐', 'messages.noDescriptionAvailable' => '暂无描述', 'messages.noProfilesAvailable' => '没有可用的用户', 'messages.contactAdminForProfiles' => '请联系服务器管理员添加用户配置', - _ => null, - } ?? switch (path) { 'messages.unableToDetermineLibrarySection' => '无法确定此项目的库分区', 'messages.logsCleared' => '日志已清除', 'messages.logsCopied' => '日志已复制到剪贴板', @@ -2651,6 +2677,11 @@ extension on TranslationsZh { 'subtitlingStyling.backgroundColor' => '背景颜色', 'subtitlingStyling.position' => '位置', 'subtitlingStyling.assOverride' => 'ASS 样式覆盖', + 'subtitlingStyling.overrideScale' => '缩放', + 'subtitlingStyling.overrideForce' => '强制', + 'subtitlingStyling.overrideStrip' => '移除样式', + 'subtitlingStyling.positionTop' => '顶部', + 'subtitlingStyling.positionBottom' => '底部', 'subtitlingStyling.bold' => '粗体', 'subtitlingStyling.italic' => '斜体', 'subtitlingStyling.renderResolution' => '渲染分辨率', @@ -2764,7 +2795,7 @@ extension on TranslationsZh { 'errors.searchFailed' => ({required Object error}) => '搜索失败: ${error}', 'errors.connectionTimeout' => ({required Object context}) => '加载 ${context} 时连接超时', 'errors.connectionFailed' => '无法连接到媒体服务器', - 'errors.failedToLoad' => ({required Object context, required Object error}) => '无法加载 ${context}: ${error}', + 'errors.unableToLoad' => ({required Object context}) => '无法加载${context}。请重试。', 'errors.noClientAvailable' => '没有可用客户端', 'errors.pleaseEnterToken' => '请输入一个令牌', 'errors.invalidToken' => '令牌无效', @@ -2985,7 +3016,6 @@ extension on TranslationsZh { 'collections.deleted' => '已删除合集', 'collections.deleteFailed' => '删除合集失败', 'collections.deleteFailedWithError' => ({required Object error}) => '删除合集失败:${error}', - 'collections.failedToLoadItems' => ({required Object error}) => '加载合集项目失败:${error}', 'collections.selectCollection' => '选择合集', 'collections.collectionName' => '合集名称', 'collections.enterCollectionName' => '输入合集名称', @@ -3059,6 +3089,8 @@ extension on TranslationsZh { 'watchTogether.hostingSession' => '主持会话', 'watchTogether.inSession' => '在会话中', 'watchTogether.sessionCode' => '会话代码', + 'watchTogether.openSessionControls' => '打开“一起看”会话控制', + 'watchTogether.copySessionCode' => '复制会话代码', 'watchTogether.hostControlsPlayback' => '主持人控制播放', 'watchTogether.anyoneCanControl' => '任何人都可以控制播放', 'watchTogether.hostControls' => '主持人控制', @@ -3113,6 +3145,8 @@ extension on TranslationsZh { 'downloads.manage' => '管理', 'downloads.tvShows' => '电视剧', 'downloads.movies' => '电影', + _ => null, + } ?? switch (path) { 'downloads.music' => '音乐', 'downloads.tracksQueued' => ({required Object count}) => '${count} 首曲目已加入下载队列', 'downloads.noDownloads' => '暂无下载', @@ -3125,8 +3159,6 @@ extension on TranslationsZh { 'downloads.serverErrorBitrate' => '服务器错误:文件可能超过远程比特率限制', 'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列', 'downloads.downloadDeleted' => '下载已删除', - _ => null, - } ?? switch (path) { 'downloads.deleteConfirm' => ({required Object title}) => '要从此设备删除“${title}”吗?', 'downloads.cancelledDownloadTitle' => '已取消的下载', 'downloads.cancelledDownloadMessage' => '此下载已取消。你想怎么做?', @@ -3258,6 +3290,8 @@ extension on TranslationsZh { 'companionRemote.errors.connectionLostAfterAttempts' => ({required Object attempts}) => '${attempts} 次尝试后连接丢失', 'companionRemote.errors.connectionLost' => '连接丢失', 'videoSettings.playbackSpeed' => '播放速度', + 'videoSettings.normalSpeed' => '正常', + 'videoSettings.sleepTimerActive' => ({required Object duration}) => '运行中(${duration})', 'videoSettings.zoom' => '缩放', 'videoSettings.sleepTimer' => '睡眠定时器', 'videoSettings.audioSync' => '音频同步', @@ -3349,6 +3383,8 @@ extension on TranslationsZh { 'metadataEdit.artworkUpdated' => '封面图片已更新', 'metadataEdit.artworkUpdateFailed' => '封面图片更新失败', 'metadataEdit.noArtworkAvailable' => '没有可用的封面图片', + 'metadataEdit.artworkOption' => ({required Object index}) => '封面图片选项 ${index}', + 'metadataEdit.selectedArtworkOption' => ({required Object index}) => '封面图片选项 ${index},已选择', 'metadataEdit.notSet' => '未设置', 'metadataEdit.libraryDefault' => '媒体库默认', 'metadataEdit.accountDefault' => '账户默认', @@ -3463,11 +3499,13 @@ extension on TranslationsZh { 'services.deviceCode.title' => ({required Object service}) => '在 ${service} 上激活 Plezy', 'services.deviceCode.body' => ({required Object url}) => '访问 ${url} 并输入此代码:', 'services.deviceCode.openToActivate' => ({required Object service}) => '打开 ${service} 以激活', + 'services.deviceCode.copyCode' => '复制激活代码', 'services.deviceCode.waitingForAuthorization' => '等待授权…', 'services.deviceCode.codeCopied' => '代码已复制', 'services.oauthProxy.title' => ({required Object service}) => '登录 ${service}', 'services.oauthProxy.body' => '扫描此二维码,或在任意设备上打开 URL。', 'services.oauthProxy.openToSignIn' => ({required Object service}) => '打开 ${service} 以登录', + 'services.oauthProxy.copyUrl' => '复制登录 URL', 'services.oauthProxy.urlCopied' => 'URL 已复制', 'services.libraryFilter.title' => '媒体库筛选', 'services.libraryFilter.subtitleAllSyncing' => '同步所有媒体库', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index eb4b8a59..865b2fc7 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "Eftertextmarkörsmönster", "creditsPatternDescription": "Reguljärt uttryck för att matcha eftertextmarkörer i kapiteltitlar", "invalidRegex": "Ogiltigt reguljärt uttryck", + "regex": "Reguljärt uttryck", "downloads": "Nedladdningar", "downloadLocationDescription": "Välj var nedladdat innehåll ska lagras", "downloadLocationDefault": "Standard (App-lagring)", @@ -336,6 +337,8 @@ "clearShortcut": "Rensa genväg", "noShortcutSet": "Ingen genväg angiven", "currentShortcut": "Aktuell genväg:", + "pressToRecord": "Välj för att registrera en genväg", + "recordingShortcut": "Tryck på genvägen nu", "actions": { "playPause": "Spela/Pausa", "volumeUp": "Höj volym", @@ -434,7 +437,9 @@ "hue": "Nyans", "saturation": "Mättnad", "brightness": "Ljusstyrka", - "hexColor": "Hexfärg" + "hexColor": "Hexfärg", + "expandText": "Expandera text", + "collapseText": "Fäll ihop text" }, "tooltips": { "shufflePlay": "Blanda uppspelning", @@ -534,6 +539,10 @@ "markedAsWatchedOffline": "Markerad som sedd (synkroniseras när online)", "markedAsUnwatchedOffline": "Markerad som osedd (synkroniseras när online)", "autoRemovedWatchedDownload": "Automatiskt borttagen: ${title}", + "autoRemovedWatchedDownloads": { + "one": "Tog automatiskt bort ${n} sedd nedladdning", + "other": "Tog automatiskt bort ${n} sedda nedladdningar" + }, "removedFromContinueWatching": "Borttagen från Fortsätt titta", "errorLoading": "Fel: ${error}", "streamInterrupted": "Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.", @@ -585,6 +594,11 @@ "backgroundColor": "Bakgrundsfärg", "position": "Position", "assOverride": "ASS-åsidosättning", + "overrideScale": "Skala", + "overrideForce": "Tvinga", + "overrideStrip": "Ta bort formatering", + "positionTop": "Överst", + "positionBottom": "Nederst", "bold": "Fet", "italic": "Kursiv", "renderResolution": "Renderingsupplösning", @@ -710,7 +724,7 @@ "searchFailed": "Sökning misslyckades: ${error}", "connectionTimeout": "Anslutnings-timeout vid laddning ${context}", "connectionFailed": "Kan inte ansluta till mediaserver", - "failedToLoad": "Misslyckades att ladda ${context}: ${error}", + "unableToLoad": "Det gick inte att läsa in ${context}. Försök igen.", "noClientAvailable": "Ingen klient tillgänglig", "pleaseEnterToken": "Vänligen ange en token", "invalidToken": "Ogiltig token", @@ -966,7 +980,6 @@ "deleted": "Samling borttagen", "deleteFailed": "Det gick inte att ta bort samlingen", "deleteFailedWithError": "Det gick inte att ta bort samlingen: ${error}", - "failedToLoadItems": "Det gick inte att läsa in samlingsobjekt: ${error}", "selectCollection": "Välj samling", "collectionName": "Samlingsnamn", "enterCollectionName": "Ange samlingsnamn", @@ -1049,6 +1062,8 @@ "hostingSession": "Värd för Session", "inSession": "I Session", "sessionCode": "Sessionskod", + "openSessionControls": "Öppna sessionskontroller för Titta Tillsammans", + "copySessionCode": "Kopiera sessionskod", "hostControlsPlayback": "Värden styr uppspelningen", "anyoneCanControl": "Alla kan styra uppspelningen", "hostControls": "Värd styr", @@ -1262,6 +1277,8 @@ }, "videoSettings": { "playbackSpeed": "Uppspelningshastighet", + "normalSpeed": "Normal", + "sleepTimerActive": "Aktiv (${duration})", "zoom": "Zoom", "sleepTimer": "Sovtimer", "audioSync": "Ljudsynkronisering", @@ -1359,6 +1376,8 @@ "artworkUpdated": "Artwork uppdaterad", "artworkUpdateFailed": "Kunde inte uppdatera artwork", "noArtworkAvailable": "Ingen artwork tillgänglig", + "artworkOption": "Bildalternativ ${index}", + "selectedArtworkOption": "Bildalternativ ${index}, valt", "notSet": "Inte angiven", "libraryDefault": "Biblioteksstandard", "accountDefault": "Kontostandard", @@ -1486,6 +1505,7 @@ "title": "Aktivera Plezy på ${service}", "body": "Besök ${url} och ange den här koden:", "openToActivate": "Öppna ${service} för att aktivera", + "copyCode": "Kopiera aktiveringskod", "waitingForAuthorization": "Väntar på auktorisering…", "codeCopied": "Kod kopierad" }, @@ -1493,6 +1513,7 @@ "title": "Logga in på ${service}", "body": "Skanna den här QR-koden eller öppna URL:en på valfri enhet.", "openToSignIn": "Öppna ${service} för att logga in", + "copyUrl": "Kopiera inloggnings-URL", "urlCopied": "URL kopierad" }, "libraryFilter": { diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 11d9bb7b..3f249ef4 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -227,6 +227,7 @@ "creditsPattern": "片尾标记模式", "creditsPatternDescription": "用于匹配章节标题中片尾标记的正则表达式", "invalidRegex": "无效的正则表达式", + "regex": "正则表达式", "downloads": "下载", "downloadLocationDescription": "选择下载内容的存储位置", "downloadLocationDefault": "默认(应用存储)", @@ -336,6 +337,8 @@ "clearShortcut": "清除快捷键", "noShortcutSet": "未设置快捷键", "currentShortcut": "当前快捷键:", + "pressToRecord": "选择以录制快捷键", + "recordingShortcut": "现在请按下快捷键", "actions": { "playPause": "播放/暂停", "volumeUp": "增大音量", @@ -434,7 +437,9 @@ "hue": "色相", "saturation": "饱和度", "brightness": "亮度", - "hexColor": "十六进制颜色" + "hexColor": "十六进制颜色", + "expandText": "展开文本", + "collapseText": "折叠文本" }, "tooltips": { "shufflePlay": "随机播放", @@ -534,6 +539,9 @@ "markedAsWatchedOffline": "已标记为已观看 (将在联网时同步)", "markedAsUnwatchedOffline": "已标记为未观看 (将在联网时同步)", "autoRemovedWatchedDownload": "已自动移除: ${title}", + "autoRemovedWatchedDownloads": { + "other": "已自动移除 ${n} 个看过的下载" + }, "removedFromContinueWatching": "已从继续观看中移除", "errorLoading": "错误: ${error}", "streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。", @@ -585,6 +593,11 @@ "backgroundColor": "背景颜色", "position": "位置", "assOverride": "ASS 样式覆盖", + "overrideScale": "缩放", + "overrideForce": "强制", + "overrideStrip": "移除样式", + "positionTop": "顶部", + "positionBottom": "底部", "bold": "粗体", "italic": "斜体", "renderResolution": "渲染分辨率", @@ -710,7 +723,7 @@ "searchFailed": "搜索失败: ${error}", "connectionTimeout": "加载 ${context} 时连接超时", "connectionFailed": "无法连接到媒体服务器", - "failedToLoad": "无法加载 ${context}: ${error}", + "unableToLoad": "无法加载${context}。请重试。", "noClientAvailable": "没有可用客户端", "pleaseEnterToken": "请输入一个令牌", "invalidToken": "令牌无效", @@ -965,7 +978,6 @@ "deleted": "已删除合集", "deleteFailed": "删除合集失败", "deleteFailedWithError": "删除合集失败:${error}", - "failedToLoadItems": "加载合集项目失败:${error}", "selectCollection": "选择合集", "collectionName": "合集名称", "enterCollectionName": "输入合集名称", @@ -1047,6 +1059,8 @@ "hostingSession": "主持会话", "inSession": "在会话中", "sessionCode": "会话代码", + "openSessionControls": "打开“一起看”会话控制", + "copySessionCode": "复制会话代码", "hostControlsPlayback": "主持人控制播放", "anyoneCanControl": "任何人都可以控制播放", "hostControls": "主持人控制", @@ -1260,6 +1274,8 @@ }, "videoSettings": { "playbackSpeed": "播放速度", + "normalSpeed": "正常", + "sleepTimerActive": "运行中(${duration})", "zoom": "缩放", "sleepTimer": "睡眠定时器", "audioSync": "音频同步", @@ -1357,6 +1373,8 @@ "artworkUpdated": "封面图片已更新", "artworkUpdateFailed": "封面图片更新失败", "noArtworkAvailable": "没有可用的封面图片", + "artworkOption": "封面图片选项 ${index}", + "selectedArtworkOption": "封面图片选项 ${index},已选择", "notSet": "未设置", "libraryDefault": "媒体库默认", "accountDefault": "账户默认", @@ -1484,6 +1502,7 @@ "title": "在 ${service} 上激活 Plezy", "body": "访问 ${url} 并输入此代码:", "openToActivate": "打开 ${service} 以激活", + "copyCode": "复制激活代码", "waitingForAuthorization": "等待授权…", "codeCopied": "代码已复制" }, @@ -1491,6 +1510,7 @@ "title": "登录 ${service}", "body": "扫描此二维码,或在任意设备上打开 URL。", "openToSignIn": "打开 ${service} 以登录", + "copyUrl": "复制登录 URL", "urlCopied": "URL 已复制" }, "libraryFilter": { diff --git a/lib/main.dart b/lib/main.dart index ca4bb7e2..cdeccb00 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; // ignore: depend_on_referenced_packages import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; @@ -68,6 +69,7 @@ import 'utils/media_server_http_client.dart'; import 'utils/orientation_helper.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; +import 'widgets/app_icon.dart'; import 'focus/input_mode_tracker.dart'; import 'focus/key_event_utils.dart'; import 'package:intl/date_symbol_data_local.dart'; @@ -678,7 +680,7 @@ class _MainAppState extends State with WidgetsBindingObserver { if (deleted.isNotEmpty) { final msg = deleted.length == 1 ? t.messages.autoRemovedWatchedDownload(title: deleted.first) - : t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items'); + : t.messages.autoRemovedWatchedDownloads(n: deleted.length); showMainSnackBar(msg); } } @@ -1404,9 +1406,9 @@ class _SetupScreenState extends State with MountedSetStateMixin { child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor), ); } else if (connected) { - statusIcon = const Icon(Icons.check_circle, size: 14, color: successColor); + statusIcon = const AppIcon(Symbols.check_circle_rounded, size: 14, color: successColor); } else { - statusIcon = const Icon(Icons.cancel, size: 14, color: failColor); + statusIcon = const AppIcon(Symbols.cancel_rounded, size: 14, color: failColor); } return Padding( key: ValueKey(entry.key), diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 5081c678..297a0e7b 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -3,7 +3,7 @@ import '../media/media_sort.dart'; import '../services/api_cache.dart'; import '../services/playback_initialization_types.dart'; import '../utils/app_logger.dart'; -import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse; +import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse, throwIfHttpError; import '../utils/external_ids.dart'; import '../utils/watch_state_notifier.dart'; import 'download_resolution.dart'; @@ -732,6 +732,7 @@ mixin MediaServerCacheMixin implements MediaServerClient { } try { final response = await networkCall(); + throwIfHttpError(response); if (cacheResponse) { await _putCacheResponse(cacheKey, response.data); } diff --git a/lib/mixins/item_updatable.dart b/lib/mixins/item_updatable.dart index 095ed78d..8a153e05 100644 --- a/lib/mixins/item_updatable.dart +++ b/lib/mixins/item_updatable.dart @@ -9,11 +9,6 @@ import '../utils/provider_extensions.dart'; /// and replacing items in lists, while allowing each screen to customize /// which lists should be updated. mixin ItemUpdatable on State { - /// Override to enable backend-aware item refresh. [updateItem] resolves - /// the right [MediaServerClient] for the item's server. When null, - /// [updateItem] is a no-op. - String? get itemServerId => null; - /// Updates a single item in the screen's list(s) after watch state changes /// /// Fetches the latest metadata with images (including clearLogo) and @@ -21,17 +16,18 @@ mixin ItemUpdatable on State { /// /// If the fetch fails, the error is silently caught and the item will /// be updated on the next full refresh. - Future updateItem(String itemId) async { + Future updateItem(MediaItem source) async { if (!mounted) return; + final serverId = source.serverId; + if (serverId == null) return; + try { - final serverId = itemServerId; - if (serverId == null) return; - final updatedItem = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(itemId); + final updatedItem = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(source.id); if (updatedItem != null) { if (!mounted) return; setState(() { - updateItemInLists(itemId, updatedItem); + updateItemInLists(source.globalKey, updatedItem); }); } } catch (e) { @@ -39,20 +35,12 @@ mixin ItemUpdatable on State { } } - /// Override this method to specify which list(s) should be updated + /// Override this method to specify which list(s) should be updated. /// - /// This method is called within [setState], so you should directly - /// modify your list(s) without calling setState again. + /// This method is called within [setState], so implementations should + /// directly modify their lists without calling [setState] again. /// - /// Example: - /// ```dart - /// @override - /// void updateItemInLists(String itemId, MediaItem updatedItem) { - /// final index = _items.indexWhere((item) => item.id == itemId); - /// if (index != -1) { - /// _items[index] = updatedItem; - /// } - /// } - /// ``` - void updateItemInLists(String itemId, MediaItem updatedItem); + /// [sourceGlobalKey] is the server-qualified identity of the item that + /// initiated the refresh. + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem); } diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index c48950bd..e20abbde 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -421,43 +421,32 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin return fetched.items; } - /// Refetch a single item (post-edit refresh from a hub row) and swap it - /// into whichever lists contain it. Items can come from any registered - /// server, so the owning server is resolved by scanning the visible lists. - Future updateItem(String itemId) async { + /// Refetch a single item (post-edit refresh from a hub row) through its + /// source server and swap it into whichever lists contain that qualified + /// identity. + Future updateItem(MediaItem source) async { + final serverId = source.serverId; + if (serverId == null) return; + try { - final serverId = _serverIdForItem(itemId); - if (serverId == null) return; - final updated = await _multiServer.getClientForServer(ServerId(serverId))?.fetchItem(itemId); + final updated = await _multiServer.getClientForServer(ServerId(serverId))?.fetchItem(source.id); if (updated == null || isDisposed) return; - _updateItemInLists(itemId, updated); + _updateItemInLists(source.globalKey, updated); safeNotifyListeners(); } catch (_) { // Silently fail — the item will refresh on the next full reload. } } - String? _serverIdForItem(String itemId) { - for (final item in _onDeck) { - if (item.id == itemId) return item.serverId; - } - for (final hub in _hubs) { - for (final item in hub.items) { - if (item.id == itemId) return item.serverId; - } - } - return null; - } - - void _updateItemInLists(String itemId, MediaItem updatedItem) { - final onDeckIndex = _onDeck.indexWhere((item) => item.id == itemId); + void _updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { + final onDeckIndex = _onDeck.indexWhere((item) => item.globalKey == sourceGlobalKey); if (onDeckIndex != -1) { _onDeck = List.of(_onDeck)..[onDeckIndex] = updatedItem; } for (var i = 0; i < _hubs.length; i++) { final hub = _hubs[i]; - final itemIndex = hub.items.indexWhere((item) => item.id == itemId); + final itemIndex = hub.items.indexWhere((item) => item.globalKey == sourceGlobalKey); if (itemIndex != -1) { final newItems = List.from(hub.items); newItems[itemIndex] = updatedItem; diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 03c700f9..6a25bedb 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -112,19 +112,6 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W _themeChannel.invokeMethod('setSplashTheme', {'mode': name}); } - String get themeModeDisplayName { - switch (_themeMode) { - case settings.ThemeMode.light: - return 'Light'; - case settings.ThemeMode.dark: - return 'Dark'; - case settings.ThemeMode.oled: - return 'OLED'; - case settings.ThemeMode.system: - return 'System'; - } - } - IconData get themeModeIcon { switch (_themeMode) { case settings.ThemeMode.light: diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index 286d37ed..e63b9608 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -60,9 +60,6 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen serverName: widget.serverName, ); - @override - String? get itemServerId => widget.serverId; - @override String get title => widget.actorName; @@ -87,9 +84,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen } @override - void updateItemInLists(String itemId, MediaItem updatedItem) { + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { for (final entry in loadedItems.entries) { - if (entry.value.id == itemId) { + if (entry.value.globalKey == sourceGlobalKey) { loadedItems[entry.key] = updatedItem; return; } diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 398a2ddb..01f14dac 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -11,6 +11,7 @@ import '../utils/provider_extensions.dart'; import '../services/media_list_playback_launcher.dart'; import '../widgets/loading_indicator_box.dart'; import '../utils/app_logger.dart'; +import '../utils/error_message_utils.dart'; import '../utils/snackbar_helper.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; @@ -98,8 +99,8 @@ abstract class BaseMediaListDetailScreen extends State } @override - void updateItemInLists(String itemId, MediaItem updatedItem) { - final index = items.indexWhere((it) => it.id == itemId); + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { + final index = items.indexWhere((item) => item.globalKey == sourceGlobalKey); if (index != -1) { items[index] = updatedItem; } @@ -169,11 +170,6 @@ mixin StandardItemLoader on BaseMediaListDetailScreen< /// Fetch items from the API (must be implemented by subclass) Future> fetchItems(); - /// Get error message for failed load (can be overridden) - String getLoadErrorMessage(Object error) { - return 'Failed to load items: ${error.toString()}'; - } - /// Get log message for successful load (can be overridden) String getLoadSuccessMessage(int itemCount) { return 'Loaded $itemCount items'; @@ -200,11 +196,11 @@ mixin StandardItemLoader on BaseMediaListDetailScreen< } appLogger.d(getLoadSuccessMessage(newItems.length)); - } catch (e) { - appLogger.e('Failed to load items', error: e); + } catch (e, stackTrace) { + final message = localizedLoadErrorMessage(e, stackTrace, context: title); if (mounted) { setState(() { - errorMessage = getLoadErrorMessage(e); + errorMessage = message; isLoading = false; }); } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index cecba182..44eb6bea 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -9,6 +9,7 @@ import '../mixins/paginated_item_loader.dart'; import '../providers/download_provider.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; +import '../utils/error_message_utils.dart'; import '../utils/download_utils.dart'; import '../utils/platform_detector.dart'; import '../utils/media_server_http_client.dart'; @@ -40,9 +41,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen widget.collection; - @override - String? get itemServerId => widget.collection.serverId; - @override String get title => widget.collection.title!; @@ -72,12 +70,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadItems() async { + String? loadErrorMessage; await loadInitialPaginatedItems( pageSize: _pageSize, resetViewState: () { @@ -97,8 +96,8 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen { builder: (context, provider, child) { if (provider.isConnected) { return IconButton( - icon: const Icon(Icons.link_off), + icon: const AppIcon(Symbols.link_off_rounded), onPressed: () async { final confirmed = await showConfirmDialog( context, @@ -74,7 +76,7 @@ class _MobileRemoteScreenState extends State { const SizedBox(height: 8), Text( t.companionRemote.remote.attemptOf(current: provider.reconnectAttempts), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: tokens(context).textMuted), ), const SizedBox(height: 32), Row( @@ -161,7 +163,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { color: Theme.of(context).colorScheme.primaryContainer, child: Row( children: [ - Icon(Icons.computer, color: Theme.of(context).colorScheme.onPrimaryContainer), + AppIcon(Symbols.computer_rounded, color: Theme.of(context).colorScheme.onPrimaryContainer), const SizedBox(width: 12), Expanded( child: Column( @@ -202,17 +204,17 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { ButtonSegment( value: 0, label: Text(t.companionRemote.remote.tabRemote), - icon: const Icon(Icons.navigation), + icon: const AppIcon(Symbols.navigation_rounded), ), ButtonSegment( value: 1, label: Text(t.companionRemote.remote.tabPlay), - icon: const Icon(Icons.play_arrow), + icon: const AppIcon(Symbols.play_arrow_rounded), ), ButtonSegment( value: 2, label: Text(t.companionRemote.remote.tabMore), - icon: const Icon(Icons.flash_on), + icon: const AppIcon(Symbols.flash_on_rounded), ), ], selected: {_selectedTab}, @@ -243,17 +245,17 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { mainAxisAlignment: .spaceEvenly, children: [ _RemoteButton( - icon: Icons.home, + icon: Symbols.home_rounded, label: t.common.home, onPressed: () => _sendCommand(RemoteCommandType.home), ), _RemoteButton( - icon: Icons.arrow_back, + icon: Symbols.arrow_back_rounded, label: t.common.back, onPressed: () => _sendCommand(RemoteCommandType.back), ), _RemoteButton( - icon: Icons.menu, + icon: Symbols.menu_rounded, label: t.companionRemote.remote.menu, onPressed: () => _sendCommand(RemoteCommandType.contextMenu), ), @@ -271,27 +273,27 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { alignment: WrapAlignment.center, children: [ _RemoteChip( - icon: Icons.explore, + icon: Symbols.explore_rounded, label: t.companionRemote.remote.tabDiscover, onPressed: () => _sendCommand(RemoteCommandType.tabDiscover), ), _RemoteChip( - icon: Icons.video_library, + icon: Symbols.video_library_rounded, label: t.companionRemote.remote.tabLibraries, onPressed: () => _sendCommand(RemoteCommandType.tabLibraries), ), _RemoteChip( - icon: Icons.search, + icon: Symbols.search_rounded, label: t.companionRemote.remote.tabSearch, onPressed: () => _showSearchSheet(switchToSearchTab: true), ), _RemoteChip( - icon: Icons.download, + icon: Symbols.download_rounded, label: t.companionRemote.remote.tabDownloads, onPressed: () => _sendCommand(RemoteCommandType.tabDownloads), ), _RemoteChip( - icon: Icons.settings, + icon: Symbols.settings_rounded, label: t.companionRemote.remote.tabSettings, onPressed: () => _sendCommand(RemoteCommandType.tabSettings), ), @@ -310,13 +312,13 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { mainAxisAlignment: .center, children: [ _RemoteButton( - icon: Icons.skip_previous, + icon: Symbols.skip_previous_rounded, label: t.companionRemote.remote.previous, onPressed: () => _sendCommand(RemoteCommandType.previousTrack), ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.play_arrow, + icon: Symbols.play_arrow_rounded, label: t.companionRemote.remote.playPause, size: 64, iconSize: 36, @@ -324,7 +326,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.skip_next, + icon: Symbols.skip_next_rounded, label: t.companionRemote.remote.next, onPressed: () => _sendCommand(RemoteCommandType.nextTrack), ), @@ -335,19 +337,19 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { mainAxisAlignment: .center, children: [ _RemoteButton( - icon: Icons.replay_10, + icon: Symbols.replay_10_rounded, label: t.companionRemote.remote.seekBack, onPressed: () => _sendCommand(RemoteCommandType.seekBackward), ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.stop, + icon: Symbols.stop_rounded, label: t.companionRemote.remote.stop, onPressed: () => _sendCommand(RemoteCommandType.stop), ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.forward_10, + icon: Symbols.forward_10_rounded, label: t.companionRemote.remote.seekForward, onPressed: () => _sendCommand(RemoteCommandType.seekForward), ), @@ -360,19 +362,19 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { mainAxisAlignment: .center, children: [ _RemoteButton( - icon: Icons.volume_off, + icon: Symbols.volume_off_rounded, label: t.common.mute, onPressed: () => _sendCommand(RemoteCommandType.volumeMute), ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.volume_down, + icon: Symbols.volume_down_rounded, label: t.companionRemote.remote.volumeDown, onPressed: () => _sendCommand(RemoteCommandType.volumeDown), ), const SizedBox(width: 16), _RemoteButton( - icon: Icons.volume_up, + icon: Symbols.volume_up_rounded, label: t.companionRemote.remote.volumeUp, onPressed: () => _sendCommand(RemoteCommandType.volumeUp), ), @@ -393,20 +395,21 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { runSpacing: 12, alignment: WrapAlignment.center, children: [ - if (!isPlayerActive) _RemoteCard(icon: Icons.search, label: t.common.search, onPressed: _showSearchSheet), + if (!isPlayerActive) + _RemoteCard(icon: Symbols.search_rounded, label: t.common.search, onPressed: _showSearchSheet), if (isPlayerActive) ...[ _RemoteCard( - icon: Icons.fullscreen, + icon: Symbols.fullscreen_rounded, label: t.companionRemote.remote.fullscreen, onPressed: () => _sendCommand(RemoteCommandType.fullscreen), ), _RemoteCard( - icon: Icons.subtitles, + icon: Symbols.subtitles_rounded, label: t.companionRemote.remote.subtitles, onPressed: () => _sendCommand(RemoteCommandType.subtitles), ), _RemoteCard( - icon: Icons.audiotrack, + icon: Symbols.audiotrack_rounded, label: t.companionRemote.remote.audio, onPressed: () => _sendCommand(RemoteCommandType.audioTracks), ), @@ -442,22 +445,22 @@ class _DPad extends StatelessWidget { ), _DPadZone( startAngle: -135, - icon: Icons.keyboard_arrow_up, + icon: Symbols.keyboard_arrow_up_rounded, onTap: () => onCommand(RemoteCommandType.dpadUp), ), _DPadZone( startAngle: -45, - icon: Icons.keyboard_arrow_right, + icon: Symbols.keyboard_arrow_right_rounded, onTap: () => onCommand(RemoteCommandType.dpadRight), ), _DPadZone( startAngle: 45, - icon: Icons.keyboard_arrow_down, + icon: Symbols.keyboard_arrow_down_rounded, onTap: () => onCommand(RemoteCommandType.dpadDown), ), _DPadZone( startAngle: 135, - icon: Icons.keyboard_arrow_left, + icon: Symbols.keyboard_arrow_left_rounded, onTap: () => onCommand(RemoteCommandType.dpadLeft), ), Center(child: _DPadCenter(onTap: () => onCommand(RemoteCommandType.select))), @@ -512,7 +515,7 @@ class _DPadZoneState extends State<_DPadZone> { duration: tokens(context).fast, color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary, alignment: widget.iconAlignment, - child: Icon(widget.icon, size: 28, color: colors.onPrimary), + child: AppIcon(widget.icon, size: 28, color: colors.onPrimary), ), ), ), @@ -626,7 +629,7 @@ class _RemoteButton extends StatelessWidget { onPressed(); }, style: FilledButton.styleFrom(padding: .zero, shape: const CircleBorder()), - child: Icon(icon, size: iconSize), + child: AppIcon(icon, size: iconSize), ), ), const SizedBox(height: 4), @@ -650,7 +653,7 @@ class _RemoteChip extends StatelessWidget { HapticFeedback.lightImpact(); onPressed(); }, - icon: Icon(icon, size: 18), + icon: AppIcon(icon, size: 18), label: Text(label), ); } @@ -689,8 +692,11 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerD decoration: pillInputDecoration( context, hintText: t.companionRemote.remote.searchHint, - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)), + prefixIcon: const AppIcon(Symbols.search_rounded), + suffixIcon: IconButton( + icon: const AppIcon(Symbols.send_rounded), + onPressed: () => _submit(_controller.text), + ), ), onSubmitted: _submit, ), @@ -723,7 +729,7 @@ class _RemoteCard extends StatelessWidget { child: Column( mainAxisAlignment: .center, children: [ - Icon(icon, size: 32), + AppIcon(icon, size: 32), const SizedBox(height: 8), Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center), ], diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 3f49b52c..93f22b30 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -9,7 +9,6 @@ import '../widgets/server_activities_button.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; -import '../focus/focusable_button.dart'; import '../focus/hub_vertical_navigation.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; @@ -57,6 +56,7 @@ import '../utils/layout_constants.dart'; import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; import 'libraries/content_state_builder.dart'; +import 'libraries/state_messages.dart'; import 'main_screen.dart'; import 'settings/settings_screen.dart'; import '../watch_together/watch_together.dart'; @@ -87,10 +87,7 @@ class _DiscoverScreenState extends State bool get _hasMoreContinueWatching => _discover.hasMoreContinueWatching; bool get _isLoading => _discover.isLoading; bool get _areHubsLoading => _discover.areHubsLoading; - String? get _errorMessage { - final raw = _discover.errorMessage; - return raw == null ? null : t.errors.failedToLoad(context: t.discover.title, error: raw); - } + String? get _errorMessage => _discover.errorMessage == null ? null : t.errors.unableToLoad(context: t.discover.title); bool _switchingProfile = false; final PageController _heroController = PageController(); @@ -1115,19 +1112,10 @@ class _DiscoverScreenState extends State ), if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: .center, - children: [ - const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.discover.noContentAvailable), - const SizedBox(height: 8), - Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)), - ], - ), - ), + SliverEmptyState( + message: t.discover.noContentAvailable, + subtitle: t.discover.addMediaToLibraries, + icon: Symbols.movie_rounded, ), SliverToBoxAdapter(child: SizedBox(height: 24 + bottomPadding)), @@ -1195,35 +1183,18 @@ class _DiscoverScreenState extends State children: [ if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()), if (_errorMessage != null) - Center( - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.error_outline_rounded, fill: 1, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - FocusableButton( - autofocus: true, - onPressed: _discover.load, - useBackgroundFocus: true, - child: FilledButton(onPressed: _discover.load, child: Text(t.common.retry)), - ), - ], - ), + ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _discover.load, + actionAutofocus: true, + actionUseBackgroundFocus: true, ), if (!_isLoading && _errorMessage == null && browseHubs.isEmpty && !_areHubsLoading) - Center( - child: Column( - mainAxisAlignment: .center, - children: [ - const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(t.discover.noContentAvailable), - const SizedBox(height: 8), - Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)), - ], - ), + EmptyStateWidget( + message: t.discover.noContentAvailable, + subtitle: t.discover.addMediaToLibraries, + icon: Symbols.movie_rounded, ), if (browseHubs.isNotEmpty) Positioned( @@ -1550,13 +1521,20 @@ class _DiscoverScreenState extends State child: Builder( builder: (context) { final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final targetWidth = (heroLogoWidth * dpr).round(); + final targetHeight = (heroLogoHeight * dpr).round(); + final (memCacheWidth, memCacheHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: targetWidth, + displayHeight: targetHeight, + imageType: ImageType.heroLogo, + ); final logoUrl = MediaImageHelper.getOptimizedImageUrl( client: heroClient, thumbPath: heroItem.clearLogoPath, maxWidth: heroLogoWidth, maxHeight: heroLogoHeight, devicePixelRatio: dpr, - imageType: ImageType.logo, + imageType: ImageType.heroLogo, ); return blurArtwork( @@ -1565,7 +1543,8 @@ class _DiscoverScreenState extends State cacheManager: PlexImageCacheManager.instance, filterQuality: FilterQuality.medium, fit: BoxFit.contain, - memCacheWidth: (heroLogoWidth * dpr).clamp(200, isTv ? 1000 : 800).round(), + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, placeholder: (context, url) => const SizedBox.shrink(), errorBuilder: (context, error, stackTrace) { diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index ee22db2d..ce9f3b01 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -20,11 +20,11 @@ import '../../widgets/settings_builder.dart'; import '../../utils/global_key_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; import '../../mixins/refreshable.dart'; -import '../../utils/grid_size_calculator.dart'; +import '../../utils/media_image_helper.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/focusable_media_card.dart'; -import '../../widgets/media_grid_delegate.dart'; +import '../../widgets/media_card_sliver_layout.dart'; import '../../widgets/download_tree_view.dart'; import '../main_screen.dart'; import '../libraries/state_messages.dart'; @@ -153,7 +153,7 @@ class DownloadsScreenState extends State onNavigateDown: _focusCurrentTab, actions: [ FocusableAction( - icon: Symbols.rule_settings, + icon: Symbols.rule_settings_rounded, tooltip: t.downloads.activeSyncRules, onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SyncRulesScreen())), @@ -306,50 +306,37 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { const effectivePadding = EdgeInsets.only(left: 8, right: 8, top: 8); return SettingsBuilder( - prefs: const [SettingsService.libraryDensity, SettingsService.tvFullCardLayout], + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], builder: (context) { final settings = SettingsService.instance; + final viewMode = settings.read(SettingsService.viewMode); final density = settings.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); - final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, density); - // Use LayoutBuilder to get actual available width (accounting for sidebar) - return LayoutBuilder( - builder: (context, constraints) { - final availableWidth = constraints.maxWidth - effectivePadding.left - effectivePadding.right; - final gridSpacing = MediaGridDelegate.spacingFor(context: context, fullBleedImage: fullCardLayout); - final columnCount = GridSizeCalculator.getColumnCount( - availableWidth, - maxCrossAxisExtent, - crossAxisSpacing: gridSpacing, - ); - return GridView.builder( - addAutomaticKeepAlives: false, - addSemanticIndexes: false, - padding: effectivePadding, - // Allow focus decoration to render outside scroll bounds - clipBehavior: Clip.none, - gridDelegate: MediaGridDelegate.createDelegate( - context: context, - density: density, - fullBleedImage: fullCardLayout, - ), + return CustomScrollView( + // Allow focus decoration to render outside scroll bounds. + clipBehavior: Clip.none, + slivers: [ + MediaCardSliverLayout( + viewMode: viewMode, itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); - final isFirst = index == 0; + density: density, + padding: effectivePadding, + fullBleedImage: fullCardLayout, + itemBuilder: (context, position) { + final item = items[position.index]; return FocusableMediaCard( item: item, - focusNode: isFirst ? _firstItemFocusNode : null, + focusNode: position.index == 0 ? _firstItemFocusNode : null, + disableScale: position.disableScale, onBack: widget.onBack, isOffline: true, // Downloaded content works without server - fullBleedImage: fullCardLayout, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + fullBleedImage: fullCardLayout && position.isGrid, + onNavigateLeft: position.isFirstColumn ? _navigateToSidebar : null, ); }, - ); - }, + ), + ], ); }, ); @@ -437,7 +424,22 @@ class _DownloadedMusicContentState extends State<_DownloadedMusicContent> { final artist = album.albumArtistTitle; final serverId = album.serverId; final localArt = serverId == null ? null : provider.getArtworkLocalPath(ServerId(serverId), album.thumbPath); - + final ImageProvider? localCoverImage; + if (localArt == null) { + localCoverImage = null; + } else { + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (48 * dpr).round(), + displayHeight: (48 * dpr).round(), + imageType: ImageType.square, + ); + localCoverImage = MediaImageHelper.boundedDecode( + FileImage(File(localArt)), + memWidth: memWidth, + memHeight: memHeight, + ); + } Widget fallbackCover() => Container( width: 48, height: 48, @@ -451,9 +453,9 @@ class _DownloadedMusicContentState extends State<_DownloadedMusicContent> { children: [ ClipRRect( borderRadius: BorderRadius.circular(tk.radiusSm), - child: localArt != null - ? Image.file( - File(localArt), + child: localCoverImage != null + ? Image( + image: localCoverImage, width: 48, height: 48, fit: BoxFit.cover, diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index 097f6dfa..3797c86a 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -14,6 +14,7 @@ import '../../services/sync_rule_executor.dart'; import '../../utils/content_utils.dart'; import '../../utils/download_utils.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/app_icon.dart'; import '../libraries/state_messages.dart'; import '../../i18n/strings.g.dart'; @@ -247,7 +248,7 @@ class _SyncRuleTileState extends State<_SyncRuleTile> { dense: true, visualDensity: const VisualDensity(vertical: -3), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), + leading: AppIcon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), title: Text(title, maxLines: 1, overflow: .ellipsis), subtitle: Column( crossAxisAlignment: .start, @@ -334,7 +335,7 @@ class _SwipeRevealDeleteActionState extends State<_SwipeRevealDeleteAction> { child: Column( mainAxisAlignment: .center, children: [ - Icon(Symbols.delete_rounded, color: colorScheme.onError, size: 20), + AppIcon(Symbols.delete_rounded, color: colorScheme.onError, size: 20), const SizedBox(height: 2), Text( t.common.delete, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 8d8716be..cf831adb 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -11,6 +11,7 @@ import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/settings_builder.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_card_sliver_layout.dart'; +import '../widgets/overlay_sheet.dart'; import '../widgets/skeleton_media_card.dart'; /// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a @@ -89,25 +90,24 @@ mixin FocusableDetailScreenMixin on State, GridFocu FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'detail_grid_item'); - /// Wrap [slivers] in the standard detail-screen scaffold — PopScope that - /// defers to [handleBackNavigation], plus a Scaffold with a CustomScrollView - /// bound as the primary scroll view. Callers build the slivers themselves - /// (typically `[appBar, ...header, ...buildStateSlivers(), grid]`). + /// Wrap [slivers] in the standard detail-screen scaffold — an overlay-sheet + /// host that defers route back to [handleBackNavigation], plus a Scaffold + /// with a CustomScrollView bound as the primary scroll view. Callers build + /// the slivers themselves (typically + /// `[appBar, ...header, ...buildStateSlivers(), grid]`). Widget buildDetailScaffold({required List slivers}) { - return PopScope( - canPop: PlatformDetector.isHandheldIOS(context), - onPopInvokedWithResult: (didPop, result) { - if (BackKeyCoordinator.consumeIfHandled()) return; - if (didPop) return; - final shouldPop = handleBackNavigation(); - if (shouldPop && mounted) { - Navigator.pop(context); - } - }, - child: PrimaryScrollController( + return PrimaryScrollController( + controller: scrollController, + child: IosStatusBarTapScrollToTop( controller: scrollController, - child: IosStatusBarTapScrollToTop( - controller: scrollController, + child: OverlaySheetHost( + canPop: PlatformDetector.isHandheldIOS(context), + onSystemBack: () { + if (BackKeyCoordinator.consumeIfHandled()) return; + if (handleBackNavigation() && mounted) { + Navigator.pop(context); + } + }, child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)), ), ), @@ -165,7 +165,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// for album grids); null keeps the stock poster geometry. Widget buildFocusableGrid({ required List items, - required void Function(String itemId) onRefresh, + required void Function(MediaItem source) onRefresh, String? collectionId, VoidCallback? onListRefresh, CardShape? shape, @@ -216,7 +216,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu Widget buildSparseFocusableGrid({ required int totalItems, required MediaItem? Function(int index) itemAt, - required void Function(String itemId) onRefresh, + required void Function(MediaItem source) onRefresh, void Function(int index)? onSkeletonVisible, String? collectionId, VoidCallback? onListRefresh, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 42496b8b..7b7c233a 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -14,6 +14,7 @@ import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; import '../utils/app_logger.dart'; import '../utils/continuation_pagination_coordinator.dart'; +import '../utils/error_message_utils.dart'; import '../utils/platform_detector.dart'; import '../utils/plex_library_section_utils.dart'; import '../utils/provider_extensions.dart'; @@ -325,11 +326,11 @@ class _HubDetailScreenState extends State } appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}'); - } catch (e) { - appLogger.e('Failed to load hub content', error: e); + } catch (e, stackTrace) { + final message = localizedLoadErrorMessage(e, stackTrace, context: widget.hub.title); if (!mounted) return; setState(() { - _errorMessage = t.messages.errorLoading(error: e.toString()); + _errorMessage = message; _isLoading = false; }); } @@ -383,30 +384,22 @@ class _HubDetailScreenState extends State return items.where((item) => int.tryParse(item.libraryId ?? '') == sectionFilter).toList(); } - Future _handleItemRefresh(String ratingKey) async { - final itemIndex = _items.indexWhere((item) => item.id == ratingKey); - final filteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey); - final existing = itemIndex != -1 - ? _items[itemIndex] - : filteredIndex != -1 - ? _filteredItems[filteredIndex] - : null; - if (existing == null) return; - final serverId = existing.serverId ?? widget.hub.serverId; + Future _handleItemRefresh(MediaItem source) async { + final serverId = source.serverId; if (serverId == null) return; try { - final updated = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(ratingKey); + final updated = await context.tryGetMediaClientForServer(ServerId(serverId))?.fetchItem(source.id); if (updated == null || !mounted) return; setState(() { - final currentItemIndex = _items.indexWhere((item) => item.id == ratingKey); + final currentItemIndex = _items.indexWhere((item) => item.globalKey == source.globalKey); if (currentItemIndex != -1) _items[currentItemIndex] = updated; - final currentFilteredIndex = _filteredItems.indexWhere((item) => item.id == ratingKey); + final currentFilteredIndex = _filteredItems.indexWhere((item) => item.globalKey == source.globalKey); if (currentFilteredIndex != -1) _filteredItems[currentFilteredIndex] = updated; }); if (_selectedSort != null) _applySort(); } catch (e) { - appLogger.d('Item refresh skipped for: $ratingKey', error: e); + appLogger.d('Item refresh skipped for: ${source.globalKey}', error: e); } } diff --git a/lib/screens/libraries/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart index 67bee552..d22a40c2 100644 --- a/lib/screens/libraries/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -159,6 +159,10 @@ class _AlphaJumpBarState extends State { } }); if (selectResult != KeyEventResult.ignored) return selectResult; + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) return backResult; + } if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; @@ -182,12 +186,6 @@ class _AlphaJumpBarState extends State { widget.onNavigateLeft?.call(); return KeyEventResult.handled; } - if (event.logicalKey == LogicalKeyboardKey.escape || - event.logicalKey == LogicalKeyboardKey.goBack || - event.logicalKey == LogicalKeyboardKey.gameButtonB) { - widget.onBack?.call(); - return KeyEventResult.handled; - } return KeyEventResult.ignored; } diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index 23ac8d13..d0c7f2d1 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -14,6 +14,8 @@ class SliverErrorState extends StatelessWidget { final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; + final bool actionAutofocus; + final bool actionUseBackgroundFocus; const SliverErrorState({ super.key, @@ -26,6 +28,8 @@ class SliverErrorState extends StatelessWidget { this.onActionNavigateLeft, this.onActionNavigateRight, this.onActionBack, + this.actionAutofocus = false, + this.actionUseBackgroundFocus = false, }); @override @@ -34,13 +38,15 @@ class SliverErrorState extends StatelessWidget { message: message, icon: Symbols.error_outline_rounded, onRetry: onRetry, - retryLabel: retryLabel ?? t.common.retry, + retryLabel: retryLabel, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, + actionAutofocus: actionAutofocus, + actionUseBackgroundFocus: actionUseBackgroundFocus, ), ); } diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index af21dd70..155e15d7 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:ui'; import '../../media/ids.dart'; @@ -19,7 +18,6 @@ import '../../utils/provider_extensions.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../../widgets/watched_indicator.dart'; import '../../theme/mono_tokens.dart'; import '../../i18n/strings.g.dart'; @@ -42,7 +40,7 @@ class FolderTreeItem extends StatefulWidget { final FocusNode? focusNode; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateLeft; - final void Function(String itemId)? onRefresh; + final void Function(MediaItem source)? onRefresh; final VoidCallback? onListRefresh; final String? serverId; @@ -150,23 +148,13 @@ class _FolderTreeItemState extends State with ContextMenuTapMixi final previousFocus = FocusManager.instance.primaryFocus; final position = lastTapPosition; final fromKeyboard = position == null; - final useBottomSheet = Platform.isIOS || Platform.isAndroid; - - String? selected; - if (useBottomSheet) { - selected = await OverlaySheetController.showAdaptive( - context, - showDragHandle: true, - builder: (context) => AppMenuSheet(title: _rowTitle(), entries: entries, focusFirstItem: fromKeyboard), - ); - } else { - selected = await showAppMenu( - context, - entries: entries, - position: position ?? _rowCenter(), - focusFirstItem: fromKeyboard, - ); - } + final selected = await showAdaptiveAppMenu( + context, + title: _rowTitle(), + entries: entries, + position: position ?? _rowCenter(), + focusFirstItem: fromKeyboard, + ); if (!mounted) return; diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 76ca64bb..bfde4f39 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -22,7 +22,7 @@ class FolderTreeView extends StatefulWidget { final String libraryKey; final String? serverId; // Server this library belongs to final MediaKind? libraryKind; - final void Function(String)? onRefresh; + final void Function(MediaItem source)? onRefresh; final FocusNode? firstItemFocusNode; final VoidCallback? onNavigateUp; final VoidCallback? onNavigateLeft; @@ -117,11 +117,12 @@ class FolderTreeViewState extends State { }); appLogger.d('Loaded ${folders.length} root folders'); - } catch (e) { + } catch (e, stackTrace) { if (!mounted || epoch != _loadEpoch) return; + final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders); setState(() { - _errorMessage = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); + _errorMessage = message; _isLoadingRoot = false; }); } @@ -181,10 +182,10 @@ class FolderTreeViewState extends State { }); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); - } catch (e) { + } catch (e, stackTrace) { if (!mounted || epoch != _loadEpoch) return; - final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); + final message = localizedLoadErrorMessage(e, stackTrace, context: t.libraries.folders); setState(() { _loadingFolders.remove(folderIdentity); // Drop partial pages so a retry refetches instead of leaving a @@ -228,14 +229,14 @@ class FolderTreeViewState extends State { _loadFolderChildren(parent); } - Future _handleItemTap(MediaItem item) async { + Future _handleItemTap(MediaItem item, MediaItem? parent) async { final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); if (!mounted) return; switch (result) { case MediaNavigationResult.unsupported: showAppSnackBar(context, t.messages.musicNotSupported); case MediaNavigationResult.listRefreshNeeded: - widget.onRefresh?.call(item.id); + _refreshAfterDeletion(parent); case MediaNavigationResult.navigated: case MediaNavigationResult.librarySelected: break; @@ -398,7 +399,7 @@ class FolderTreeViewState extends State { isLoading: isLoading, serverId: widget.serverId, onExpand: isExpandable ? () => _toggleFolder(item) : null, - onTap: !isExpandable ? () => _handleItemTap(item) : null, + onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null, onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 9067898e..b6ceff47 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -512,7 +512,7 @@ class _LibrariesScreenState extends State } @override - void updateItemInLists(String itemId, MediaItem updatedItem) { + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { // Delegate to the active tab — parent doesn't maintain its own item list } diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index d97d1e41..cf6bf3fe 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; +import '../../i18n/strings.g.dart'; /// Base widget for displaying state messages (empty, error, etc.) /// Provides a consistent UI pattern for showing icons, messages, and actions @@ -43,6 +44,12 @@ class StateMessageWidget extends StatelessWidget { final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; + /// Whether the action button should request focus when it appears. + final bool actionAutofocus; + + /// Whether the action button uses a background focus indicator. + final bool actionUseBackgroundFocus; + const StateMessageWidget({ super.key, required this.message, @@ -61,6 +68,8 @@ class StateMessageWidget extends StatelessWidget { this.onActionNavigateRight, this.onActionBack, this.actionIcon, + this.actionAutofocus = false, + this.actionUseBackgroundFocus = false, }); @override @@ -106,6 +115,8 @@ class StateMessageWidget extends StatelessWidget { onNavigateRight: onActionNavigateRight, onBack: onActionBack, onPressed: onAction, + autofocus: actionAutofocus, + useBackgroundFocus: actionUseBackgroundFocus, child: FilledButton.icon( onPressed: onAction, icon: AppIcon(actionIcon ?? Symbols.refresh_rounded, fill: 1), @@ -197,6 +208,12 @@ class ErrorStateWidget extends StatelessWidget { /// Optional callback for retry action final VoidCallback? onRetry; + /// Whether the retry action should request focus when it appears. + final bool actionAutofocus; + + /// Whether the retry action uses a background focus indicator. + final bool actionUseBackgroundFocus; + /// Optional label for the retry button final String? retryLabel; final FocusNode? actionFocusNode; @@ -218,6 +235,8 @@ class ErrorStateWidget extends StatelessWidget { this.onActionNavigateLeft, this.onActionNavigateRight, this.onActionBack, + this.actionAutofocus = false, + this.actionUseBackgroundFocus = false, }); @override @@ -228,7 +247,7 @@ class ErrorStateWidget extends StatelessWidget { iconColor: Theme.of(context).colorScheme.error, textColor: Theme.of(context).colorScheme.error, onAction: onRetry, - actionLabel: retryLabel ?? 'Retry', + actionLabel: retryLabel ?? t.common.retry, actionIcon: Symbols.refresh_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, @@ -236,6 +255,8 @@ class ErrorStateWidget extends StatelessWidget { onActionNavigateLeft: onActionNavigateLeft, onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, + actionAutofocus: actionAutofocus, + actionUseBackgroundFocus: actionUseBackgroundFocus, ); } } diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 797b7a91..a9a134a3 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../media/media_library.dart'; -import '../../../utils/app_logger.dart'; +import '../../../utils/error_message_utils.dart'; import '../../../mixins/library_tab_state.dart'; import '../../../mixins/refreshable.dart'; import '../content_state_builder.dart'; @@ -246,12 +246,12 @@ abstract class BaseLibraryTabState> extends State widget.onDataLoaded!(); }); } - } catch (e) { + } catch (e, stackTrace) { + final message = localizedLoadErrorMessage(e, stackTrace, context: errorContext); if (!mounted) return; - appLogger.e('Error loading $errorContext', error: e); setState(() { - _errorMessage = 'Failed to load $errorContext: ${e.toString()}'; + _errorMessage = message; _isLoading = false; }); } diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 3b3a53f0..5db9215a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -4,7 +4,6 @@ import '../../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import 'package:cached_network_image_ce/cached_network_image.dart'; import '../../../media/library_first_character.dart'; import '../../../media/library_query.dart'; import '../../../media/media_backend.dart'; @@ -12,13 +11,11 @@ import '../../../media/media_item.dart'; import '../../../media/media_kind.dart'; import '../../../providers/multi_server_provider.dart'; import '../../../utils/media_server_http_client.dart'; -import '../../../exceptions/media_server_exceptions.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../media/media_filter.dart'; import '../../../media/media_sort.dart'; import '../../../widgets/settings_builder.dart'; -import '../../../services/image_cache_service.dart'; import '../../../services/library_query_translator.dart'; import '../../../services/plex_constants.dart'; import '../../../utils/error_message_utils.dart'; @@ -108,9 +105,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState, SkeletonUpgradeScheduler { - @override - String? get itemServerId => widget.library.serverId; - String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey); @override @@ -161,7 +155,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState totalSize; @override - void updateItemInLists(String itemId, MediaItem updatedMetadata) { + void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) { for (final entry in loadedItems.entries) { - if (entry.value.id == itemId) { + if (entry.value.globalKey == sourceGlobalKey) { loadedItems[entry.key] = updatedMetadata; break; } @@ -600,10 +594,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? scaledWidth.round() : 0, displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, imageType: imageType, ); precacheImage( - CachedNetworkImageProvider( - imageUrl, - cacheManager: PlexImageCacheManager.instance, - headers: const {'User-Agent': 'Plezy'}, - maxHeight: memHeight, - ), + MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: memWidth, memHeight: memHeight), context, ).ignore(); } diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 485a91f4..6756e133 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -6,7 +6,7 @@ import '../../../media/media_item.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../mixins/paginated_item_loader.dart'; import '../../../services/settings_service.dart'; -import '../../../utils/app_logger.dart'; +import '../../../utils/error_message_utils.dart'; import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; @@ -79,6 +79,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { + String? loadErrorMessage; await loadInitialPaginatedItems( pageSize: _pageSize, resetViewState: () { @@ -90,8 +91,8 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { + String? loadErrorMessage; await loadInitialPaginatedItems( pageSize: _pageSize, resetViewState: () { @@ -95,8 +96,8 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState widget.library.serverId; - @override String? get watchStateServerId => widget.library.serverId; @@ -106,12 +103,12 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState item.id == itemId); + final itemIndex = hub.items.indexWhere((item) => item.globalKey == sourceGlobalKey); if (itemIndex != -1) { final newItems = List.from(hub.items); newItems[itemIndex] = updatedItem; @@ -131,16 +128,16 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState{}; + final refreshItems = {}; for (final hub in items) { for (final item in hub.items) { if (affectedIds.contains(item.id)) { - refreshIds.add(item.id); + refreshItems[item.globalKey] = item; } } } - for (final itemId in refreshIds) { - unawaited(updateItem(itemId)); + for (final item in refreshItems.values) { + unawaited(updateItem(item)); } } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index ee98bf71..3a908d8e 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -7,7 +7,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../media/live_tv_support.dart'; import '../../media/media_server_client.dart'; @@ -19,13 +18,14 @@ import '../../providers/multi_server_provider.dart'; import '../../services/settings_service.dart'; import '../../widgets/settings_builder.dart'; import '../../utils/app_logger.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../utils/serial_future_queue.dart'; import '../../utils/snackbar_helper.dart'; -import '../../widgets/app_icon.dart'; import '../../widgets/focusable_tab_chip.dart'; import '../../widgets/overlay_sheet.dart'; +import '../libraries/state_messages.dart'; import 'reorder_favorites_sheet.dart'; import 'tabs/guide_tab.dart'; import 'tabs/recordings_tab.dart'; @@ -420,12 +420,12 @@ class _LiveTvScreenState extends State if (mounted) _focusCurrentTab(); }); } - } catch (e) { - appLogger.e('Failed to load Live TV channels', error: e); + } catch (e, stackTrace) { + final message = localizedLoadErrorMessage(e, stackTrace, context: t.liveTv.title); if (mounted) { setState(() { _isLoading = false; - _error = e.toString(); + _error = message; }); } } @@ -633,7 +633,6 @@ class _LiveTvScreenState extends State @override Widget build(BuildContext context) { - final theme = Theme.of(context); final useSideNav = PlatformDetector.shouldUseSideNavigation(context); final isRecordings = _currentTab == LiveTvTab.recordings; @@ -674,7 +673,7 @@ class _LiveTvScreenState extends State ), ]), ), - body: _buildLiveTvBody(theme, useSideNav), + body: _buildLiveTvBody(useSideNav), ); } @@ -698,31 +697,17 @@ class _LiveTvScreenState extends State }; } - Widget _buildLiveTvBody(ThemeData theme, bool useSideNav) { + Widget _buildLiveTvBody(bool useSideNav) { if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return Center( - child: Column( - mainAxisSize: .min, - children: [ - AppIcon(Symbols.error_rounded, size: 48, color: theme.colorScheme.error), - const SizedBox(height: 16), - Text(_error!, style: theme.textTheme.bodyLarge), - const SizedBox(height: 16), - FocusableButton( - autofocus: true, - onPressed: _loadChannels, - useBackgroundFocus: true, - child: FilledButton.icon( - onPressed: _loadChannels, - icon: const AppIcon(Symbols.refresh_rounded), - label: Text(t.common.retry), - ), - ), - ], - ), + return ErrorStateWidget( + message: _error!, + icon: Symbols.error_rounded, + onRetry: _loadChannels, + actionAutofocus: true, + actionUseBackgroundFocus: true, ); } if (_channels.isEmpty && !_visibleTabs.contains(LiveTvTab.recordings)) { diff --git a/lib/screens/livetv/live_tv_show_schedule_screen.dart b/lib/screens/livetv/live_tv_show_schedule_screen.dart index 541c8e72..94496413 100644 --- a/lib/screens/livetv/live_tv_show_schedule_screen.dart +++ b/lib/screens/livetv/live_tv_show_schedule_screen.dart @@ -122,6 +122,7 @@ class _LiveTvShowScheduleScreenState extends State child: Builder( builder: (hostContext) => FocusedScrollScaffold( title: Text(widget.showTitle), + focusableAppBarActions: true, actions: showRecord ? [ FocusableActionBar( diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 1b9409bc..5158fd02 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -18,6 +18,7 @@ import '../services/tvos_system_navigation_service.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; import '../widgets/auth_error_banner.dart'; +import '../widgets/app_icon.dart'; import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; @@ -1880,7 +1881,7 @@ class _MainScreenState extends State ), ) else - Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary), + AppIcon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 8), Text( t.common.reconnect, diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index edfde822..71af4df3 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -424,7 +424,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { return MediaContextMenu( key: _contextMenuKey, item: metadata, - onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)), + onRefresh: (source) => unawaited(_refreshItemInPlace(source)), onPlayTrailer: onPlayTrailer, child: Builder( builder: (buttonContext) => IconButton.filledTonal( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 99739910..9f1ac7b5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -39,6 +39,7 @@ import '../media/paged_media_list_state.dart'; import '../widgets/media_card.dart'; import '../widgets/media_rating_badge.dart'; import '../i18n/strings.g.dart'; +import '../theme/mono_tokens.dart'; import '../widgets/optimized_media_image.dart'; import '../utils/media_image_helper.dart'; import '../utils/media_quality_labels.dart'; @@ -453,45 +454,48 @@ class _MediaDetailScreenState extends State ); } - void _patchItemEverywhere(MediaItem item) { + void _patchItemEverywhere(String sourceGlobalKey, MediaItem item) { final base = _fullMetadata ?? widget.metadata; - if (base.id == item.id) { + if (base.globalKey == sourceGlobalKey) { _fullMetadata = _normalizeRefreshedItem(item, base); } final onDeckEpisode = _onDeckEpisode; - if (onDeckEpisode != null && onDeckEpisode.id == item.id) { + if (onDeckEpisode != null && onDeckEpisode.globalKey == sourceGlobalKey) { _onDeckEpisode = _normalizeRefreshedItem(item, onDeckEpisode); } - _patchItemInList(_seasons, item); - _patchItemInList(_episodes, item); + _patchItemInList(_seasons, sourceGlobalKey, item); + _patchItemInList(_episodes, sourceGlobalKey, item); _syncFlattenEpisodeState(); - _seasonEpisodePager.patchEpisode(item.id, (existing) => _normalizeRefreshedItem(item, existing)); + _seasonEpisodePager.patchEpisode( + item.id, + (existing) => existing.globalKey == sourceGlobalKey ? _normalizeRefreshedItem(item, existing) : existing, + ); final extras = _extras; if (extras != null) { - _patchItemInList(extras, item); + _patchItemInList(extras, sourceGlobalKey, item); } - _relatedHubs = _patchItemInHubs(_relatedHubs, item); + _relatedHubs = _patchItemInHubs(_relatedHubs, sourceGlobalKey, item); } - void _patchItemInList(List items, MediaItem item) { + void _patchItemInList(List items, String sourceGlobalKey, MediaItem item) { for (var i = 0; i < items.length; i++) { - if (items[i].id == item.id) { + if (items[i].globalKey == sourceGlobalKey) { items[i] = _normalizeRefreshedItem(item, items[i]); } } } - List _patchItemInHubs(List hubs, MediaItem item) { + List _patchItemInHubs(List hubs, String sourceGlobalKey, MediaItem item) { var changed = false; final updatedHubs = []; for (final hub in hubs) { var hubChanged = false; final items = List.of(hub.items); for (var i = 0; i < items.length; i++) { - if (items[i].id == item.id) { + if (items[i].globalKey == sourceGlobalKey) { items[i] = _normalizeRefreshedItem(item, items[i]); hubChanged = true; } @@ -502,18 +506,20 @@ class _MediaDetailScreenState extends State return changed ? updatedHubs : hubs; } - Future _refreshItemInPlace(String itemId) async { - final client = _getMediaClientForMetadata(context); + Future _refreshItemInPlace(MediaItem source) async { + final serverId = source.serverId; + if (serverId == null) return; + final client = context.tryGetMediaClientForServer(ServerId(serverId)); if (client == null) return; try { - final refreshed = await client.fetchItem(itemId); + final refreshed = await client.fetchItem(source.id); if (refreshed == null || !mounted) return; setStateIfMounted(() { - _patchItemEverywhere(refreshed); + _patchItemEverywhere(source.globalKey, refreshed); }); } catch (e) { - appLogger.d('Item refresh failed for $itemId', error: e); + appLogger.d('Item refresh failed for ${source.globalKey}', error: e); } } @@ -2305,9 +2311,9 @@ class _MediaDetailScreenState extends State child: MediaContextMenu( key: contextMenuKey, item: season, - onRefresh: (itemId) { + onRefresh: (source) { _watchStateChanged = true; - unawaited(_refreshItemInPlace(itemId)); + unawaited(_refreshItemInPlace(source)); }, onListRefresh: () { if (widget.isOffline) { @@ -2542,7 +2548,7 @@ class _MediaDetailScreenState extends State return Padding( padding: const EdgeInsets.all(32), child: Center( - child: Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey)), + child: Text(message, style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: tokens(context).textMuted)), ), ); } @@ -2608,7 +2614,7 @@ class _MediaDetailScreenState extends State context, metadata: episode, isOffline: widget.isOffline, - onRefresh: () => unawaited(_refreshItemInPlace(episode.id)), + onRefresh: () => unawaited(_refreshItemInPlace(episode)), ); }, onRefresh: widget.isOffline ? null : _refreshItemInPlace, @@ -3388,7 +3394,7 @@ class _MediaDetailScreenState extends State hubs: detailHubs, iconForHub: _getTvDetailHubIcon, onFocusedHubItemChanged: _handleTvDetailFocusedRailItemChanged, - onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)), + onRefresh: (source) => unawaited(_refreshItemInPlace(source)), onActiveHubChanged: _handleTvDetailHubChanged, onActivateItem: _handleTvDetailRailItemActivated, trailingForHub: _tvDetailTrailingState, @@ -3661,20 +3667,27 @@ class _MediaDetailScreenState extends State artworkPaths: [metadata.clearLogoPath], fit: BoxFit.contain, alignment: .centerLeft, - imageType: ImageType.logo, + imageType: ImageType.heroLogo, errorWidget: (context, url, error) => titleFallback(context), ); if (localArtwork != null) return localArtwork; final client = _getArtworkMediaClient(context); final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final targetWidth = (width * dpr).round(); + final targetHeight = (height * dpr).round(); + final (memCacheWidth, memCacheHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: targetWidth, + displayHeight: targetHeight, + imageType: ImageType.heroLogo, + ); final logoUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: metadata.clearLogoPath, maxWidth: width, maxHeight: height, devicePixelRatio: dpr, - imageType: ImageType.logo, + imageType: ImageType.heroLogo, ); if (logoUrl.isEmpty) return titleFallback(context); @@ -3686,7 +3699,8 @@ class _MediaDetailScreenState extends State filterQuality: FilterQuality.medium, fit: BoxFit.contain, alignment: .centerLeft, - memCacheWidth: (width * dpr).clamp(200, 1000).round(), + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, placeholder: (context, url) => const SizedBox.shrink(), errorBuilder: (context, error, stackTrace) => titleFallback(context), ), diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index efde06f7..1883ffa1 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -562,7 +562,7 @@ class _ArtworkPickerDialogState extends State { child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle), - child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), + child: AppIcon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), ), ), ], diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index 09efb262..c096137b 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -86,9 +86,6 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen> fetchItems() => mediaClient.fetchAlbumTracks(widget.album.id); - @override - String getLoadErrorMessage(Object error) => t.messages.errorLoading(error: error.toString()); - @override Future loadItems() async { await super.loadItems(); diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index dd617201..b2c96af1 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -13,6 +13,7 @@ import '../../mixins/grid_focus_node_mixin.dart'; import '../../services/music/music_playback_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; import '../../utils/platform_detector.dart'; @@ -63,9 +64,6 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen> fetchItems() => mediaClient.fetchArtistAlbums(widget.artist.id); - @override - String getLoadErrorMessage(Object error) => t.messages.errorLoading(error: error.toString()); - @override Future loadItems() async { await super.loadItems(); @@ -87,8 +85,9 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen tracks; try { tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); - } catch (e) { - if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + } catch (e, stackTrace) { + final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle); + if (mounted) showErrorSnackBar(context, message); return; } if (!mounted) return; diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index b38abf60..5a5c2366 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -14,6 +14,7 @@ import '../../services/media_list_playback_launcher.dart'; import '../../services/music/music_playback_service.dart'; import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; +import '../../utils/error_message_utils.dart'; import '../../utils/continuation_pagination_coordinator.dart'; import '../../utils/music_navigation.dart'; import '../../widgets/app_icon.dart'; @@ -34,6 +35,7 @@ import '../../widgets/listenable_selector.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; import '../../mixins/grid_focus_node_mixin.dart'; +import '../../widgets/overlay_sheet.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -55,9 +57,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen widget.playlist; - @override - String? get itemServerId => widget.playlist.serverId; - @override String get title => widget.playlist.title; @@ -103,9 +102,9 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with ContextMenuTap Widget build(BuildContext context) { final item = _effectiveItem(context); final colorScheme = Theme.of(context).colorScheme; + final textMuted = tokens(context).textMuted; // Determine if row is focused (main content area) final isRowFocused = widget.isFocused && widget.focusedColumn == 0; @@ -116,7 +118,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap child: AppIcon( widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, fill: 1, - color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey, + color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : textMuted, ), ), ), @@ -147,21 +149,10 @@ class _PlaylistItemCardState extends State with ContextMenuTap // Subtitle (episode info or type) Text( _buildSubtitle(item), - style: TextStyle(fontSize: 13, color: Colors.grey[400]), + style: TextStyle(fontSize: 13, color: textMuted), maxLines: 1, overflow: .ellipsis, ), - - // Progress indicator if partially watched - if (item.viewOffsetMs != null && item.durationMs != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: MediaProgressBar( - viewOffset: item.viewOffsetMs!, - duration: item.durationMs!, - minHeight: 3, - ), - ), ], ), ), @@ -170,10 +161,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap // Duration if (item.durationMs != null) - Text( - formatDurationTextual(item.durationMs!), - style: TextStyle(fontSize: 13, color: Colors.grey[400]), - ), + Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)), const SizedBox(width: 8), @@ -189,7 +177,7 @@ class _PlaylistItemCardState extends State with ContextMenuTap icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), onPressed: widget.onRemove, tooltip: t.playlists.removeItem, - color: isRemoveButtonFocused ? colorScheme.primary : Colors.grey[400], + color: isRemoveButtonFocused ? colorScheme.primary : textMuted, ), ), ], @@ -202,18 +190,27 @@ class _PlaylistItemCardState extends State with ContextMenuTap Widget _buildPosterImage(BuildContext context, MediaItem item) { final posterUrl = item.posterThumb(); - return ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(6)), - child: OptimizedMediaImage.poster( - // Backend-neutral lookup so Jellyfin items render via their own - // image transcoder; null falls through to the placeholder below. - client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), - imagePath: posterUrl, - width: 60, - height: 90, - fit: BoxFit.cover, - placeholder: (context, url) => _buildPlaceholder(), - errorWidget: (context, url, error) => _buildPlaceholder(), + return SizedBox( + width: 60, + height: 90, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(6)), + child: OptimizedMediaImage.poster( + // Backend-neutral lookup so Jellyfin items render via their own + // image transcoder; null falls through to the placeholder below. + client: context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), + imagePath: posterUrl, + width: 60, + height: 90, + fit: BoxFit.cover, + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), + ), + ), + WatchedIndicator(item: item, size: WatchedIndicatorSize.compact), + ], ), ); } diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 080a3400..e95b8ea2 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -539,7 +539,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { Widget _buildKeyContent(BuildContext context, _PinKey key, Color foreground) { final icon = key.icon; - if (icon != null) return Icon(icon, color: foreground, size: 30); + if (icon != null) return AppIcon(icon, color: foreground, size: 30); return FittedBox( fit: BoxFit.scaleDown, @@ -678,9 +678,9 @@ class _PinKey { case _PinKeyType.digit: return null; case _PinKeyType.backspace: - return Icons.backspace_outlined; + return Symbols.backspace_rounded; case _PinKeyType.close: - return Icons.close_rounded; + return Symbols.close_rounded; } } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index eed25a3b..99f5875b 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../focus/focusable_text_field.dart'; import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; +import '../media/ids.dart'; import '../media/media_item.dart'; import '../mixins/debounced_media_search.dart'; import '../mixins/mounted_set_state_mixin.dart'; @@ -153,10 +154,23 @@ class _SearchScreenState extends State searchController.clear(); } - void updateItem(String _) { + Future updateItem(MediaItem source) async { if (!mounted) return; - // Trigger a refresh of the search to get updated metadata - runSearch(searchController.text.trim()); + final serverId = source.serverId; + if (serverId == null) return; + + try { + final multiServer = context.read(); + final updated = await multiServer.getClientForServer(ServerId(serverId))?.fetchItem(source.id); + if (!mounted || updated == null) return; + final index = searchResults.indexWhere((item) => item.globalKey == source.globalKey); + if (index == -1) return; + setState(() { + searchResults[index] = updated; + }); + } catch (e) { + appLogger.d('Search item refresh skipped for ${source.globalKey}', error: e); + } } /// Navigate focus to the sidebar @@ -180,7 +194,7 @@ class _SearchScreenState extends State disableScale: true, focusNode: index == 0 ? firstResultFocusNode : null, onRefresh: updateItem, - onListRefresh: () => updateItem(item.id), + onListRefresh: refresh, onNavigateLeft: _navigateToSidebar, onNavigateUp: index == 0 ? focusSearchInput : null, showServerName: showServerName, diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index dc0036d9..4cbdd844 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -6,6 +6,7 @@ import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import '../../i18n/strings.g.dart'; +import '../../theme/mono_tokens.dart'; import 'licenses_screen.dart'; class AboutScreen extends StatelessWidget { @@ -39,7 +40,7 @@ class AboutScreen extends StatelessWidget { const SizedBox(height: 8), Text( t.about.versionLabel(version: appVersion), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: tokens(context).textMuted), ), const SizedBox(height: 24), Text( diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 476a3a33..3931093c 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -199,13 +199,6 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } - String _themeModeLabel(settings.ThemeMode mode) => switch (mode) { - settings.ThemeMode.system => t.settings.systemTheme, - settings.ThemeMode.light => t.settings.lightTheme, - settings.ThemeMode.dark => t.settings.darkTheme, - settings.ThemeMode.oled => t.settings.oledTheme, - }; - // Writes the pref directly; ThemeProvider listens to the pref's listenable // and applies the change live. The Consumer only feeds the dynamic icon. Widget _themeSelector() { @@ -215,8 +208,8 @@ class AppearanceSettingsScreen extends StatelessWidget { pref: SettingsService.themeMode, icon: themeProvider.themeModeIcon, title: t.settings.theme, - subtitleBuilder: _themeModeLabel, - options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: _themeModeLabel(m))).toList(), + subtitleBuilder: themeModeLabel, + options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(), decode: (v) => v, encode: (v) => v, ); diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index ea4f4536..6c50b5f4 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -11,6 +11,7 @@ import 'package:logger/logger.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../focus/focusable_action_bar.dart'; import '../../widgets/dialog_action_button.dart'; +import '../../widgets/app_icon.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/mounted_set_state_mixin.dart'; @@ -207,7 +208,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { ), const SizedBox(width: 8), IconButton( - icon: const Icon(Icons.copy, size: 20), + icon: const AppIcon(Symbols.content_copy_rounded, size: 20), onPressed: () { Clipboard.setData(ClipboardData(text: id)); showSuccessSnackBar(context, t.messages.logsCopied); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 9788e5d0..7e8d9563 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -51,6 +51,7 @@ import 'logs_screen.dart'; import 'playback_settings_screen.dart'; import '../profile/profile_switch_screen.dart'; import 'services_settings_screen.dart'; +import 'settings_utils.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { @@ -191,12 +192,12 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildDonateTile() { - return FocusableListTile( + return SettingNavigationTile( focusNode: _focusTracker.get(_kDonate), - leading: const AppIcon(Symbols.favorite_rounded, fill: 1), - title: Text(t.settings.supportDeveloper), - subtitle: Text(t.settings.supportDeveloperDescription), - trailing: const AppIcon(Symbols.open_in_new_rounded, fill: 1), + icon: Symbols.favorite_rounded, + title: t.settings.supportDeveloper, + subtitle: t.settings.supportDeveloperDescription, + trailingIcon: Symbols.open_in_new_rounded, onTap: () async { final url = Uri.parse(DonationService.donationUrl); if (await canLaunchUrl(url)) { @@ -211,7 +212,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun builder: (context, themeProvider, _) => SettingValueBuilder( pref: settings.SettingsService.libraryDensity, builder: (context, libraryDensity, _) { - final summary = '${themeProvider.themeModeDisplayName} · ${t.settings.libraryDensity} $libraryDensity'; + final summary = '${themeModeLabel(themeProvider.themeMode)} · ${t.settings.libraryDensity} $libraryDensity'; return SettingNavigationTile( focusNode: _focusTracker.get(_kAppearance), icon: Symbols.palette_rounded, @@ -336,6 +337,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun subtitle: Text(currentPath, maxLines: 2, overflow: .ellipsis), trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), onTap: () => _showDownloadLocationDialog(), + dense: false, + visualDensity: VisualDensity.standard, ); }, ), @@ -390,12 +393,11 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.advanced, children: [ - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kWatchTogetherRelay), - leading: const AppIcon(Symbols.dns_rounded, fill: 1), - title: Text(t.settings.watchTogetherRelay), - subtitle: Text(t.settings.watchTogetherRelayDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.dns_rounded, + title: t.settings.watchTogetherRelay, + subtitle: t.settings.watchTogetherRelayDescription, onTap: () => _showRelayUrlDialog(), ), SettingSwitchTile( @@ -419,38 +421,34 @@ class _SettingsScreenState extends State with FocusableTab, Moun subtitle: t.settings.viewLogsDescription, destinationBuilder: (context) => const LogsScreen(), ), - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kClearCache), - leading: const AppIcon(Symbols.cleaning_services_rounded, fill: 1), - title: Text(t.settings.clearCache), - subtitle: Text(t.settings.clearCacheDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.cleaning_services_rounded, + title: t.settings.clearCache, + subtitle: t.settings.clearCacheDescription, onTap: () => _showClearCacheDialog(), ), - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kResetSettings), - leading: const AppIcon(Symbols.restore_rounded, fill: 1), - title: Text(t.settings.resetSettings), - subtitle: Text(t.settings.resetSettingsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.restore_rounded, + title: t.settings.resetSettings, + subtitle: t.settings.resetSettingsDescription, onTap: () => _showResetSettingsDialog(), ), if (kDebugMode) - FocusableListTile( - leading: const AppIcon(Symbols.error_rounded, fill: 1), - title: const Text('Test Sentry'), - subtitle: const Text('Send a test error'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + SettingNavigationTile( + icon: Symbols.error_rounded, + title: 'Test Sentry', + subtitle: 'Send a test error', onTap: () { throw Exception("Example exception"); }, ), if (kDebugMode) - FocusableListTile( - leading: const AppIcon(Symbols.timer_rounded, fill: 1), - title: const Text('Test ANR'), - subtitle: const Text('Block the main thread for 10 seconds'), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + SettingNavigationTile( + icon: Symbols.timer_rounded, + title: 'Test ANR', + subtitle: 'Block the main thread for 10 seconds', onTap: () { showSnackBar(context, 'Blocking main thread...'); final end = DateTime.now().add(const Duration(seconds: 10)); @@ -465,20 +463,18 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.backup, children: [ - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kExportSettings), - leading: const AppIcon(Symbols.upload_rounded, fill: 1), - title: Text(t.settings.exportSettings), - subtitle: Text(t.settings.exportSettingsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.upload_rounded, + title: t.settings.exportSettings, + subtitle: t.settings.exportSettingsDescription, onTap: _handleExportSettings, ), - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kImportSettings), - leading: const AppIcon(Symbols.download_rounded, fill: 1), - title: Text(t.settings.importSettings), - subtitle: Text(t.settings.importSettingsDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.download_rounded, + title: t.settings.importSettings, + subtitle: t.settings.importSettingsDescription, onTap: _showImportSettingsDialog, ), ], @@ -498,11 +494,10 @@ class _SettingsScreenState extends State with FocusableTab, Moun return SettingsGroup( title: t.settings.updates, children: [ - FocusableListTile( + SettingNavigationTile( focusNode: _focusTracker.get(_kCheckForUpdates), - leading: const AppIcon(Symbols.system_update_rounded, fill: 1), - title: Text(t.settings.checkForUpdates), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), + icon: Symbols.system_update_rounded, + title: t.settings.checkForUpdates, onTap: () => UpdateService.checkForUpdatesNative(inBackground: false), ), _buildAutoCheckUpdatesOnStartupTile(), @@ -536,6 +531,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun _checkForUpdates(); } }, + dense: false, + visualDensity: VisualDensity.standard, ), _buildAutoCheckUpdatesOnStartupTile(), ], diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 7412f7cd..1ea1147b 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -1,15 +1,25 @@ import 'package:flex_color_picker/flex_color_picker.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../services/settings_service.dart' as settings; import '../../utils/dialogs.dart'; +import '../../widgets/app_icon.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/tv_color_picker.dart'; import '../../widgets/tv_number_spinner.dart'; +String themeModeLabel(settings.ThemeMode mode) => switch (mode) { + settings.ThemeMode.system => t.settings.systemTheme, + settings.ThemeMode.light => t.settings.lightTheme, + settings.ThemeMode.dark => t.settings.darkTheme, + settings.ThemeMode.oled => t.settings.oledTheme, +}; + /// Model for option selection dialogs. class DialogOption { final T value; @@ -118,8 +128,8 @@ Future showSelectionDialog({ final selected = option.value == currentValue; return FocusableListTile( key: ValueKey(option.value), - leading: Icon( - selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + leading: AppIcon( + selected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, color: selected ? Theme.of(dialogContext).colorScheme.primary : null, ), title: Text(option.title), @@ -380,7 +390,7 @@ void showRegexInputDialog({ contentBuilder: (_, _, setDialogState, saveFocusNode) { return FocusableTextField( controller: controller, - decoration: InputDecoration(labelText: 'Regex', errorText: errorText), + decoration: InputDecoration(labelText: t.settings.regex, errorText: errorText), autofocus: true, textInputAction: TextInputAction.done, onEditingComplete: () => saveFocusNode.requestFocus(), diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index e80057c1..6562e064 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -15,17 +15,17 @@ class SubtitleStylingScreen extends StatelessWidget { String _assOverrideLabel(SubAssOverride value) { return switch (value) { - SubAssOverride.no => 'No', - SubAssOverride.yes => 'Yes', - SubAssOverride.scale => 'Scale', - SubAssOverride.force => 'Force', - SubAssOverride.strip => 'Strip', + SubAssOverride.no => t.common.no, + SubAssOverride.yes => t.common.yes, + SubAssOverride.scale => t.subtitlingStyling.overrideScale, + SubAssOverride.force => t.subtitlingStyling.overrideForce, + SubAssOverride.strip => t.subtitlingStyling.overrideStrip, }; } String _formatPosition(int value) { - if (value == 0) return 'Top'; - if (value == 100) return 'Bottom'; + if (value == 0) return t.subtitlingStyling.positionTop; + if (value == 100) return t.subtitlingStyling.positionBottom; return '$value%'; } diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index d67a1ebc..3802dffc 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -155,7 +155,7 @@ class TrackerSettingsScreen extends StatelessWidget { toggles: [ TrackerSettingsToggle( pref: config.scrobblePref, - icon: Symbols.auto_timer, + icon: Symbols.auto_timer_rounded, title: t.services.scrobble, subtitle: t.services.scrobbleDescription, onAfterWrite: config.onScrobbleChanged, diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 92de30d2..2cbe95db 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -72,7 +72,7 @@ class TraktSettingsScreen extends StatelessWidget { toggles: [ TrackerSettingsToggle( pref: SettingsService.enableTraktScrobble, - icon: Symbols.auto_timer, + icon: Symbols.auto_timer_rounded, title: t.trakt.scrobble, subtitle: t.trakt.scrobbleDescription, onAfterWrite: TraktScrobbleService.instance.setEnabled, diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index da1a85d4..05ebbf50 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -30,7 +30,7 @@ class VideoPlayerMacPipPlaceholder extends StatelessWidget { child: Column( mainAxisSize: .min, children: [ - Icon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)), + AppIcon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)), const SizedBox(height: 12), Text( t.videoControls.pipActive, @@ -119,7 +119,7 @@ class VideoPlayerWatchTogetherOverlays extends StatelessWidget { mainAxisSize: .min, children: [ if (PlatformDetector.isTV()) - const Icon(Symbols.sync_rounded, size: 14, color: Colors.white) + const AppIcon(Symbols.sync_rounded, size: 14, color: Colors.white) else const SizedBox( width: 14, diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 50655b1c..c0659946 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -47,6 +47,7 @@ import '../utils/resolution_label.dart'; import '../utils/track_label_builder.dart'; import '../exceptions/media_server_exceptions.dart'; import '../i18n/strings.g.dart'; +import '../utils/json_utils.dart'; import '../utils/jellyfin_time.dart'; import 'jellyfin_auth_header.dart'; import '../media/download_resolution.dart'; @@ -285,7 +286,7 @@ class JellyfinClient @override Future checkHealth() async { try { - final response = await _http.get('/Users/Me', timeout: const Duration(seconds: 8)); + final response = await _http.get('/Users/Me', timeout: MediaServerTimeouts.jellyfinProbe); final ok = response.statusCode >= 200 && response.statusCode < 300; if (ok) { final data = response.data; diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 477cdd84..0360cc64 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -27,23 +27,22 @@ mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { final audioTracks = parsed.audioTracks; final subtitleTracks = parsed.subtitleTracks; - final width = videoStream?['Width'] as int?; - final height = videoStream?['Height'] as int?; + final width = flexibleInt(videoStream?['Width']); + final height = flexibleInt(videoStream?['Height']); final aspectRatioString = videoStream?['AspectRatio'] as String?; double? aspectRatio; if (aspectRatioString != null && aspectRatioString.contains(':')) { final parts = aspectRatioString.split(':'); - final num = double.tryParse(parts.first); - final den = double.tryParse(parts[1]); + final num = flexibleDouble(parts.first); + final den = flexibleDouble(parts[1]); if (num != null && den != null && den != 0) aspectRatio = num / den; } aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; - final runtimeTicks = source['RunTimeTicks'] as int?; - final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10_000) : null; + final durationMs = jellyfinTicksToMs(flexibleInt(source['RunTimeTicks'])); - final bitrateBps = source['Bitrate'] as int?; - final videoBitrateBps = videoStream?['BitRate'] as int?; + final bitrateBps = flexibleInt(source['Bitrate']); + final videoBitrateBps = flexibleInt(videoStream?['BitRate']); return MediaFileInfo( container: source['Container'] as String?, @@ -55,21 +54,20 @@ mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { height: height, aspectRatio: aspectRatio, // Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps. - bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null, + bitrate: bitrateKbpsFromBps(bitrateBps), duration: durationMs, audioCodec: audioStream?['Codec'] as String?, audioProfile: audioStream?['Profile'] as String?, - audioChannels: audioStream?['Channels'] as int?, + audioChannels: flexibleInt(audioStream?['Channels']), filePath: source['Path'] as String?, - fileSize: source['Size'] as int?, + fileSize: flexibleInt(source['Size']), colorSpace: videoStream?['ColorSpace'] as String?, colorRange: videoStream?['ColorRange'] as String?, colorPrimaries: videoStream?['ColorPrimaries'] as String?, chromaSubsampling: null, - frameRate: - (videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(), - bitDepth: videoStream?['BitDepth'] as int?, - videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null, + frameRate: flexibleDouble(videoStream?['RealFrameRate']) ?? flexibleDouble(videoStream?['AverageFrameRate']), + bitDepth: flexibleInt(videoStream?['BitDepth']), + videoBitrate: bitrateKbpsFromBps(videoBitrateBps), audioChannelLayout: audioStream?['ChannelLayout'] as String?, audioTracks: audioTracks, subtitleTracks: subtitleTracks, diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index de429237..e31a98e3 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:logger/logger.dart'; +import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'base_shared_preferences_service.dart'; @@ -13,7 +13,6 @@ import 'base_shared_preferences_service.dart'; /// via auto_updater for native update dialogs and in-app installs. /// On all other platforms: falls back to GitHub API check + browser link dialog. class UpdateService { - static final Logger _logger = Logger(); static const String _githubRepo = 'edde746/plezy'; static const String _feedUrl = 'https://cdn.jsdelivr.net/gh/edde746/plezy@appcast/appcast.xml'; @@ -47,8 +46,8 @@ class UpdateService { try { await autoUpdater.setFeedURL(_feedUrl); _nativeUpdaterInitialized = true; - } catch (e) { - _logger.e('Failed to initialize native auto updater: $e'); + } catch (error, stackTrace) { + appLogger.e('Failed to initialize native auto updater', error: error, stackTrace: stackTrace); } } @@ -61,8 +60,8 @@ class UpdateService { } try { await autoUpdater.checkForUpdates(inBackground: inBackground); - } catch (e) { - _logger.e('Native update check failed: $e'); + } catch (error, stackTrace) { + appLogger.e('Native update check failed', error: error, stackTrace: stackTrace); } } @@ -72,7 +71,8 @@ class UpdateService { try { final execPath = Platform.resolvedExecutable; return execPath.contains('/Caskroom/') || execPath.contains('/homebrew/'); - } catch (_) { + } catch (error, stackTrace) { + appLogger.e('Failed to determine Homebrew install status', error: error, stackTrace: stackTrace); return false; } } @@ -83,7 +83,8 @@ class UpdateService { try { final exeDir = File(Platform.resolvedExecutable).parent.path; return File('$exeDir\\.winget').existsSync(); - } catch (_) { + } catch (error, stackTrace) { + appLogger.e('Failed to determine winget install status', error: error, stackTrace: stackTrace); return false; } } @@ -94,7 +95,8 @@ class UpdateService { try { final exeDir = File(Platform.resolvedExecutable).parent.path; return File('$exeDir\\unins000.exe').existsSync(); - } catch (_) { + } catch (error, stackTrace) { + appLogger.e('Failed to determine Windows installation status', error: error, stackTrace: stackTrace); return false; } } @@ -190,8 +192,8 @@ class UpdateService { if (respectCooldown) { await _updateLastCheckTime(); } - } catch (e) { - _logger.e('Failed to check for updates: $e'); + } catch (error, stackTrace) { + appLogger.e('Failed to check for updates', error: error, stackTrace: stackTrace); } return null; @@ -237,8 +239,8 @@ class UpdateService { } return false; - } catch (e) { - _logger.e('Error comparing versions: $e'); + } catch (error, stackTrace) { + appLogger.e('Error comparing versions', error: error, stackTrace: stackTrace); return false; } } diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart index b5ca0d77..4e2f07ff 100644 --- a/lib/utils/error_message_utils.dart +++ b/lib/utils/error_message_utils.dart @@ -1,24 +1,25 @@ +import '../exceptions/media_server_exceptions.dart'; import '../i18n/strings.g.dart'; import 'app_logger.dart'; -import '../exceptions/media_server_exceptions.dart'; -/// Shared helpers for translating network errors into user-friendly messages. -String mapHttpErrorToMessage(MediaServerHttpException error, {required String context}) { - switch (error.type) { - case MediaServerHttpErrorType.connectionTimeout: - case MediaServerHttpErrorType.receiveTimeout: - return t.errors.connectionTimeout(context: context); - case MediaServerHttpErrorType.connectionError: - return t.errors.connectionFailed; - default: - appLogger.e('Error loading $context', error: error); - final msg = error.message.isNotEmpty ? error.message : t.common.unknown; - return t.errors.failedToLoad(context: context, error: msg); +/// Logs a load failure once and returns a localized, user-safe message. +/// +/// The returned text never includes exception or server response content. +String localizedLoadErrorMessage(Object error, StackTrace stackTrace, {required String context}) { + appLogger.e('Error loading $context', error: error, stackTrace: stackTrace); + + if (error is MediaServerHttpException) { + switch (error.type) { + case MediaServerHttpErrorType.connectionTimeout: + case MediaServerHttpErrorType.receiveTimeout: + return t.errors.connectionTimeout(context: context); + case MediaServerHttpErrorType.connectionError: + return t.errors.connectionFailed; + case MediaServerHttpErrorType.cancelled: + case MediaServerHttpErrorType.unknown: + break; + } } -} -/// Generic fallback for unexpected errors. -String mapUnexpectedErrorToMessage(dynamic error, {required String context}) { - appLogger.e('Unexpected error in $context', error: error); - return t.errors.failedToLoad(context: context, error: error.toString()); + return t.errors.unableToLoad(context: context); } diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index 6601b2d6..38ed7f28 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -184,10 +184,10 @@ String formatRating(double value) => final RegExp _trailingZeroPattern = RegExp(r'\.?0+$'); /// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x"). -/// When [normalAtOne] is true, 1.0 renders as "Normal" for menu labels; -/// the in-player pill passes false to keep a numeric indicator. +/// When [normalAtOne] is true, 1.0 renders with the localized normal-speed +/// label for menus; the in-player pill passes false to keep a numeric indicator. String formatPlaybackRate(double rate, {bool normalAtOne = false}) { - if (normalAtOne && (rate - 1.0).abs() < 0.005) return 'Normal'; + if (normalAtOne && (rate - 1.0).abs() < 0.005) return t.videoSettings.normalSpeed; return '${rate.toStringAsFixed(2).replaceFirst(_trailingZeroPattern, '')}x'; } diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index 28644354..8ab5b731 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -1,7 +1,15 @@ +import 'dart:convert'; import 'dart:math'; + +import 'package:cached_network_image_ce/cached_network_image.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter/widgets.dart'; + +import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../services/device_performance.dart'; +import '../services/image_cache_service.dart'; +import '../services/settings_service.dart' show EpisodePosterMode; import 'platform_detector.dart'; /// Image types for different transcoding strategies @@ -10,6 +18,7 @@ enum ImageType { art, // Wide background art thumb, // 16:9 episode thumbnails logo, // Variable ratio clear logos + heroLogo, // Large hero clear logos avatar, // Square-ish user avatars square, // 1:1 music artwork (albums, artists, tracks) } @@ -107,6 +116,7 @@ class MediaImageHelper { return roundDimensions(coverWidth, coverHeight); case ImageType.logo: + case ImageType.heroLogo: final logoWidth = targetWidth; final logoHeight = targetHeight; return roundDimensions(logoWidth, logoHeight); @@ -243,6 +253,7 @@ class MediaImageHelper { ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight), ImageType.art => (1920, 1080), ImageType.logo => (600, 300), + ImageType.heroLogo => (1000, 500), ImageType.avatar => (300, 300), }; @@ -262,6 +273,55 @@ class MediaImageHelper { return ResizeImage(provider, width: width, height: height, policy: ResizeImagePolicy.fit); } + /// Selects the decode/transcode shape used by media cards and their + /// prefetchers. Keeping this derived from [MediaItem.cardShape] prevents the + /// renderer and prefetch pipeline from assigning different cache budgets. + static ImageType cardImageType(MediaItem item, EpisodePosterMode episodePosterMode, {bool mixedHubContext = false}) { + return switch (item.cardShape(episodePosterMode, mixedHubContext: mixedHubContext)) { + CardShape.square => ImageType.square, + CardShape.wide => ImageType.thumb, + CardShape.poster => ImageType.poster, + }; + } + + /// Creates the final provider for server-hosted artwork. + /// + /// The disk key deliberately depends only on the fully bucketed URL. Decode + /// dimensions belong to Flutter's memory-cache key and must not fragment the + /// shared disk cache during small layout changes. + static ImageProvider serverArtworkProvider({ + required String imageUrl, + required int memWidth, + required int memHeight, + String? cacheKey, + }) { + final provider = CachedNetworkImageProvider( + imageUrl, + cacheKey: cacheKey ?? _serverArtworkCacheKey(imageUrl), + cacheManager: PlexImageCacheManager.instance, + headers: const {'User-Agent': 'Plezy'}, + ); + return boundedDecode(provider, memWidth: memWidth, memHeight: memHeight); + } + + static final _serverArtworkCacheKeys = {}; + static const _serverArtworkCacheKeyLimit = 512; + + static String _serverArtworkCacheKey(String imageUrl) { + final cached = _serverArtworkCacheKeys.remove(imageUrl); + if (cached != null) { + _serverArtworkCacheKeys[imageUrl] = cached; + return cached; + } + + final key = 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}'; + if (_serverArtworkCacheKeys.length >= _serverArtworkCacheKeyLimit) { + _serverArtworkCacheKeys.remove(_serverArtworkCacheKeys.keys.first); + } + _serverArtworkCacheKeys[imageUrl] = key; + return key; + } + /// Determines if an image path is suitable for transcoding static bool shouldTranscode(String? imagePath) { if (imagePath == null || imagePath.isEmpty) return false; diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 421f08e0..fc6cfb13 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -140,8 +140,8 @@ bool shouldOpenEpisodeDetailsForActivation({ /// For artists/albums, navigates to the music detail screens; tracks start /// playback in their album queue. /// -/// The [onRefresh] callback is invoked with the item's id after returning from -/// the detail screen, allowing the caller to refresh state. +/// The [onRefresh] callback is invoked with the source item after returning +/// from the detail screen, preserving its server-qualified identity. /// /// Set [isOffline] to true for downloaded content without server access. /// @@ -156,13 +156,16 @@ bool shouldOpenEpisodeDetailsForActivation({ Future navigateToMediaItem( BuildContext context, Object item, { - void Function(String)? onRefresh, + void Function(MediaItem source)? onRefresh, bool isOffline = false, bool playDirectly = false, }) async { if (item is MediaPlaylist) { - await Navigator.push(context, MaterialPageRoute(builder: (context) => PlaylistDetailScreen(playlist: item))); - return MediaNavigationResult.navigated; + final result = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => PlaylistDetailScreen(playlist: item)), + ); + return result == true ? MediaNavigationResult.listRefreshNeeded : MediaNavigationResult.navigated; } if (item is! MediaItem) { @@ -233,7 +236,7 @@ Future navigateToMediaItem( } final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline); if (result == true && context.mounted) { - onRefresh?.call(mi.id); + onRefresh?.call(mi); } return MediaNavigationResult.navigated; @@ -241,7 +244,7 @@ Future navigateToMediaItem( if (playDirectly && !shouldOpenContinueWatchingDetails) { final result = await navigateToVideoPlayer(context, metadata: mi, isOffline: isOffline); if (result == true && context.mounted) { - onRefresh?.call(mi.id); + onRefresh?.call(mi); } return MediaNavigationResult.navigated; } @@ -259,7 +262,7 @@ Future navigateToMediaItemDetails( BuildContext context, MediaItem mi, { bool isOffline = false, - void Function(String)? onRefresh, + void Function(MediaItem source)? onRefresh, MediaItem? metadataOverride, }) async { // Catalog stand-ins (Explore tab) must never reach MediaDetailScreen — it @@ -284,7 +287,7 @@ Future navigateToMediaItemDetails( ), ); if (result == true && context.mounted) { - onRefresh?.call(mi.id); + onRefresh?.call(mi); } return MediaNavigationResult.navigated; } diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 1d99078b..50d440ee 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -18,6 +18,7 @@ import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/dialog_action_button.dart'; +import '../../widgets/app_icon.dart'; import '../../utils/video_player_navigation.dart'; import '../../widgets/focused_scroll_scaffold.dart'; import '../../widgets/app_menu.dart'; @@ -135,7 +136,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta child: Column( mainAxisSize: .min, children: [ - Icon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary), + AppIcon(Symbols.group_rounded, size: 80, color: theme.colorScheme.primary), const SizedBox(height: 24), Text(t.watchTogether.title, style: theme.textTheme.headlineMedium, textAlign: TextAlign.center), const SizedBox(height: 8), @@ -152,7 +153,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta padding: const EdgeInsets.all(12), child: Row( children: [ - Icon(Symbols.warning_rounded, color: theme.colorScheme.onErrorContainer), + AppIcon(Symbols.warning_rounded, color: theme.colorScheme.onErrorContainer), const SizedBox(width: 12), Expanded( child: Text( @@ -174,7 +175,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta useBackgroundFocus: true, child: FilledButton.icon( onPressed: _isBusy ? null : _createSession, - icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.add_rounded), + icon: _isCreating ? const LoadingIndicatorBox(size: 20) : const AppIcon(Symbols.add_rounded), label: Text(_isCreating ? t.watchTogether.creating : t.watchTogether.createSession), ), ), @@ -186,7 +187,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta onPressed: _isBusy ? null : _joinSession, child: OutlinedButton.icon( onPressed: _isBusy ? null : _joinSession, - icon: _isJoining ? const LoadingIndicatorBox(size: 20) : const Icon(Symbols.group_add_rounded), + icon: _isJoining ? const LoadingIndicatorBox(size: 20) : const AppIcon(Symbols.group_add_rounded), label: Text(_isJoining ? t.watchTogether.joining : t.watchTogether.joinSession), ), ), @@ -422,7 +423,7 @@ class _RecentRoomTile extends StatelessWidget { type: MaterialType.transparency, child: ListTile( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), - leading: isEntering ? const LoadingIndicatorBox(size: 24) : const Icon(Symbols.meeting_room_rounded), + leading: isEntering ? const LoadingIndicatorBox(size: 24) : const AppIcon(Symbols.meeting_room_rounded), title: Text(title, maxLines: 1, overflow: .ellipsis), subtitle: room.name != null ? Text( @@ -430,7 +431,10 @@ class _RecentRoomTile extends StatelessWidget { style: TextStyle(fontFamily: 'monospace', color: theme.colorScheme.onSurfaceVariant), ) : null, - trailing: IconButton(icon: const Icon(Symbols.more_vert_rounded), onPressed: () => _showActions(context)), + trailing: IconButton( + icon: const AppIcon(Symbols.more_vert_rounded), + onPressed: () => _showActions(context), + ), onTap: isBusy ? null : onTap, onLongPress: () => _showActions(context), ), @@ -485,7 +489,7 @@ class _ActiveSessionContent extends StatelessWidget { children: [ Row( children: [ - Icon( + AppIcon( watchTogether.isHost ? Symbols.star_rounded : Symbols.group_rounded, color: theme.colorScheme.primary, ), @@ -509,7 +513,7 @@ class _ActiveSessionContent extends StatelessWidget { const SizedBox(height: 8), Row( children: [ - Icon( + AppIcon( session.controlMode == ControlMode.anyone ? Symbols.groups_rounded : Symbols.admin_panel_settings_rounded, @@ -540,7 +544,7 @@ class _ActiveSessionContent extends StatelessWidget { children: [ Row( children: [ - Icon(Symbols.people_rounded, color: theme.colorScheme.primary), + AppIcon(Symbols.people_rounded, color: theme.colorScheme.primary), const SizedBox(width: 12), Text( '${t.watchTogether.participants} (${watchTogether.participantCount})', @@ -554,7 +558,7 @@ class _ActiveSessionContent extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ - Icon( + AppIcon( participant.isHost ? Symbols.star_rounded : Symbols.person_rounded, size: 20, color: participant.isHost ? Colors.amber : theme.colorScheme.onSurfaceVariant, @@ -611,7 +615,7 @@ class _ActiveSessionContent extends StatelessWidget { foregroundColor: theme.colorScheme.error, side: BorderSide(color: theme.colorScheme.error), ), - icon: Icon(watchTogether.isHost ? Symbols.close_rounded : Symbols.logout_rounded), + icon: AppIcon(watchTogether.isHost ? Symbols.close_rounded : Symbols.logout_rounded), label: Text(watchTogether.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession), ), ), @@ -691,7 +695,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { children: [ Row( children: [ - Icon(Symbols.play_circle_rounded, color: theme.colorScheme.primary), + AppIcon(Symbols.play_circle_rounded, color: theme.colorScheme.primary), const SizedBox(width: 12), Expanded( child: Column( @@ -719,7 +723,7 @@ class _JoinCurrentPlaybackCardState extends State<_JoinCurrentPlaybackCard> { useBackgroundFocus: true, child: FilledButton.icon( onPressed: _isJoining ? null : _joinCurrentPlayback, - icon: _isJoining ? const LoadingIndicatorBox() : const Icon(Symbols.play_arrow_rounded), + icon: _isJoining ? const LoadingIndicatorBox() : const AppIcon(Symbols.play_arrow_rounded), label: Text(_isJoining ? t.watchTogether.joining : t.watchTogether.joinCurrentPlayback), ), ), @@ -762,7 +766,7 @@ class _SessionCodeRow extends StatelessWidget { ), ), const SizedBox(width: 4), - Icon(Symbols.content_copy_rounded, size: 14, color: theme.colorScheme.onSurfaceVariant), + AppIcon(Symbols.content_copy_rounded, size: 14, color: theme.colorScheme.onSurfaceVariant), ], ), ), diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 2459c6dd..4a803a9e 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -8,6 +8,7 @@ import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../utils/dialogs.dart'; +import '../../widgets/app_icon.dart'; class JoinSessionDialog extends StatefulWidget { const JoinSessionDialog({super.key}); @@ -48,7 +49,7 @@ class _JoinSessionDialogState extends State with ControllerDi children: [ Row( children: [ - Icon(Symbols.group_add, color: theme.colorScheme.primary), + AppIcon(Symbols.group_add_rounded, color: theme.colorScheme.primary), const SizedBox(width: 12), Expanded(child: Text(t.watchTogether.joinWatchSession, style: theme.textTheme.titleLarge)), FocusableWrapper( @@ -59,7 +60,10 @@ class _JoinSessionDialogState extends State with ControllerDi descendantsAreFocusable: false, onSelect: () => Navigator.of(context).pop(), onNavigateDown: _sessionIdFocusNode.requestFocus, - child: IconButton(onPressed: () => Navigator.of(context).pop(), icon: const Icon(Symbols.close)), + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const AppIcon(Symbols.close_rounded), + ), ), ], ), @@ -72,10 +76,10 @@ class _JoinSessionDialogState extends State with ControllerDi decoration: InputDecoration( labelText: t.watchTogether.sessionCode, hintText: t.watchTogether.enterCodeHint, - prefixIcon: const Icon(Symbols.tag), + prefixIcon: const AppIcon(Symbols.tag_rounded), suffixIcon: IconButton( onPressed: _pasteFromClipboard, - icon: const Icon(Symbols.content_paste), + icon: const AppIcon(Symbols.content_paste_rounded), tooltip: t.watchTogether.pasteFromClipboard, ), border: const OutlineInputBorder(), @@ -115,7 +119,7 @@ class _JoinSessionDialogState extends State with ControllerDi useBackgroundFocus: true, child: FilledButton.icon( onPressed: _join, - icon: const Icon(Symbols.group_add), + icon: const AppIcon(Symbols.group_add_rounded), label: Text(t.watchTogether.joinSession), ), ), diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index c662e673..b8968f7a 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -12,6 +12,7 @@ import '../../utils/dialogs.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/app_icon.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/overlay_sheet.dart'; import '../models/watch_session.dart'; @@ -95,11 +96,11 @@ class _SessionIndicator extends StatelessWidget { width: 16, height: 16, child: PlatformDetector.isTV() - ? const Icon(Symbols.sync_rounded, size: 16, color: Colors.white) + ? const AppIcon(Symbols.sync_rounded, size: 16, color: Colors.white) : const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) else - Icon(Symbols.group, size: 18, color: isHost ? Colors.amber : Colors.white), + AppIcon(Symbols.group_rounded, size: 18, color: isHost ? Colors.amber : Colors.white), const SizedBox(width: 6), Text( '$participantCount', @@ -143,7 +144,7 @@ class _SessionMenuSheet extends StatelessWidget { children: [ BottomSheetHeader( title: t.watchTogether.title, - icon: Symbols.group, + icon: Symbols.group_rounded, iconColor: theme.colorScheme.primary, action: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -197,7 +198,7 @@ class _SessionMenuSheet extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontWeight: .bold), ), const SizedBox(width: 8), - Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), + AppIcon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), ], ), ), @@ -215,8 +216,8 @@ class _SessionMenuSheet extends StatelessWidget { backgroundColor: participant.isHost ? theme.colorScheme.primary : theme.colorScheme.surfaceContainerHighest, - child: Icon( - participant.isHost ? Symbols.star : Symbols.person, + child: AppIcon( + participant.isHost ? Symbols.star_rounded : Symbols.person_rounded, color: participant.isHost ? Colors.white : theme.colorScheme.onSurfaceVariant, size: 20, ), @@ -228,7 +229,7 @@ class _SessionMenuSheet extends StatelessWidget { width: 16, height: 16, child: PlatformDetector.isTV() - ? const Icon(Symbols.hourglass_empty_rounded, size: 16) + ? const AppIcon(Symbols.hourglass_empty_rounded, size: 16) : const CircularProgressIndicator(strokeWidth: 2), ) : null, @@ -239,7 +240,7 @@ class _SessionMenuSheet extends StatelessWidget { const Divider(), const SizedBox(height: 8), FocusableListTile( - leading: Icon(Symbols.logout, color: theme.colorScheme.error), + leading: AppIcon(Symbols.logout_rounded, color: theme.colorScheme.error), title: Text( provider.isHost ? t.watchTogether.endSession : t.watchTogether.leaveSession, style: TextStyle(color: theme.colorScheme.error), @@ -430,7 +431,7 @@ class _StatusPill extends StatelessWidget { width: 14, height: 14, child: PlatformDetector.isTV() - ? Icon(tvIcon, size: 14, color: Colors.white) + ? AppIcon(tvIcon, size: 14, color: Colors.white) : const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ), const SizedBox(width: 8), diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart index fc6c1c84..05f00b45 100644 --- a/lib/widgets/app_menu.dart +++ b/lib/widgets/app_menu.dart @@ -119,6 +119,42 @@ Future showAppMenu( ); } +Future showAdaptiveAppMenu( + BuildContext context, { + required List> entries, + String? title, + Offset? position, + Rect? anchorRect, + AppMenuAnchorAlignment anchorAlignment = AppMenuAnchorAlignment.start, + bool focusFirstItem = false, + double minWidth = 220, + double? maxWidth, + bool isScrollControlled = false, +}) { + // ThemeData.platform follows the real target platform by default, including + // Android TV and tvOS, while remaining overrideable in widget tests. + final platform = Theme.of(context).platform; + if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) { + return OverlaySheetController.showAdaptive( + context, + showDragHandle: true, + isScrollControlled: isScrollControlled, + builder: (context) => AppMenuSheet(title: title, entries: entries, focusFirstItem: focusFirstItem), + ); + } + + return showAppMenu( + context, + entries: entries, + position: position, + anchorRect: anchorRect, + anchorAlignment: anchorAlignment, + focusFirstItem: focusFirstItem, + minWidth: minWidth, + maxWidth: maxWidth, + ); +} + Alignment _transitionAlignment(BuildContext context, {Offset? position, Rect? anchorRect}) { final size = MediaQuery.sizeOf(context); final origin = position ?? anchorRect?.center ?? Offset(size.width / 2, size.height / 2); diff --git a/lib/widgets/catalog_context_menu.dart b/lib/widgets/catalog_context_menu.dart index 4463ddd2..cade29d3 100644 --- a/lib/widgets/catalog_context_menu.dart +++ b/lib/widgets/catalog_context_menu.dart @@ -38,8 +38,9 @@ Future showCatalogItemMenu(BuildContext context, CatalogItem item, {Offset anchorRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; } - final action = await showAppMenu<_CatalogMenuAction>( + final action = await showAdaptiveAppMenu<_CatalogMenuAction>( context, + title: item.title, anchorRect: anchorRect, focusFirstItem: position == null, entries: [ diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 06c2b694..282f54d4 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../connection/connection_registry.dart'; @@ -18,9 +19,11 @@ import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/base_peer_service.dart'; import '../../services/settings_service.dart'; +import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../loading_indicator_box.dart'; +import '../app_icon.dart'; @visibleForTesting String companionRemotePairingErrorMessage(Object error) { @@ -211,13 +214,13 @@ class _DiscoveryViewState extends State with ControllerDisposerMi IconData _platformIcon(String platform) { switch (platform.toLowerCase()) { case 'macos': - return Icons.desktop_mac; + return Symbols.desktop_mac_rounded; case 'windows': - return Icons.desktop_windows; + return Symbols.desktop_windows_rounded; case 'linux': - return Icons.computer; + return Symbols.computer_rounded; default: - return Icons.devices; + return Symbols.devices_rounded; } } @@ -242,7 +245,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi padding: const EdgeInsets.all(16.0), child: Row( children: [ - Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer), + AppIcon(Symbols.error_outline_rounded, color: Theme.of(context).colorScheme.onErrorContainer), const SizedBox(width: 12), Expanded( child: Text( @@ -291,7 +294,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi padding: const EdgeInsets.all(24.0), child: Column( children: [ - const Icon(Icons.warning_amber, size: 48, color: Colors.orange), + const AppIcon(Symbols.warning_amber_rounded, size: 48, color: Colors.orange), const SizedBox(height: 12), Text(t.companionRemote.pairing.cryptoInitFailed, textAlign: TextAlign.center), ], @@ -321,7 +324,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi padding: const EdgeInsets.all(24.0), child: Column( children: [ - const Icon(Icons.devices_other, size: 48, color: Colors.grey), + AppIcon(Symbols.devices_other_rounded, size: 48, color: tokens(context).textMuted), const SizedBox(height: 12), Text( t.companionRemote.pairing.noDevicesFound, @@ -331,7 +334,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi const SizedBox(height: 8), Text( t.companionRemote.pairing.noDevicesHint, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), + style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ], @@ -348,10 +351,12 @@ class _DiscoveryViewState extends State with ControllerDisposerMi ..._hosts.map( (host) => Card( child: ListTile( - leading: Icon(_platformIcon(host.platform), size: 32), + leading: AppIcon(_platformIcon(host.platform), size: 32), title: Text(host.name), subtitle: Text(host.platform), - trailing: _isConnecting ? const LoadingIndicatorBox(size: 24) : const Icon(Icons.arrow_forward), + trailing: _isConnecting + ? const LoadingIndicatorBox(size: 24) + : const AppIcon(Symbols.arrow_forward_rounded), onTap: _isConnecting ? null : () => _connect(() => _provider.connectToDiscoveredHost(host)), ), ), @@ -378,8 +383,8 @@ class _DiscoveryViewState extends State with ControllerDisposerMi padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ - Icon( - _showManualEntry ? Icons.expand_less : Icons.expand_more, + AppIcon( + _showManualEntry ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, color: Theme.of(context).colorScheme.primary, ), const SizedBox(width: 8), @@ -408,7 +413,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi labelText: t.companionRemote.session.hostAddress, hintText: t.companionRemote.pairing.hostAddressHint, border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.computer), + prefixIcon: const AppIcon(Symbols.computer_rounded), ), validator: (value) { final hostAddress = value?.trim() ?? ''; @@ -428,7 +433,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi onPressed: _isConnecting ? null : _submitManualHost, child: FilledButton.icon( onPressed: _isConnecting ? null : _submitManualHost, - icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const Icon(Icons.link), + icon: _isConnecting ? const LoadingIndicatorBox(size: 16) : const AppIcon(Symbols.link_rounded), label: Text(_isConnecting ? t.companionRemote.pairing.connecting : t.common.connect), ), ), diff --git a/lib/widgets/companion_remote/remote_session_dialog.dart b/lib/widgets/companion_remote/remote_session_dialog.dart index 7b692941..243fb3e2 100644 --- a/lib/widgets/companion_remote/remote_session_dialog.dart +++ b/lib/widgets/companion_remote/remote_session_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; @@ -6,10 +7,12 @@ import '../../mixins/mounted_set_state_mixin.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/settings_service.dart'; +import '../../theme/mono_tokens.dart'; import '../../utils/dialogs.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; import '../dialog_action_button.dart'; +import '../app_icon.dart'; class RemoteSessionDialog extends StatefulWidget { const RemoteSessionDialog({super.key}); @@ -148,7 +151,7 @@ class _RemoteSessionDialogState extends State with MountedS children: [ Row( children: [ - const Icon(Icons.phone_android, size: 32), + const AppIcon(Symbols.phone_android_rounded, size: 32), const SizedBox(width: 16), Expanded( child: Column( @@ -166,7 +169,7 @@ class _RemoteSessionDialogState extends State with MountedS onBack: _close, onNavigateDown: () => _toggleFocusNode.requestFocus(), useBackgroundFocus: true, - child: IconButton(icon: const Icon(Icons.close), onPressed: _close), + child: IconButton(icon: const AppIcon(Symbols.close_rounded), onPressed: _close), ), ], ), @@ -193,7 +196,9 @@ class _RemoteSessionDialogState extends State with MountedS useBackgroundFocus: true, child: TextButton.icon( onPressed: _toggleServer, - icon: Icon(provider.isHostServerRunning ? Icons.stop : Icons.play_arrow), + icon: AppIcon( + provider.isHostServerRunning ? Symbols.stop_rounded : Symbols.play_arrow_rounded, + ), label: Text( provider.isHostServerRunning ? t.companionRemote.session.stopServer @@ -233,12 +238,12 @@ class _RemoteSessionDialogState extends State with MountedS if (provider.isHostServerRunning) { return Text( t.companionRemote.session.serverRunning, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).textTheme.bodySmall?.color), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: tokens(context).textMuted), ); } return Text( t.companionRemote.session.serverStopped, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: tokens(context).textMuted), ); } @@ -253,7 +258,10 @@ class _RemoteSessionDialogState extends State with MountedS Container( width: 12, height: 12, - decoration: BoxDecoration(shape: BoxShape.circle, color: isRunning ? Colors.green : Colors.grey), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRunning ? Colors.green : tokens(context).textMuted, + ), ), const SizedBox(width: 16), Expanded( @@ -287,7 +295,7 @@ class _RemoteSessionDialogState extends State with MountedS padding: const EdgeInsets.all(16.0), child: Column( children: [ - const Icon(Icons.check_circle, color: Colors.green, size: 48), + const AppIcon(Symbols.check_circle_rounded, color: Colors.green, size: 48), const SizedBox(height: 8), Text(t.companionRemote.session.connected, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index a913b93b..d35d59bf 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; import '../focus/focusable_button.dart'; @@ -7,6 +8,7 @@ import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../models/trackers/device_code.dart'; import '../utils/snackbar_helper.dart'; +import 'app_icon.dart'; import 'dialog_action_button.dart'; import 'loading_indicator_box.dart'; @@ -77,7 +79,7 @@ class DeviceCodeDialog extends StatelessWidget { onPressed: _open, useBackgroundFocus: true, child: FilledButton.icon( - icon: const Icon(Icons.open_in_new), + icon: const AppIcon(Symbols.open_in_new_rounded), label: Text(t.services.deviceCode.openToActivate(service: serviceName)), onPressed: _open, ), diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index c5557e78..a760f749 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -32,7 +32,7 @@ class EpisodeCard extends StatefulWidget { final MediaItem episode; final MediaServerClient? client; final VoidCallback onTap; - final Future Function(String)? onRefresh; + final Future Function(MediaItem source)? onRefresh; final Future Function()? onListRefresh; final bool autofocus; final bool isOffline; @@ -92,7 +92,7 @@ class _EpisodeCardState extends State with ContextMenuTapMixin with MountedSetStateMixin, Skele child: Column( mainAxisSize: .min, children: [ - Icon( + AppIcon( Symbols.arrow_forward_rounded, size: isTv ? 42 : 32, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), @@ -695,10 +695,7 @@ class HubSectionState extends State with MountedSetStateMixin, Skele padding: widget.inset ? const EdgeInsets.symmetric(vertical: 8) : const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - t.messages.noItemsAvailable, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), - ), + child: Text(t.messages.noItemsAvailable, style: Theme.of(context).textTheme.bodySmall), ), ], ), diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index b531f83f..c57a7eeb 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -121,7 +121,7 @@ class MediaCard extends StatefulWidget { final Object item; final double? width; final double? height; - final void Function(String itemId)? onRefresh; + final void Function(MediaItem source)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // Callback to refresh the entire parent list /// Overrides the card's default media navigation for specialized surfaces. @@ -881,11 +881,12 @@ Widget _buildPosterImage( posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl; final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)); final fallbackIcon = _mediaPosterFallbackIcon(item); + final imageType = MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext); Widget image; // Square 1:1 artwork for music (artists/albums/tracks) - if (item.kind.isMusic) { + if (imageType == ImageType.square) { image = OptimizedMediaImage( client: mediaClient, imagePath: posterUrl, @@ -912,7 +913,7 @@ Widget _buildPosterImage( imageType: ImageType.square, localFilePath: localPosterPath, ); - } else if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { + } else if (imageType == ImageType.thumb) { // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) image = OptimizedMediaImage.thumb( client: mediaClient, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 09b05f8a..ee106c0e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,6 +1,5 @@ import 'dart:async'; import '../media/ids.dart'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -98,7 +97,7 @@ class MediaContextMenu extends StatefulWidget { /// Dart has no nominal union type — guarded at runtime via the /// [_itemAsMediaItem] / [_itemAsPlaylist] helpers. final Object item; - final void Function(String itemId)? onRefresh; + final void Function(MediaItem source)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; @@ -142,9 +141,9 @@ class MediaContextMenuState extends State { bool get isContextMenuOpen => _isContextMenuOpen; - void _notifyRefresh(String itemId) { + void _notifyRefresh(MediaItem source) { if (!mounted) return; - widget.onRefresh?.call(itemId); + widget.onRefresh?.call(source); } void _notifyListRefresh() { @@ -189,7 +188,7 @@ class MediaContextMenuState extends State { _ => null, }; - /// Item identifier for refresh callbacks. + /// Item identifier used for playlist and download sync operations. String _itemId() => switch (widget.item) { MediaItem(:final id) => id, MediaPlaylist(:final id) => id, @@ -234,8 +233,6 @@ class MediaContextMenuState extends State { (mediaKind == MediaKind.movie || mediaKind == MediaKind.episode) && mediaItem?.hasActiveProgress == true; - final useBottomSheet = Platform.isIOS || Platform.isAndroid; - // Check if user has admin privileges. Backend-neutral: Plex uses the // server-owned flag (folded with the active Plex Home profile's admin // bit, when applicable); Jellyfin uses `JellyfinConnection.isAdministrator` @@ -575,44 +572,29 @@ class MediaContextMenuState extends State { menuActions.add(_MenuAction(value: 'extra_$i', icon: entry.icon, label: entry.label)); } - String? selected; - final openedFromKeyboard = _openedFromKeyboard; _openedFromKeyboard = false; - if (useBottomSheet) { - // Present from the menu's own context: it sits at the trigger widget, - // below any screen-level OverlaySheetHost, while callers often pass a - // screen context from ABOVE its host (which would skip the host and - // fall back to a hostless modal sheet). - selected = await OverlaySheetController.showAdaptive( - this.context, - showDragHandle: true, - isScrollControlled: true, - builder: (context) => AppMenuSheet( - title: _itemDisplayTitle(), - entries: _menuEntries(menuActions), - focusFirstItem: openedFromKeyboard, - ), - ); - } else { - Offset position; - if (_tapPosition != null) { - position = _tapPosition!; - } else { - final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; - final RenderBox renderBox = context.findRenderObject() as RenderBox; - position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); - } - - selected = await showAppMenu( - context, - entries: _menuEntries(menuActions), - position: position, - focusFirstItem: openedFromKeyboard, - ); + var position = _tapPosition; + if (position == null) { + final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; + final RenderBox renderBox = context.findRenderObject() as RenderBox; + position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); } + // Present from the menu's own context: it sits at the trigger widget, + // below any screen-level OverlaySheetHost, while callers often pass a + // screen context from ABOVE its host (which would skip the host and + // fall back to a hostless modal sheet). + final selected = await showAdaptiveAppMenu( + this.context, + title: _itemDisplayTitle(), + entries: _menuEntries(menuActions), + position: position, + focusFirstItem: openedFromKeyboard, + isScrollControlled: true, + ); + try { if (!context.mounted) return; @@ -656,7 +638,7 @@ class MediaContextMenuState extends State { context, watched ? t.messages.markedAsWatchedOffline : t.messages.markedAsUnwatchedOffline, ); - _notifyRefresh(item.id); + _notifyRefresh(item); } } else { await _executeAction(context, () async { @@ -676,7 +658,7 @@ class MediaContextMenuState extends State { if (widget.onRemoveFromContinueWatching != null) { widget.onRemoveFromContinueWatching!(); } else { - _notifyRefresh(mediaItem.id); + _notifyRefresh(mediaItem); } } } catch (e) { @@ -711,7 +693,7 @@ class MediaContextMenuState extends State { if (context.mounted) { final item = mediaItem!; await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item))); - _notifyRefresh(item.id); + _notifyRefresh(item); } break; @@ -720,7 +702,7 @@ class MediaContextMenuState extends State { if (context.mounted) { final item = mediaItem!; await Navigator.push(context, MaterialPageRoute(builder: (context) => PlexMatchScreen(metadata: item))); - _notifyRefresh(item.id); + _notifyRefresh(item); } break; @@ -737,13 +719,16 @@ class MediaContextMenuState extends State { await _navigateToRelated( context, mediaItem!.kind == MediaKind.season ? mediaItem.parentId : mediaItem.grandparentId, - (item) { + (context, item) async { final target = mediaDetailNavigationTargetFor(mediaItem, metadataOverride: item); - return mediaDetailRoute( - metadata: target.metadata, - initialSeasonIndex: target.initialSeasonIndex, - initialSeasonId: target.initialSeasonId, - initialEpisodeId: target.initialEpisodeId, + await Navigator.push( + context, + mediaDetailRoute( + metadata: target.metadata, + initialSeasonIndex: target.initialSeasonIndex, + initialSeasonId: target.initialSeasonId, + initialEpisodeId: target.initialEpisodeId, + ), ); }, t.messages.errorLoadingSeries, @@ -828,12 +813,7 @@ class MediaContextMenuState extends State { case 'music_album': didNavigate = true; - await _navigateToRelated( - context, - mediaItem!.parentId, - (item) => MaterialPageRoute(builder: (_) => AlbumDetailScreen(album: item)), - t.common.error, - ); + await _navigateToRelated(context, mediaItem!.parentId, navigateToAlbum, t.common.error); break; case 'music_artist': @@ -841,7 +821,7 @@ class MediaContextMenuState extends State { await _navigateToRelated( context, mediaItem!.kind == MediaKind.album ? mediaItem.parentId : mediaItem.grandparentId, - (item) => MaterialPageRoute(builder: (_) => ArtistDetailScreen(artist: item)), + navigateToArtist, t.common.error, ); break; @@ -884,7 +864,7 @@ class MediaContextMenuState extends State { await action(); if (context.mounted) { showSuccessSnackBar(context, successMessage); - _notifyRefresh(_itemId()); + if (_mediaItem case final source?) _notifyRefresh(source); } } catch (e) { if (context.mounted) { @@ -916,7 +896,7 @@ class MediaContextMenuState extends State { if (!context.mounted) return; if (success) { showSuccessSnackBar(context, t.matchScreen.unmatchSuccess); - _notifyRefresh(item.id); + _notifyRefresh(item); } else { showErrorSnackBar(context, t.matchScreen.unmatchFailed); } @@ -927,23 +907,24 @@ class MediaContextMenuState extends State { } } - /// Navigate to a related item (series or season) + /// Fetch and navigate to a related item (series, album, or artist). Future _navigateToRelated( BuildContext context, String? id, - Route Function(MediaItem) routeBuilder, + Future Function(BuildContext context, MediaItem item) navigate, String errorPrefix, ) async { if (id == null) return; final client = _getMediaClientForItem(); - final refreshItemId = _itemId(); + final source = _mediaItem; + if (source == null) return; try { final metadata = await client.fetchItem(id); if (metadata != null && context.mounted) { - await Navigator.push(context, routeBuilder(metadata)); - _notifyRefresh(refreshItemId); + await navigate(context, metadata); + _notifyRefresh(source); } } catch (e) { if (context.mounted) { @@ -1325,8 +1306,8 @@ class MediaContextMenuState extends State { builder: (context) => RatingBottomSheet( item: item, serverClient: client, - onServerRatingChanged: (_) => _notifyRefresh(item.id), - onServerFavoriteChanged: (_) => _notifyRefresh(item.id), + onServerRatingChanged: (_) => _notifyRefresh(item), + onServerFavoriteChanged: (_) => _notifyRefresh(item), ), ); } @@ -1644,7 +1625,7 @@ class MediaContextMenuState extends State { // DownloadProvider.deleteDownload now broadcasts the DeletionEvent, // so DeletionAware screens (e.g. offline season detail) update without // a duplicate notification here. - _notifyRefresh(item.id); + _notifyRefresh(item); } } catch (e) { appLogger.e('Failed to delete download', error: e); @@ -1908,7 +1889,7 @@ class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { decoration: pillInputDecoration( context, hintText: widget.searchHint, - prefixIcon: const Icon(Symbols.search_rounded, size: 20), + prefixIcon: const AppIcon(Symbols.search_rounded, size: 20), ), onChanged: _onFilterChanged, ), diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart index 18f06fe1..b0e2c72b 100644 --- a/lib/widgets/music/track_row.dart +++ b/lib/widgets/music/track_row.dart @@ -34,7 +34,7 @@ class TrackRow extends StatefulWidget { final MediaItem item; final VoidCallback? onTap; - final void Function(String itemId)? onRefresh; + final void Function(MediaItem source)? onRefresh; /// Grouped-card corner shaping (see class doc). final bool isFirst; diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index 8d463d0c..6ec125ae 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -8,6 +9,7 @@ import '../focus/focusable_button.dart'; import '../focus/focusable_wrapper.dart'; import '../services/trackers/oauth_proxy_client.dart'; import '../utils/snackbar_helper.dart'; +import 'app_icon.dart'; import 'dialog_action_button.dart'; import 'loading_indicator_box.dart'; @@ -86,7 +88,7 @@ class OAuthProxyDialog extends StatelessWidget { onPressed: _open, useBackgroundFocus: true, child: FilledButton.icon( - icon: const Icon(Icons.open_in_new), + icon: const AppIcon(Symbols.open_in_new_rounded), label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)), onPressed: _open, ), diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 1a84d18f..7e2d3ae6 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -1,16 +1,12 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:ui'; -import 'package:cached_network_image_ce/cached_network_image.dart'; -import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/widgets/app_icon.dart'; import '../media/media_server_client.dart'; import '../services/device_performance.dart'; -import '../services/image_cache_service.dart'; import '../utils/app_logger.dart'; import '../utils/media_image_helper.dart'; import '../utils/obfuscation_utils.dart'; @@ -305,17 +301,13 @@ class OptimizedMediaImage extends StatelessWidget { imageType: imageType, ); - final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl); - - final provider = CachedNetworkImageProvider( - imageUrl, - cacheKey: effectiveCacheKey, - cacheManager: PlexImageCacheManager.instance, - headers: const {'User-Agent': 'Plezy'}, + final resizedProvider = MediaImageHelper.serverArtworkProvider( + imageUrl: imageUrl, + memWidth: memWidth, + memHeight: memHeight, + cacheKey: cacheKey, ); - final resizedProvider = MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight); - // Reduced tier: swap in directly, no fade machinery at all. if (DevicePerformance.isReduced) { return Image( @@ -389,30 +381,6 @@ class OptimizedMediaImage extends StatelessWidget { Widget _buildFallback(BuildContext context) => _surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded); - - /// url → disk-cache key. The SHA-1 is not free on the UI thread and this - /// runs per image per build, so memoize it (bounded LRU, same eviction - /// style as MediaCard's failed-poster set). - static final _cacheKeyByUrl = {}; - static const _cacheKeyCap = 512; - - String _generateCacheKey(String imageUrl) { - final cached = _cacheKeyByUrl.remove(imageUrl); - if (cached != null) { - _cacheKeyByUrl[imageUrl] = cached; // re-insert as most recently used - return cached; - } - // URL already encodes bucketed transcode dimensions via roundDimensions, - // so the URL hash alone uniquely identifies the bytes on disk. Including - // mem-cache dimensions here would re-introduce churn on every pixel of - // window resize and defeat getMemCacheDimensions' bucketing. - final key = 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}'; - if (_cacheKeyByUrl.length >= _cacheKeyCap) { - _cacheKeyByUrl.remove(_cacheKeyByUrl.keys.first); - } - _cacheKeyByUrl[imageUrl] = key; - return key; - } } /// Fades a network image in by animating the image paint's alpha diff --git a/lib/widgets/seerr_request_sheet.dart b/lib/widgets/seerr_request_sheet.dart index d2fc6185..48664622 100644 --- a/lib/widgets/seerr_request_sheet.dart +++ b/lib/widgets/seerr_request_sheet.dart @@ -401,7 +401,7 @@ class _SeerrRequestSheetState extends State { value: _is4k, onChanged: _submitting ? null : _toggle4k, title: Text(t.seerr.request4k), - secondary: const AppIcon(Symbols.four_k, fill: 1), + secondary: const AppIcon(Symbols.four_k_rounded, fill: 1), contentPadding: EdgeInsets.zero, ), if (_advancedAllowed && _serversForVariant.isNotEmpty) ..._buildAdvancedSection(theme), diff --git a/lib/widgets/server_activities_button.dart b/lib/widgets/server_activities_button.dart index 8dd3e7dd..4a825c75 100644 --- a/lib/widgets/server_activities_button.dart +++ b/lib/widgets/server_activities_button.dart @@ -230,7 +230,7 @@ class ServerActivitiesButtonState extends State { child: Column( mainAxisSize: .min, children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), + AppIcon(Symbols.error_outline_rounded, color: theme.colorScheme.error), const SizedBox(height: 8), Text(t.serverTasks.failedToLoad, style: theme.textTheme.bodyMedium), ], diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 97fd596c..fe5801cf 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -314,7 +314,7 @@ class TvBrowseRail extends StatefulWidget { final bool showServerName; final ValueChanged? onFocusedItemChanged; final void Function(MediaHub hub, MediaItem item)? onFocusedHubItemChanged; - final void Function(String)? onRefresh; + final void Function(MediaItem source)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final bool Function(MediaHub hub)? isContinueWatchingHub; final bool Function(MediaHub hub)? usesContinueWatchingAction; diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index d81c7759..b68d462a 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -243,6 +243,12 @@ class TvSpotlightBackground extends StatelessWidget { if (logoPath == null || logoPath.isEmpty) { return SizedBox(width: logoWidth, height: logoHeight, child: _buildTitle(context, title)); } + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final (logoMemWidth, logoMemHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (logoWidth * dpr).round(), + displayHeight: (logoHeight * dpr).round(), + imageType: ImageType.heroLogo, + ); final localLogoPath = localArtworkPathResolver?.call(logoPath); if (localLogoPath != null && File(localLogoPath).existsSync()) { @@ -250,8 +256,12 @@ class TvSpotlightBackground extends StatelessWidget { width: logoWidth, height: logoHeight, child: blurArtwork( - Image.file( - File(localLogoPath), + Image( + image: MediaImageHelper.boundedDecode( + FileImage(File(localLogoPath)), + memWidth: logoMemWidth, + memHeight: logoMemHeight, + ), fit: BoxFit.contain, alignment: .centerLeft, errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), @@ -262,14 +272,13 @@ class TvSpotlightBackground extends StatelessWidget { ); } - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, thumbPath: logoPath, maxWidth: logoWidth, maxHeight: logoHeight, devicePixelRatio: dpr, - imageType: ImageType.logo, + imageType: ImageType.heroLogo, ); if (imageUrl.isEmpty) return _buildTitle(context, title); @@ -282,7 +291,8 @@ class TvSpotlightBackground extends StatelessWidget { cacheManager: PlexImageCacheManager.instance, fit: BoxFit.contain, alignment: .centerLeft, - memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(), + memCacheWidth: logoMemWidth, + memCacheHeight: logoMemHeight, fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)), fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)), placeholder: (context, url) => const SizedBox.shrink(), diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index ff63863d..8ed5da83 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../i18n/strings.g.dart'; import '../utils/platform_detector.dart'; +import 'app_icon.dart'; import 'clickable_cursor.dart'; import 'listenable_selector.dart'; @@ -220,12 +221,12 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { _characters('456'), _characters('789'), [ - _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded), + _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), const _TvKey.character('0'), - const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined), + const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), ], [ - _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded), + _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), const _TvKey.character('.'), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), ], @@ -233,7 +234,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { } final actionRow = [ - const _TvKey.action('Space', _TvKeyType.space, icon: Icons.space_bar_rounded), + const _TvKey.action('Space', _TvKeyType.space, icon: Symbols.space_bar_rounded), const _TvKey.character('@'), const _TvKey.character('#'), const _TvKey.character('_'), @@ -241,10 +242,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { const _TvKey.character(':'), const _TvKey.character('='), _isMultiline - ? const _TvKey.action('Line', _TvKeyType.newline, icon: Icons.keyboard_return_rounded) + ? const _TvKey.action('Line', _TvKeyType.newline, icon: Symbols.keyboard_return_rounded) : const _TvKey.character('&'), - _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded), - _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded), + _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), + _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), ]; @@ -253,10 +254,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { [const _TvKey.spacer(), ..._characters('qwertyuiop'), const _TvKey.spacer()], [const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'"), const _TvKey.spacer()], [ - const _TvKey.action('', _TvKeyType.symbols, icon: Icons.functions_rounded), - _TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift), + const _TvKey.action('', _TvKeyType.symbols, icon: Symbols.functions_rounded), + _TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift_rounded), ..._characters('zxcvbnm.-'), - const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined), + const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), ], [const _TvKey.spacer(), ...actionRow], ]; @@ -267,7 +268,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { [ const _TvKey.action('ABC', _TvKeyType.symbols), ..._symbols(['!', '?', r'$', '%', '^', '*', '+', '=', '~']), - const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined), + const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), const _TvKey.spacer(), ], [ @@ -285,13 +286,13 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { [ const _TvKey.spacer(), const _TvKey.spacer(), - const _TvKey.action('Space', _TvKeyType.space, icon: Icons.space_bar_rounded), + const _TvKey.action('Space', _TvKeyType.space, icon: Symbols.space_bar_rounded), const _TvKey.character('@'), const _TvKey.character('#'), const _TvKey.character('_'), const _TvKey.character('/'), - _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded), - _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded), + _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), + _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), const _TvKey.spacer(), const _TvKey.spacer(), @@ -330,13 +331,13 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { IconData _doneIcon() { switch (widget.textInputAction) { case TextInputAction.search: - return Icons.search_rounded; + return Symbols.search_rounded; case TextInputAction.next: - return Icons.arrow_forward_rounded; + return Symbols.arrow_forward_rounded; case TextInputAction.go: - return Icons.keyboard_double_arrow_right_rounded; + return Symbols.keyboard_double_arrow_right_rounded; default: - return Icons.check_rounded; + return Symbols.check_rounded; } } @@ -813,7 +814,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground, _TvKeyboardMetrics metrics) { final icon = key.icon; if (icon != null) { - return Icon( + return AppIcon( icon, color: foreground, size: key.type == _TvKeyType.space ? metrics.iconSize * 1.12 : metrics.iconSize, diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index c8533df1..d8eb7a72 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -613,7 +613,7 @@ class DesktopVideoControlsState extends State { left: 0, right: 0, bottom: 12, - child: Icon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), + child: AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), ), ], ), @@ -636,7 +636,7 @@ class DesktopVideoControlsState extends State { child: Column( mainAxisSize: .min, children: [ - const Icon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), + const AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), const SizedBox(height: 4), ContentStrip( key: _contentStripKey, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 071af3ab..f1d01777 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/app_icon.dart'; import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; @@ -273,7 +274,7 @@ class _MobileVideoControlsState extends State with SingleTi left: 0, right: 0, bottom: 12, - child: Icon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), + child: AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), ), ], ), @@ -306,7 +307,7 @@ class _MobileVideoControlsState extends State with SingleTi child: Column( mainAxisSize: .min, children: [ - const Icon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), + const AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), const SizedBox(height: 4), ContentStrip( player: widget.player, diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index 0838fdaf..fee764f9 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -278,7 +278,7 @@ class _SubtitleSearchSheetState extends State with Controll decoration: pillInputDecoration( context, hintText: widget.mediaTitle ?? t.metadataEdit.title, - prefixIcon: const Icon(Symbols.search_rounded, size: 20), + prefixIcon: const AppIcon(Symbols.search_rounded, size: 20), ), onChanged: _onTitleChanged, textInputAction: TextInputAction.search, @@ -446,7 +446,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle decoration: pillInputDecoration( context, hintText: t.videoControls.searchLanguages, - prefixIcon: const Icon(Symbols.search_rounded, size: 20), + prefixIcon: const AppIcon(Symbols.search_rounded, size: 20), ), onChanged: _onFilterChanged, onSubmitted: (_) => _focusFirstLanguageAfterSubmit(), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 5cf77a5d..b67a226e 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -350,7 +350,7 @@ class _VideoSettingsSheetState extends State { case _SettingsView.zoom: return Symbols.zoom_in_rounded; case _SettingsView.versionQuality: - return Symbols.art_track; + return Symbols.art_track_rounded; case _SettingsView.sleep: return Symbols.bedtime_rounded; case _SettingsView.audioSync: @@ -385,10 +385,10 @@ class _VideoSettingsSheetState extends State { } String _formatSleepTimer(SleepTimerService sleepTimer) { - if (!sleepTimer.isActive) return 'Off'; + if (!sleepTimer.isActive) return t.common.off; final remaining = sleepTimer.remainingTime; - if (remaining == null) return 'Off'; - return 'Active (${formatDurationWithSeconds(remaining)})'; + if (remaining == null) return t.common.off; + return t.videoSettings.sleepTimerActive(duration: formatDurationWithSeconds(remaining)); } String _formatZoomScale(double scale) => '${(scale * 100).round()}%'; @@ -473,7 +473,7 @@ class _VideoSettingsSheetState extends State { if (_hasVersionQuality) _SettingsMenuItem( - icon: Symbols.art_track, + icon: Symbols.art_track_rounded, title: _versionQualityTitle(), valueText: _versionQualityValueText(), allowValueOverflow: true, @@ -593,7 +593,7 @@ class _VideoSettingsSheetState extends State { if (widget.onToggleAmbientLighting != null) FocusableListTile( leading: AppIcon( - Symbols.blur_on, + Symbols.blur_on_rounded, fill: 1, color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, ), diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 4e659717..55645f23 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -180,7 +180,7 @@ class _SyncOffsetControlState extends State { color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8)), ), - child: Icon(icon, color: tokens(context).text, size: iconSize), + child: AppIcon(icon, color: tokens(context).text, size: iconSize), ), ), ); @@ -213,7 +213,7 @@ class _SyncOffsetControlState extends State { color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8)), ), - child: Icon(icon, color: tokens(context).text, size: 22), + child: AppIcon(icon, color: tokens(context).text, size: 22), ), ), ); diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index fadad422..c92c83cc 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -337,7 +337,7 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: Symbols.picture_in_picture_alt, + icon: Symbols.picture_in_picture_alt_rounded, tooltip: t.videoControls.pipButton, semanticLabel: t.videoControls.pipButton, isMobile: isMobile, diff --git a/test/i18n/localized_value_labels_test.dart b/test/i18n/localized_value_labels_test.dart new file mode 100644 index 00000000..2836241d --- /dev/null +++ b/test/i18n/localized_value_labels_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; + +void main() { + test('user-facing and accessibility labels are populated in every locale', () async { + for (final locale in AppLocale.values) { + final translations = await locale.build(); + final labels = { + 'hotkeys.pressToRecord': translations.hotkeys.pressToRecord, + 'hotkeys.recordingShortcut': translations.hotkeys.recordingShortcut, + 'accessibility.expandText': translations.accessibility.expandText, + 'accessibility.collapseText': translations.accessibility.collapseText, + 'watchTogether.openSessionControls': translations.watchTogether.openSessionControls, + 'watchTogether.copySessionCode': translations.watchTogether.copySessionCode, + 'services.deviceCode.copyCode': translations.services.deviceCode.copyCode, + 'services.oauthProxy.copyUrl': translations.services.oauthProxy.copyUrl, + }; + + for (final MapEntry(key: key, value: value) in labels.entries) { + expect(value.trim(), isNotEmpty, reason: '${locale.languageCode}: $key'); + } + } + }); +} diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index 71ea6a96..a23a9680 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -121,13 +121,18 @@ class _FakeAggregationService extends DataAggregationService { } class _FakeClient implements MediaServerClient { + final String serverIdValue; + final String serverNameValue; + final List fetchedItemIds = []; MediaItem? itemResult; - @override - ServerId get serverId => ServerId('server_1'); + _FakeClient({this.serverIdValue = 'server_1', this.serverNameValue = 'Server'}); @override - String? get serverName => 'Server'; + ServerId get serverId => ServerId(serverIdValue); + + @override + String? get serverName => serverNameValue; @override MediaBackend get backend => MediaBackend.plex; @@ -136,7 +141,10 @@ class _FakeClient implements MediaServerClient { ServerCapabilities get capabilities => ServerCapabilities.plex; @override - Future fetchItem(String id, {bool useCache = true}) async => itemResult; + Future fetchItem(String id, {bool useCache = true}) async { + fetchedItemIds.add(id); + return itemResult; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @@ -146,6 +154,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _FakeClient client; + late MultiServerManager manager; late _FakeAggregationService aggregation; late MultiServerProvider multiServer; late HiddenLibrariesProvider hiddenLibraries; @@ -162,7 +171,7 @@ void main() { shelfSyncs = []; client = _FakeClient(); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); + manager = MultiServerManager()..debugRegisterClientForTesting(client); aggregation = _FakeAggregationService(manager); multiServer = MultiServerProvider(manager, aggregation); hiddenLibraries = HiddenLibrariesProvider(); @@ -588,18 +597,42 @@ void main() { expect(provider.hubs, isEmpty); }); - test('updateItem refetches one item and swaps it in place', () async { - aggregation.onDeckResult = () => [_item('ep-1')]; + test('updateItem qualifies same bare ids by source server without fan-out', () async { + final serverOneItem = _item('shared-id', serverId: 'server_1').copyWith(title: 'Server One Original'); + final serverTwoItem = _item('shared-id', serverId: 'server_2').copyWith(title: 'Server Two Original'); + final serverTwoClient = _FakeClient(serverIdValue: 'server_2', serverNameValue: 'Server Two'); + manager.debugRegisterClientForTesting(serverTwoClient); + + aggregation.onDeckResult = () => [serverOneItem, serverTwoItem]; aggregation.hubsResult = () => [ - _hub('hub-1', items: [_item('movie-1')]), + _hub('hub-1', serverId: 'server_1', items: [serverOneItem]), + _hub('hub-2', serverId: 'server_2', items: [serverTwoItem]), ]; await provider.load(); + final onDeckCallsBefore = aggregation.onDeckCalls; + final hubCallsBefore = aggregation.hubCalls; + final source = provider.hubs + .expand((hub) => hub.items) + .singleWhere((item) => item.globalKey == serverTwoItem.globalKey); + final updated = serverTwoItem.copyWith(title: 'Server Two Refreshed'); + serverTwoClient.itemResult = updated; - client.itemResult = _item('movie-1').copyWith(title: 'Updated Title'); - await provider.updateItem('movie-1'); + await provider.updateItem(source); - expect(provider.hubs.single.items.single.title, 'Updated Title'); - expect(provider.onDeck.single.id, 'ep-1'); + // The old bare-id aggregation path fetched every server. The source item + // must instead select one client and leave both full-list fetch surfaces + // untouched. + expect(client.fetchedItemIds, isEmpty); + expect(serverTwoClient.fetchedItemIds, ['shared-id']); + expect(aggregation.onDeckCalls, onDeckCallsBefore); + expect(aggregation.hubCalls, hubCallsBefore); + + final onDeckByKey = {for (final item in provider.onDeck) item.globalKey: item}; + final hubItemsByKey = {for (final item in provider.hubs.expand((hub) => hub.items)) item.globalKey: item}; + expect(onDeckByKey[serverOneItem.globalKey], same(serverOneItem)); + expect(onDeckByKey[serverTwoItem.globalKey], same(updated)); + expect(hubItemsByKey[serverOneItem.globalKey], same(serverOneItem)); + expect(hubItemsByKey[serverTwoItem.globalKey], same(updated)); }); test('syncToOnlineServers reloads for mid-session connects only', () async { diff --git a/test/providers/theme_provider_test.dart b/test/providers/theme_provider_test.dart index a604c2e8..b8e790d6 100644 --- a/test/providers/theme_provider_test.dart +++ b/test/providers/theme_provider_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/providers/theme_provider.dart'; +import 'package:plezy/screens/settings/settings_utils.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/settings_service.dart' as settings; @@ -9,6 +11,7 @@ import '../test_helpers/prefs.dart'; void main() { setUp(resetSharedPreferencesForTest); + setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); group('ThemeProvider', () { test('exposes a non-null themeMode immediately and flips after init', () async { @@ -103,29 +106,41 @@ void main() { p.dispose(); }); - test('themeModeDisplayName and themeModeIcon match the active mode', () async { + test('themeModeLabel and themeModeIcon match the active mode', () async { final p = ThemeProvider(); await Future.delayed(Duration.zero); await p.setThemeMode(settings.ThemeMode.light); - expect(p.themeModeDisplayName, 'Light'); + expect(themeModeLabel(p.themeMode), t.settings.lightTheme); expect(p.themeModeIcon, Symbols.light_mode_rounded); await p.setThemeMode(settings.ThemeMode.dark); - expect(p.themeModeDisplayName, 'Dark'); + expect(themeModeLabel(p.themeMode), t.settings.darkTheme); expect(p.themeModeIcon, Symbols.dark_mode_rounded); await p.setThemeMode(settings.ThemeMode.oled); - expect(p.themeModeDisplayName, 'OLED'); + expect(themeModeLabel(p.themeMode), t.settings.oledTheme); expect(p.themeModeIcon, Symbols.contrast_rounded); await p.setThemeMode(settings.ThemeMode.system); - expect(p.themeModeDisplayName, 'System'); + expect(themeModeLabel(p.themeMode), t.settings.systemTheme); expect(p.themeModeIcon, Symbols.brightness_auto_rounded); p.dispose(); }); + test('themeModeLabel follows a non-English locale for every mode', () async { + await LocaleSettings.setLocale(AppLocale.de); + try { + expect(themeModeLabel(settings.ThemeMode.system), 'System'); + expect(themeModeLabel(settings.ThemeMode.light), 'Hell'); + expect(themeModeLabel(settings.ThemeMode.dark), 'Dunkel'); + expect(themeModeLabel(settings.ThemeMode.oled), 'OLED'); + } finally { + LocaleSettings.setLocaleSync(AppLocale.en); + } + }); + test('reload re-reads after external mutation', () async { final p = ThemeProvider(); await Future.delayed(Duration.zero); @@ -167,4 +182,36 @@ void main() { await p.reload(); }); }); + + testWidgets('regex dialog localizes its field label and validation error', (tester) async { + await LocaleSettings.setLocale(AppLocale.de); + addTearDown(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + await tester.pumpWidget( + material.MaterialApp( + home: material.Scaffold( + body: material.Builder( + builder: (context) => material.TextButton( + onPressed: () => showRegexInputDialog( + context: context, + title: 'Muster', + currentValue: '^intro', + defaultValue: '', + onSave: (_) async {}, + ), + child: const material.Text('Öffnen'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Öffnen')); + await tester.pumpAndSettle(); + expect(find.text('Regulärer Ausdruck'), findsOneWidget); + + await tester.enterText(find.byType(material.TextField), '['); + await tester.pump(); + expect(find.text('Ungültiger regulärer Ausdruck'), findsOneWidget); + }); } diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 23ec7729..539b2a61 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -226,17 +226,22 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Hosted request sheet'), findsOneWidget); - await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); - await tester.pumpAndSettle(); - expect(find.text('Hosted request sheet'), findsNothing); - - // Android TV also dispatches a route-level back for the same remote press, - // sometimes after the sheet's close animation has completed. + await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB); + // Android TV can dispatch route Back for the same remote press. Deliver + // that duplicate in the same key sequence, before the coordinator's + // one-frame ownership marker is cleared. await tester.binding.handlePopRoute(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB); await tester.pumpAndSettle(); + expect(find.text('Hosted request sheet'), findsNothing); expect(find.byType(CatalogItemDetailScreen), findsOneWidget); await expectLater(sheetResult, completion(isNull)); + + // A later, independent system Back still pops the catalog route. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.byType(CatalogItemDetailScreen), findsNothing); }); testWidgets('recommendation posters use compact grid-equivalent TV sizing', (tester) async { diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart index ba779361..043390e3 100644 --- a/test/screens/catalog_search_screen_test.dart +++ b/test/screens/catalog_search_screen_test.dart @@ -1,12 +1,23 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; +import 'package:plezy/models/catalog/catalog_cast_member.dart'; +import 'package:plezy/providers/catalog_sources_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/screens/catalog_search_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; +import 'package:plezy/services/catalog/catalog_library_matcher.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/app_menu.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; @@ -21,6 +32,9 @@ class _FakeSearchSource implements CatalogSource { @override String get displayName => 'Trakt'; + @override + bool get supportsWatchlist => false; + @override Future> search(String query, {int limit = 30}) async { queries.add(query); @@ -33,6 +47,15 @@ class _FakeSearchSource implements CatalogSource { ]; } + @override + Future> fetchCast(CatalogItem item, {int limit = 20}) async => const []; + + @override + Future> fetchRelated(CatalogItem item, {int limit = 20}) async => const []; + + @override + void dispose() {} + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -98,6 +121,111 @@ void main() { expect(_state(tester).searchResults.single.title, 'result: abcd'); expect(find.text(t.explore.searchFailed), findsNothing); }); + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('catalog result menu uses the adaptive sheet and dispatches on ${platform.name}', (tester) async { + final source = _FakeSearchSource(); + await _pumpMenuSearch(tester, source, platform: platform); + await _searchForMenuResult(tester); + + final card = find.byType(MediaCard); + expect(card, findsOneWidget); + tester.state(card).showContextMenu(); + await tester.pumpAndSettle(); + + expect(_adaptiveMenuSheet(), findsOneWidget); + expect(_appMenuList(), findsOneWidget); + + await tester.tap(find.text(t.mediaMenu.viewDetails)); + await tester.pumpAndSettle(); + + expect(_adaptiveMenuSheet(), findsNothing); + expect(find.byType(CatalogItemDetailScreen), findsOneWidget); + }); + } + + testWidgets('catalog result menu uses an anchored desktop popup and dispatches its result', (tester) async { + final source = _FakeSearchSource(); + await _pumpMenuSearch(tester, source, platform: TargetPlatform.macOS); + await _searchForMenuResult(tester); + + final card = find.byType(MediaCard); + expect(card, findsOneWidget); + final cardRect = tester.getRect(card); + tester.state(card).showContextMenu(); + await tester.pumpAndSettle(); + + expect(_adaptiveMenuSheet(), findsNothing); + expect(_appMenuList(), findsOneWidget); + final menuRect = tester.getRect(_appMenuList()); + expect(menuRect.left, closeTo(cardRect.left, 0.01)); + expect(menuRect.top, closeTo(cardRect.bottom + 4, 0.01)); + + await tester.tap(find.text(t.mediaMenu.viewDetails)); + await tester.pumpAndSettle(); + + expect(_appMenuList(), findsNothing); + expect(find.byType(CatalogItemDetailScreen), findsOneWidget); + }); } dynamic _state(WidgetTester tester) => tester.state>(find.byType(CatalogSearchScreen)); + +class _FakeCatalogSourcesProvider extends CatalogSourcesProvider { + _FakeCatalogSourcesProvider(this.source); + + final CatalogSource source; + + @override + List get connectedSources => [source]; +} + +class _FakeCatalogLibraryMatcher extends CatalogLibraryMatcher { + _FakeCatalogLibraryMatcher(super.multiServer); + + @override + Future> match(CatalogItem item) async => const []; +} + +Future _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {required TargetPlatform platform}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = platform == TargetPlatform.iOS || platform == TargetPlatform.android + ? const Size(390, 844) + : const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final sources = _FakeCatalogSourcesProvider(source); + final manager = MultiServerManager(); + final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final matcher = _FakeCatalogLibraryMatcher(multiServer); + addTearDown(manager.dispose); + addTearDown(multiServer.dispose); + addTearDown(sources.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + Provider.value(value: matcher), + ChangeNotifierProvider.value(value: sources), + ], + child: MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: platform), + home: CatalogSearchScreen(source: source), + ), + ), + ), + ); +} + +Future _searchForMenuResult(WidgetTester tester) async { + await tester.enterText(find.byType(TextField), 'menu'); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); + expect(find.text('result: menu'), findsOneWidget); +} + +Finder _adaptiveMenuSheet() => find.byWidgetPredicate((widget) => widget is AppMenuSheet); + +Finder _appMenuList() => find.byWidgetPredicate((widget) => widget is AppMenuList); diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart index 8c9187a6..c9912611 100644 --- a/test/screens/downloads/downloads_screen_focus_test.dart +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -1,11 +1,20 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/models/download_models.dart'; +import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/downloads/downloads_screen.dart'; @@ -16,9 +25,14 @@ import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/music/music_playback_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/io_fakes.dart'; class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db); @@ -30,23 +44,33 @@ class _FakeConnectionRegistry extends ConnectionRegistry { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + final originalPathProvider = PathProviderPlatform.instance; + late AppDatabase db; late DownloadProvider downloadProvider; late MultiServerProvider multiServerProvider; late MultiServerManager serverManager; + late DownloadManagerService downloadManager; + late DownloadStorageService storageService; + late Directory temporaryDirectory; setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + temporaryDirectory = await Directory.systemTemp.createTemp('downloads_screen_focus_test_'); + PathProviderPlatform.instance = FakePathProvider(temporaryDirectory); await SettingsService.getInstance(); + storageService = DownloadStorageService.instance; + await storageService.initialize(SettingsService.instance); db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); JellyfinApiCache.initialize(db); - final downloadManager = DownloadManagerService( + downloadManager = DownloadManagerService( database: db, - storageService: DownloadStorageService.instance, + storageService: storageService, clientResolver: (serverId, {clientScopeId}) => null, ); downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); @@ -58,29 +82,21 @@ void main() { tearDown(() async { downloadProvider.dispose(); + downloadManager.dispose(); multiServerProvider.dispose(); await db.close(); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + PathProviderPlatform.instance = originalPathProvider; + if (await temporaryDirectory.exists()) { + await temporaryDirectory.delete(recursive: true); + } }); testWidgets('right from the last tab (Music) focuses and opens Sync Rules action', (tester) async { final screenKey = GlobalKey(); - await tester.pumpWidget( - InputModeTracker( - child: MultiProvider( - providers: [ - Provider.value(value: _FakeConnectionRegistry(db)), - ChangeNotifierProvider.value(value: downloadProvider), - ChangeNotifierProvider.value(value: multiServerProvider), - ], - child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.macOS), - home: DownloadsScreen(key: screenKey), - ), - ), - ), - ); - await tester.pumpAndSettle(); + await _pumpScreen(tester, db, downloadProvider, multiServerProvider, screenKey: screenKey); final state = screenKey.currentState!; state.tabController.index = 3; @@ -94,4 +110,238 @@ void main() { expect(find.text('Sync rules'), findsOneWidget); }); + + testWidgets('grid view renders downloaded offline cards in a bounded sliver grid', (tester) async { + await SettingsService.instance.write(SettingsService.viewMode, ViewMode.grid); + _seedDownloadedMovies(downloadProvider, 4); + final screenKey = GlobalKey(); + var sidebarCalls = 0; + + await _pumpScreen( + tester, + db, + downloadProvider, + multiServerProvider, + screenKey: screenKey, + onSidebar: () => sidebarCalls++, + ); + screenKey.currentState!.tabController.index = 2; + await tester.pumpAndSettle(); + + final scrollView = _activeDownloadsScrollView(); + expect(scrollView, findsOneWidget); + expect(find.descendant(of: scrollView, matching: find.byType(SliverGrid)), findsOneWidget); + expect(find.descendant(of: scrollView, matching: find.byType(SliverList)), findsNothing); + _expectBoundedSingleScrollViewport(tester, scrollView); + + final cards = tester + .widgetList(find.descendant(of: scrollView, matching: find.byType(FocusableMediaCard))) + .toList(); + expect(cards, hasLength(4)); + expect(cards.every((card) => card.isOffline), isTrue); + expect(cards.every((card) => !card.disableScale), isTrue); + + final first = _movieCard(cards, 0); + final second = _movieCard(cards, 1); + expect(first.focusNode, isNotNull); + expect(first.onBack, isNotNull); + expect(first.onNavigateLeft, isNotNull); + expect(second.onNavigateLeft, isNull); + + first.onNavigateLeft!(); + expect(sidebarCalls, 1); + first.onBack!(); + await tester.pump(); + expect(screenKey.currentState!.getTabChipFocusNode(2).hasFocus, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('list view renders downloaded offline cards as bounded sliver rows', (tester) async { + await SettingsService.instance.write(SettingsService.viewMode, ViewMode.list); + _seedDownloadedMovies(downloadProvider, 3); + final screenKey = GlobalKey(); + var sidebarCalls = 0; + + await _pumpScreen( + tester, + db, + downloadProvider, + multiServerProvider, + screenKey: screenKey, + onSidebar: () => sidebarCalls++, + ); + screenKey.currentState!.tabController.index = 2; + await tester.pumpAndSettle(); + + final scrollView = _activeDownloadsScrollView(); + expect(scrollView, findsOneWidget); + expect(find.descendant(of: scrollView, matching: find.byType(SliverList)), findsOneWidget); + expect(find.descendant(of: scrollView, matching: find.byType(SliverGrid)), findsNothing); + _expectBoundedSingleScrollViewport(tester, scrollView); + + final cards = tester + .widgetList(find.descendant(of: scrollView, matching: find.byType(FocusableMediaCard))) + .toList(); + expect(cards, hasLength(3)); + expect(cards.every((card) => card.isOffline), isTrue); + expect(cards.every((card) => card.disableScale), isTrue); + + final first = _movieCard(cards, 0); + final second = _movieCard(cards, 1); + expect(first.focusNode, isNotNull); + expect(first.onBack, isNotNull); + expect(first.onNavigateLeft, isNotNull); + expect(second.onNavigateLeft, isNotNull); + + second.onNavigateLeft!(); + expect(sidebarCalls, 1); + first.onBack!(); + await tester.pump(); + expect(screenKey.currentState!.getTabChipFocusNode(2).hasFocus, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('downloaded album header bounds local artwork decoding on both axes', (tester) async { + const serverId = 'downloads-layout-server'; + const thumbPath = '/downloads-layout/album/thumb'; + final artworkPath = downloadProvider.getArtworkLocalPath(ServerId(serverId), thumbPath); + expect(artworkPath, isNotNull); + final artworkFile = File(artworkPath!); + artworkFile.parent.createSync(recursive: true); + artworkFile.writeAsBytesSync( + base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), + ); + addTearDown(() { + if (artworkFile.existsSync()) artworkFile.deleteSync(); + }); + + final track = testMediaItem( + id: 'downloaded-track', + kind: MediaKind.track, + title: 'Downloaded Track', + parentId: 'downloaded-album', + parentTitle: 'Downloaded Album', + grandparentId: 'downloaded-artist', + grandparentTitle: 'Downloaded Artist', + parentIndex: 1, + index: 1, + serverId: serverId, + ); + final album = testMediaItem( + id: 'downloaded-album', + kind: MediaKind.album, + title: 'Downloaded Album', + parentId: 'downloaded-artist', + parentTitle: 'Downloaded Artist', + thumbPath: thumbPath, + serverId: serverId, + ); + downloadProvider.debugSeedState( + downloads: { + '$serverId:${track.id}': DownloadProgress(globalKey: '$serverId:${track.id}', status: DownloadStatus.completed), + }, + metadata: {'$serverId:${track.id}': track, '$serverId:${album.id}': album}, + ); + final screenKey = GlobalKey(); + + await _pumpScreen(tester, db, downloadProvider, multiServerProvider, screenKey: screenKey); + screenKey.currentState!.tabController.index = 3; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Downloaded Album'), findsOneWidget); + final headerImages = tester + .widgetList(find.byType(Image)) + .where((image) => image.width == 48 && image.height == 48) + .toList(); + expect(headerImages, hasLength(1)); + final provider = headerImages.single.image; + expect(provider, isA()); + final resized = provider as ResizeImage; + expect(resized.width, 120); + expect(resized.height, 180); + expect(resized.policy, ResizeImagePolicy.fit); + expect(resized.imageProvider, isA()); + expect((resized.imageProvider as FileImage).file.path, artworkFile.path); + expect(tester.takeException(), isNull); + }); +} + +MediaItem _downloadedMovie(int index) => testMediaItem( + id: 'movie-$index', + kind: MediaKind.movie, + title: 'Downloaded Movie $index', + serverId: 'downloads-layout-server', +); + +void _seedDownloadedMovies(DownloadProvider provider, int count) { + final movies = List.generate(count, _downloadedMovie); + provider.debugSeedState( + downloads: { + for (final movie in movies) + movie.globalKey: DownloadProgress(globalKey: movie.globalKey, status: DownloadStatus.completed), + }, + metadata: {for (final movie in movies) movie.globalKey: movie}, + ); +} + +FocusableMediaCard _movieCard(List cards, int index) => + cards.singleWhere((card) => (card.item as MediaItem).id == 'movie-$index'); + +Finder _activeDownloadsScrollView() => + find.descendant(of: find.byType(TabBarView), matching: find.byType(CustomScrollView)); + +void _expectBoundedSingleScrollViewport(WidgetTester tester, Finder scrollView) { + final renderBox = tester.renderObject(scrollView); + expect(renderBox.constraints, const BoxConstraints(minWidth: 1200, maxWidth: 1200, minHeight: 744, maxHeight: 744)); + expect(tester.getSize(scrollView), const Size(1200, 744)); + + final scrollable = find.descendant(of: scrollView, matching: find.byType(Scrollable)); + expect(scrollable, findsOneWidget); + final position = tester.state(scrollable).position; + expect(position.hasContentDimensions, isTrue); + expect(position.viewportDimension, 744); + expect(position.minScrollExtent, 0); + expect(position.maxScrollExtent, 0); + expect(position.pixels, 0); +} + +Future _pumpScreen( + WidgetTester tester, + AppDatabase db, + DownloadProvider downloadProvider, + MultiServerProvider multiServerProvider, { + required GlobalKey screenKey, + VoidCallback? onSidebar, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + InputModeTracker( + child: MultiProvider( + providers: [ + Provider.value(value: _FakeConnectionRegistry(db)), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider(create: (_) => StubMusicPlaybackService()), + ], + child: MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS), + home: MainScreenFocusScope( + focusSidebar: onSidebar ?? () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: DownloadsScreen(key: screenKey), + ), + ), + ), + ), + ); + await tester.pump(); } diff --git a/test/screens/focusable_detail_screen_mixin_test.dart b/test/screens/focusable_detail_screen_mixin_test.dart index e36539b1..c2303c16 100644 --- a/test/screens/focusable_detail_screen_mixin_test.dart +++ b/test/screens/focusable_detail_screen_mixin_test.dart @@ -1,20 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/mixins/grid_focus_node_mixin.dart'; import 'package:plezy/screens/focusable_detail_screen_mixin.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { TvDetectionService.debugSetAppleTVOverride(false); + BackKeyCoordinator.clear(); }); tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); + BackKeyCoordinator.clear(); }); testWidgets('detail scaffold scrolls to top on iOS top safe-area tap', (tester) async { @@ -68,12 +73,105 @@ void main() { expect(detailRoute, isNotNull); expect(detailRoute!.popGestureEnabled, isTrue); }); + + testWidgets('collection surface hosts adaptive sheets and system Back closes the sheet before the route', ( + tester, + ) async { + await _pushDetailSurface(tester, surfaceName: 'collection', hasActions: true); + + await tester.tap(find.text('Open collection sheet')); + await tester.pumpAndSettle(); + + final sheet = find.text('collection sheet'); + expect(sheet, findsOneWidget); + expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(sheet, findsNothing); + expect(find.byType(_TestDetailScreen), findsOneWidget); + }); + + testWidgets('actor surface D-pad Back closes its hosted sheet, then actor can pop with no actions', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + await _pushDetailSurface(tester, surfaceName: 'actor', hasActions: false); + + await tester.tap(find.text('Open actor sheet')); + await tester.pumpAndSettle(); + + final sheet = find.text('actor sheet'); + expect(sheet, findsOneWidget); + expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.gameButtonB); + + // Android TV can dispatch route Back for the same remote press. Dispatch + // that duplicate before pumping so the coordinator deterministically + // associates it with this key-down instead of a later, independent Back. + await tester.binding.handlePopRoute(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.gameButtonB); + await tester.pumpAndSettle(); + + expect(sheet, findsNothing); + expect(find.byType(_TestDetailScreen), findsOneWidget); + + // After the key press has settled, a new system Back is independent. With + // no actor actions to focus, it must pop the route immediately. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(_TestDetailScreen), findsNothing); + expect(find.text('Open actor detail'), findsOneWidget); + }); + + testWidgets('collection surface system Back focuses its action bar before popping the route', (tester) async { + await _pushDetailSurface(tester, surfaceName: 'collection', hasActions: true); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(_TestDetailScreen), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(_TestDetailScreen), findsNothing); + expect(find.text('Open collection detail'), findsOneWidget); + }); +} + +Future _pushDetailSurface(WidgetTester tester, {required String surfaceName, required bool hasActions}) async { + await tester.pumpWidget( + MaterialApp( + theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _TestDetailScreen(surfaceName: surfaceName, hasActions: hasActions), + ), + ), + child: Text('Open $surfaceName detail'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open $surfaceName detail')); + await tester.pumpAndSettle(); + expect(find.byType(_TestDetailScreen), findsOneWidget); } class _TestDetailScreen extends StatefulWidget { final VoidCallback? onTopTargetTap; + final String surfaceName; + final bool hasActions; - const _TestDetailScreen({this.onTopTargetTap}); + const _TestDetailScreen({this.onTopTargetTap, this.surfaceName = 'detail', this.hasActions = false}); @override State<_TestDetailScreen> createState() => _TestDetailScreenState(); @@ -85,7 +183,9 @@ class _TestDetailScreenState extends State<_TestDetailScreen> bool get hasItems => true; @override - List getAppBarActions() => const []; + List getAppBarActions() => widget.hasActions + ? [FocusableAction(icon: Icons.more_horiz, tooltip: 'Collection actions', onPressed: () {})] + : const []; @override void dispose() { @@ -97,11 +197,27 @@ class _TestDetailScreenState extends State<_TestDetailScreen> Widget build(BuildContext context) { return buildDetailScaffold( slivers: [ + SliverAppBar(title: Text('${widget.surfaceName} detail'), actions: buildFocusableAppBarActions()), SliverToBoxAdapter( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onTopTargetTap, - child: const SizedBox(height: 80, child: Text('Top target')), + child: Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTopTargetTap, + child: const SizedBox(height: 80, child: Text('Top target')), + ), + Builder( + builder: (sheetContext) => TextButton( + onPressed: () async { + await OverlaySheetController.showAdaptive( + sheetContext, + builder: (_) => SizedBox(height: 120, child: Center(child: Text('${widget.surfaceName} sheet'))), + ); + }, + child: Text('Open ${widget.surfaceName} sheet'), + ), + ), + ], ), ), SliverList.builder( diff --git a/test/screens/libraries/alpha_jump_bar_test.dart b/test/screens/libraries/alpha_jump_bar_test.dart index 70700f5e..e52be905 100644 --- a/test/screens/libraries/alpha_jump_bar_test.dart +++ b/test/screens/libraries/alpha_jump_bar_test.dart @@ -1,8 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; +import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/media/library_first_character.dart'; import 'package:plezy/screens/libraries/alpha_jump_bar.dart'; +import 'package:plezy/utils/platform_detector.dart'; + +const _backKeys = [ + _BackKeyCase('Escape', LogicalKeyboardKey.escape, PhysicalKeyboardKey.escape), + _BackKeyCase('GoBack', LogicalKeyboardKey.goBack, PhysicalKeyboardKey.escape), + _BackKeyCase('BrowserBack', LogicalKeyboardKey.browserBack, PhysicalKeyboardKey.browserBack), + _BackKeyCase('GameButtonB', LogicalKeyboardKey.gameButtonB, PhysicalKeyboardKey.enter), +]; void main() { const alphabetCharacters = [ @@ -35,6 +45,91 @@ void main() { LibraryFirstCharacter(key: 'Z', title: 'Z', size: 1), ]; + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + BackKeyUpSuppressor.clearSuppression(); + BackKeyCoordinator.clear(); + }); + + for (final backKey in _backKeys) { + testWidgets('${backKey.label} runs back once on key up and consumes the normal press', (tester) async { + TvDetectionService.debugSetAppleTVOverride(false); + final harness = await _pumpBackHarness(tester); + + final downHandled = _sendKeyDown(tester, backKey); + expect(downHandled, isTrue); + expect(harness.jumpBarBacks, 0); + expect(harness.ancestorEvents, 0); + + final repeatHandled = _sendKeyRepeat(tester, backKey); + expect(repeatHandled, isTrue); + expect(harness.jumpBarBacks, 0); + expect(harness.ancestorEvents, 0); + + final upHandled = _sendKeyUp(tester, backKey); + expect(upHandled, isTrue); + expect(harness.jumpBarBacks, 1); + expect(harness.ancestorEvents, 0); + expect(harness.ancestorBacks, 0); + + await tester.pump(); + harness.moveFocusOnBack = true; + + final movingDownHandled = _sendKeyDown(tester, backKey); + final movingRepeatHandled = _sendKeyRepeat(tester, backKey); + final movingUpHandled = _sendKeyUp(tester, backKey); + + expect(movingDownHandled, isTrue); + expect(movingRepeatHandled, isTrue); + expect(movingUpHandled, isTrue); + expect(harness.jumpBarBacks, 2); + expect(harness.ancestorEvents, 0); + expect(harness.ancestorBacks, 0); + await tester.pump(); + expect(harness.nextFocusNode.hasFocus, isTrue); + }); + + testWidgets('${backKey.label} runs back once on Apple TV key down and consumes key up', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final harness = await _pumpBackHarness(tester); + + final downHandled = _sendKeyDown(tester, backKey); + expect(downHandled, isTrue); + expect(harness.jumpBarBacks, 1); + expect(harness.ancestorEvents, 0); + + final repeatHandled = _sendKeyRepeat(tester, backKey); + expect(repeatHandled, isTrue); + expect(harness.jumpBarBacks, 1); + expect(harness.ancestorEvents, 0); + + final upHandled = _sendKeyUp(tester, backKey); + expect(upHandled, isTrue); + expect(harness.jumpBarBacks, 1); + expect(harness.ancestorEvents, 0); + expect(harness.ancestorBacks, 0); + + await tester.pump(); + harness.moveFocusOnBack = true; + + final movingDownHandled = _sendKeyDown(tester, backKey); + expect(movingDownHandled, isTrue); + expect(harness.jumpBarBacks, 2); + expect(harness.ancestorEvents, 0); + await tester.pump(); + expect(harness.nextFocusNode.hasFocus, isTrue); + + final movingRepeatHandled = _sendKeyRepeat(tester, backKey); + final movingUpHandled = _sendKeyUp(tester, backKey); + + expect(movingRepeatHandled, isTrue); + expect(movingUpHandled, isTrue); + expect(harness.jumpBarBacks, 2); + expect(harness.ancestorEvents, 2); + expect(harness.ancestorBacks, 0); + }); + } + testWidgets('keeps the full alphabet visible in a short TV-height bar', (tester) async { await tester.pumpWidget( MaterialApp( @@ -121,3 +216,114 @@ void main() { expect(jumpedTo, 2); }); } + +class _BackKeyCase { + final String label; + final LogicalKeyboardKey logicalKey; + final PhysicalKeyboardKey physicalKey; + + const _BackKeyCase(this.label, this.logicalKey, this.physicalKey); +} + +class _BackHarness { + final jumpBarFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back'); + final nextFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_destination'); + final ancestorFocusNode = FocusNode(debugLabel: 'test_alpha_jump_bar_back_ancestor'); + + var jumpBarBacks = 0; + var ancestorEvents = 0; + var ancestorBacks = 0; + var moveFocusOnBack = false; + + void dispose() { + jumpBarFocusNode.dispose(); + nextFocusNode.dispose(); + ancestorFocusNode.dispose(); + } +} + +Future<_BackHarness> _pumpBackHarness(WidgetTester tester) async { + final harness = _BackHarness(); + addTearDown(harness.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Focus( + focusNode: harness.ancestorFocusNode, + onKeyEvent: (_, event) { + harness.ancestorEvents++; + return handleBackKeyAction(event, () => harness.ancestorBacks++); + }, + child: Column( + children: [ + SizedBox( + height: 300, + child: AlphaJumpBar( + firstCharacters: const [ + LibraryFirstCharacter(key: 'A', title: 'A', size: 3), + LibraryFirstCharacter(key: 'B', title: 'B', size: 4), + LibraryFirstCharacter(key: 'C', title: 'C', size: 2), + ], + currentLetter: 'B', + focusNode: harness.jumpBarFocusNode, + onJump: (_) {}, + onBack: () { + harness.jumpBarBacks++; + if (harness.moveFocusOnBack) { + harness.nextFocusNode.requestFocus(); + } + }, + ), + ), + Focus(focusNode: harness.nextFocusNode, child: const SizedBox(width: 1, height: 1)), + ], + ), + ), + ), + ), + ); + + harness.jumpBarFocusNode.requestFocus(); + await tester.pump(); + expect(harness.jumpBarFocusNode.hasFocus, isTrue); + return harness; +} + +bool _sendKeyDown(WidgetTester _, _BackKeyCase backKey) { + return _dispatchKeyEvent( + KeyDownEvent(physicalKey: backKey.physicalKey, logicalKey: backKey.logicalKey, timeStamp: Duration.zero), + ); +} + +bool _sendKeyRepeat(WidgetTester _, _BackKeyCase backKey) { + return _dispatchKeyEvent( + KeyRepeatEvent( + physicalKey: backKey.physicalKey, + logicalKey: backKey.logicalKey, + timeStamp: const Duration(milliseconds: 1), + ), + ); +} + +bool _sendKeyUp(WidgetTester _, _BackKeyCase backKey) { + return _dispatchKeyEvent( + KeyUpEvent( + physicalKey: backKey.physicalKey, + logicalKey: backKey.logicalKey, + timeStamp: const Duration(milliseconds: 2), + ), + ); +} + +bool _dispatchKeyEvent(KeyEvent event) { + final handled = HardwareKeyboard.instance.handleKeyEvent(event); + FocusNode? node = FocusManager.instance.primaryFocus; + while (node != null) { + final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + if (result == KeyEventResult.handled) return true; + if (result == KeyEventResult.skipRemainingHandlers) return handled; + node = node.parent; + } + return handled; +} diff --git a/test/screens/libraries/state_messages_test.dart b/test/screens/libraries/state_messages_test.dart new file mode 100644 index 00000000..f706c4fb --- /dev/null +++ b/test/screens/libraries/state_messages_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_button.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/screens/libraries/state_messages.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); + tearDown(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('StateMessageWidget forwards action focus behavior to FocusableButton', (tester) async { + await _pump( + tester, + StateMessageWidget( + message: 'Nothing here', + actionLabel: 'Reload', + onAction: () {}, + actionAutofocus: true, + actionUseBackgroundFocus: true, + ), + ); + + final button = tester.widget(find.byType(FocusableButton)); + expect(button.autofocus, isTrue); + expect(button.useBackgroundFocus, isTrue); + + final focusedNode = FocusManager.instance.primaryFocus; + expect(focusedNode, isNotNull); + final focusedWidget = find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode == focusedNode); + expect(focusedWidget, findsOneWidget); + expect(find.ancestor(of: focusedWidget, matching: find.byType(FocusableButton)), findsOneWidget); + }); + + testWidgets('ErrorStateWidget retry supports D-pad and pointer activation', (tester) async { + var retries = 0; + await _pump( + tester, + ErrorStateWidget( + message: 'Could not load', + retryLabel: 'Try again', + onRetry: () => retries++, + actionAutofocus: true, + actionUseBackgroundFocus: true, + ), + ); + + final button = tester.widget(find.byType(FocusableButton)); + expect(button.autofocus, isTrue); + expect(button.useBackgroundFocus, isTrue); + + await tester.tap(find.widgetWithText(FilledButton, 'Try again')); + await tester.pump(); + expect(retries, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + expect(retries, 2); + }); + + group('ErrorStateWidget localized defaults', () { + setUp(() async { + await LocaleSettings.setLocale(AppLocale.es); + }); + + testWidgets('uses the localized default retry label', (tester) async { + await _pump(tester, ErrorStateWidget(message: 'No se pudo cargar', onRetry: () {})); + + expect(find.text('Reintentar'), findsOneWidget); + expect(find.text('Retry'), findsNothing); + }); + }); +} + +Future _pump(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: MaterialApp(home: Scaffold(body: child)), + ), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/screens/livetv/live_tv_show_schedule_screen_test.dart b/test/screens/livetv/live_tv_show_schedule_screen_test.dart new file mode 100644 index 00000000..b75a8618 --- /dev/null +++ b/test/screens/livetv/live_tv_show_schedule_screen_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/live_tv_support.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/models/livetv_program.dart'; +import 'package:plezy/models/media_subscription.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/livetv/live_tv_show_schedule_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => initializeDateFormatting('en')); + + setUp(() { + LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(true); + }); + + tearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + + testWidgets('record action is D-pad reachable and invokes the recording flow once', (tester) async { + final dvr = _FakeLiveTvDvr(); + final client = _FakeMediaServerClient( + liveTv: _FakeLiveTvSupport( + dvr: dvr, + programs: [_program(guid: 'plex://episode/recordable')], + ), + supportsDvr: true, + ); + final provider = _providerFor(client); + addTearDown(provider.dispose); + + await _pumpScreen(tester, provider); + + expect(find.byType(FocusableActionBar), findsOneWidget); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'FocusableWrapper'); + expect(dvr.subscriptionTemplateCalls, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(dvr.subscriptionTemplateCalls, 1); + expect(dvr.requestedGuids, ['plex://episode/recordable']); + }); + + testWidgets('non-recordable schedules do not add a dead app-bar focus target', (tester) async { + final unsupportedClient = _FakeMediaServerClient( + liveTv: _FakeLiveTvSupport( + dvr: _FakeLiveTvDvr(), + programs: [_program(guid: 'plex://episode/unsupported')], + ), + supportsDvr: false, + ); + final unsupportedProvider = _providerFor(unsupportedClient); + addTearDown(unsupportedProvider.dispose); + + await _pumpScreen(tester, unsupportedProvider); + await _expectNoRecordFocusTarget(tester); + + final missingGuidClient = _FakeMediaServerClient( + liveTv: _FakeLiveTvSupport( + dvr: _FakeLiveTvDvr(), + programs: [ + _program(), + _program(guid: ''), + ], + ), + supportsDvr: true, + ); + final missingGuidProvider = _providerFor(missingGuidClient); + addTearDown(missingGuidProvider.dispose); + + await _pumpScreen(tester, missingGuidProvider); + await _expectNoRecordFocusTarget(tester); + }); +} + +Future _expectNoRecordFocusTarget(WidgetTester tester) async { + expect(find.byType(FocusableActionBar), findsNothing); + final scheduleFocus = FocusManager.instance.primaryFocus; + expect(scheduleFocus?.debugLabel, 'FocusableWrapper'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + expect(FocusManager.instance.primaryFocus, same(scheduleFocus)); +} + +LiveTvProgram _program({String? guid}) => LiveTvProgram( + guid: guid, + title: 'Pilot', + grandparentTitle: 'Example Show', + beginsAt: 1_900_000_000, + endsAt: 1_900_003_600, +); + +MultiServerProvider _providerFor(MediaServerClient client) { + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + return MultiServerProvider(manager, DataAggregationService(manager)); +} + +Future _pumpScreen(WidgetTester tester, MultiServerProvider provider) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: InputModeTracker( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: const LiveTvShowScheduleScreen(showTitle: 'Example Show', serverId: 'server-a', channels: []), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +class _FakeMediaServerClient implements MediaServerClient { + @override + final LiveTvSupport liveTv; + final bool supportsDvr; + + _FakeMediaServerClient({required this.liveTv, required this.supportsDvr}); + + @override + ServerId get serverId => ServerId('server-a'); + + @override + String? get serverName => 'Test server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities(liveTv: true, liveTvDvr: supportsDvr); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLiveTvSupport implements LiveTvSupport { + @override + final LiveTvDvrSupport? dvr; + final List programs; + + _FakeLiveTvSupport({required this.dvr, required this.programs}); + + @override + Future> fetchSchedule({DateTime? from, DateTime? to}) async => programs; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLiveTvDvr implements LiveTvDvrSupport { + int subscriptionTemplateCalls = 0; + final List requestedGuids = []; + + @override + Future> getSubscriptionTemplate(String guid) async { + subscriptionTemplateCalls++; + requestedGuids.add(guid); + return const []; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/screens/playlist/playlist_item_card_test.dart b/test/screens/playlist/playlist_item_card_test.dart new file mode 100644 index 00000000..963e9ec8 --- /dev/null +++ b/test/screens/playlist/playlist_item_card_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/providers/watch_state_store.dart'; +import 'package:plezy/screens/playlist/playlist_item_card.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/watch_state_notifier.dart'; +import 'package:plezy/widgets/media_progress_bar.dart'; +import 'package:plezy/widgets/unwatched_count_badge.dart'; +import 'package:plezy/widgets/watched_indicator.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/media_items.dart'; +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + testWidgets('watched leaf uses the shared compact checkmark and remains tappable', (tester) async { + final store = WatchStateStore(); + addTearDown(store.dispose); + var tapCount = 0; + final item = testMediaItem( + id: 'watched-movie', + kind: MediaKind.movie, + title: 'Watched movie', + serverId: 'server-1', + durationMs: 120000, + viewOffsetMs: 0, + viewCount: 1, + ); + + await _pumpCard(tester, store: store, item: item, onTap: () => tapCount++); + + final indicator = find.byType(WatchedIndicator); + expect(indicator, findsOneWidget); + expect(tester.widget(indicator).size, WatchedIndicatorSize.compact); + expect(find.descendant(of: indicator, matching: find.byIcon(Symbols.check_rounded)), findsOneWidget); + expect(find.byType(MediaProgressBar), findsNothing); + + await tester.tap(find.text('Watched movie')); + await tester.pump(); + expect(tapCount, 1); + }); + + testWidgets('active resume renders exactly one shared progress bar', (tester) async { + final store = WatchStateStore(); + addTearDown(store.dispose); + final item = testMediaItem( + id: 'in-progress-movie', + kind: MediaKind.movie, + title: 'In progress', + serverId: 'server-1', + durationMs: 120000, + viewOffsetMs: 30000, + ); + + await _pumpCard(tester, store: store, item: item); + + expect(find.byType(WatchedIndicator), findsOneWidget); + expect(find.byType(MediaProgressBar), findsOneWidget); + expect(find.byIcon(Symbols.check_rounded), findsNothing); + }); + + testWidgets('zero, terminal, and past-end offsets do not render a progress bar', (tester) async { + final store = WatchStateStore(); + addTearDown(store.dispose); + + for (final offset in [0, 120000, 130000]) { + final item = testMediaItem( + id: 'movie-$offset', + kind: MediaKind.movie, + title: 'Movie $offset', + serverId: 'server-1', + durationMs: 120000, + viewOffsetMs: offset, + ); + + await _pumpCard(tester, store: store, item: item); + + expect(find.byType(MediaProgressBar), findsNothing, reason: 'offset $offset is not active progress'); + } + }); + + testWidgets('show and season containers render their remaining-count badge', (tester) async { + final store = WatchStateStore(); + addTearDown(store.dispose); + final show = testMediaItem( + id: 'show', + kind: MediaKind.show, + title: 'Show', + serverId: 'server-1', + leafCount: 12, + viewedLeafCount: 5, + ); + final season = testMediaItem( + id: 'season', + kind: MediaKind.season, + title: 'Season', + serverId: 'server-1', + leafCount: 8, + viewedLeafCount: 2, + ); + + await _pumpCard(tester, store: store, item: show); + expect(find.byType(UnwatchedCountBadge), findsOneWidget); + expect(find.text('7'), findsOneWidget); + + await _pumpCard(tester, store: store, item: season); + expect(find.byType(UnwatchedCountBadge), findsOneWidget); + expect(find.text('6'), findsOneWidget); + }); + + testWidgets('watch-state patches update the overlay without mutating the source item', (tester) async { + final store = WatchStateStore(); + addTearDown(store.dispose); + final item = testMediaItem( + id: 'patched-episode', + kind: MediaKind.episode, + title: 'Patched episode', + serverId: 'server-1', + durationMs: 120000, + ); + + await _pumpCard(tester, store: store, item: item); + expect(find.byType(MediaProgressBar), findsNothing); + expect(find.byIcon(Symbols.check_rounded), findsNothing); + + WatchStateNotifier().notifyProgress(item: item, viewOffset: 30000, duration: 120000); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MediaProgressBar), findsOneWidget); + expect(find.byIcon(Symbols.check_rounded), findsNothing); + expect(item.viewOffsetMs, isNull); + expect(item.isWatched, isFalse); + + WatchStateNotifier().notifyWatched(item: item); + await tester.pump(); + await tester.pump(); + + expect(find.byType(MediaProgressBar), findsNothing); + expect(find.byIcon(Symbols.check_rounded), findsOneWidget); + expect(item.viewOffsetMs, isNull); + expect(item.isWatched, isFalse); + }); +} + +Future _pumpCard( + WidgetTester tester, { + required WatchStateStore store, + required MediaItem item, + VoidCallback? onTap, +}) async { + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: store, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 500, + child: PlaylistItemCard(item: item, index: 0, onRemove: () {}, onTap: onTap, canReorder: false), + ), + ), + ), + ), + ), + ); + await tester.pump(); +} diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 9f27cf82..94fc1100 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -1,9 +1,11 @@ import 'package:drift/native.dart'; -import 'package:plezy/media/ids.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -14,6 +16,7 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/playlist/playlist_detail_screen.dart'; +import 'package:plezy/screens/playlist/playlist_item_card.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; @@ -23,13 +26,16 @@ import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; - -import '../test_helpers/paged_fakes.dart'; +import 'package:plezy/utils/media_navigation_helper.dart'; import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/media_card.dart'; +import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:provider/provider.dart'; -import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/paged_fakes.dart'; +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -38,6 +44,13 @@ void main() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); LocaleSettings.setLocaleSync(AppLocale.en); + TvDetectionService.debugSetAppleTVOverride(false); + BackKeyCoordinator.clear(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + BackKeyCoordinator.clear(); }); testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async { @@ -165,6 +178,221 @@ void main() { expect(playlistRoute, isNotNull); expect(playlistRoute!.popGestureEnabled, isTrue); }); + + testWidgets('playlist adaptive sheet stays in-tree and system or D-pad Back closes it before the route', ( + tester, + ) async { + TvDetectionService.debugSetAppleTVOverride(true); + final harness = await _createHarness(_mediaItems(2)); + await _pushPlaylistRoute(tester, harness); + + final sheetResult = OverlaySheetController.showAdaptive( + tester.element(find.byType(PlaylistItemCard).first), + builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist sheet'))), + ); + await tester.pumpAndSettle(); + + final sheet = find.text('Playlist sheet'); + expect(sheet, findsOneWidget); + expect(find.ancestor(of: sheet, matching: find.byType(OverlaySheetHost)), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + // A route-level Back can accompany the same TV remote press before the + // coordinator's one-frame marker is cleared. + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(sheet, findsNothing); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + await expectLater(sheetResult, completion(isNull)); + + final systemSheetResult = OverlaySheetController.showAdaptive( + tester.element(find.byType(PlaylistItemCard).first), + builder: (_) => const SizedBox(height: 120, child: Center(child: Text('Playlist system sheet'))), + ); + await tester.pumpAndSettle(); + expect(find.text('Playlist system sheet'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('Playlist system sheet'), findsNothing); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + await expectLater(systemSheetResult, completion(isNull)); + }); + + testWidgets('playlist move mode restores order and cancels before system Back can exit the route', (tester) async { + final harness = await _createHarness(_mediaItems(2)); + await _pushPlaylistRoute(tester, harness); + + final listFocus = find.byWidgetPredicate( + (widget) => widget is Focus && widget.focusNode?.debugLabel == 'playlist_list', + ); + expect(listFocus, findsOneWidget); + tester.widget(listFocus).focusNode!.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(tester.widget(find.byType(PlaylistItemCard).first).isMoving, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(_visiblePlaylistItemIds(tester), ['item_1', 'item_0']); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + expect(_visiblePlaylistItemIds(tester), ['item_0', 'item_1']); + expect(tester.widgetList(find.byType(PlaylistItemCard)).every((card) => !card.isMoving), isTrue); + }); + + testWidgets('successful playlist deletion pops true from the detail route', (tester) async { + final harness = await _createHarness(_mediaItems(1), deleteResult: true); + bool? routeResult; + + await tester.pumpWidget( + harness.wrap( + Builder( + builder: (context) => TextButton( + onPressed: () async { + routeResult = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist))); + }, + child: const Text('Open playlist'), + ), + ), + ), + ); + await tester.tap(find.text('Open playlist')); + await tester.pumpAndSettle(); + + await _choosePlaylistDeletion(tester, confirm: true); + + expect(routeResult, isTrue); + expect(harness.client.deleteCalls, 1); + expect(find.byType(PlaylistDetailScreen), findsNothing); + }); + + testWidgets('media navigation maps a successful playlist deletion to listRefreshNeeded', (tester) async { + final harness = await _createHarness(_mediaItems(1), deleteResult: true); + MediaNavigationResult? navigationResult; + + await tester.pumpWidget( + harness.wrap( + Builder( + builder: (context) => TextButton( + onPressed: () async { + navigationResult = await navigateToMediaItem(context, _playlist); + }, + child: const Text('Navigate to playlist'), + ), + ), + ), + ); + await tester.tap(find.text('Navigate to playlist')); + await tester.pumpAndSettle(); + + await _choosePlaylistDeletion(tester, confirm: true); + + expect(navigationResult, MediaNavigationResult.listRefreshNeeded); + expect(harness.client.deleteCalls, 1); + }); + + testWidgets('playlist card reload callback runs exactly once after successful deletion', (tester) async { + final harness = await _createHarness(_mediaItems(1), deleteResult: true); + var reloads = 0; + await tester.pumpWidget( + harness.wrap( + Scaffold( + body: SizedBox( + width: 640, + child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tester.state(find.byType(MediaCard)).handleTap(); + await tester.pumpAndSettle(); + await _choosePlaylistDeletion(tester, confirm: true); + + expect(reloads, 1); + expect(harness.client.deleteCalls, 1); + await tester.pump(); + expect(reloads, 1); + }); + + testWidgets('cancelled and failed deletion plus a non-delete pop never reload the playlist card', (tester) async { + final harness = await _createHarness(_mediaItems(1), deleteResult: false); + var reloads = 0; + await tester.pumpWidget( + harness.wrap( + Scaffold( + body: SizedBox( + width: 640, + child: MediaCard(item: _playlist, forceListMode: true, onListRefresh: () => reloads++), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + tester.state(find.byType(MediaCard)).handleTap(); + await tester.pumpAndSettle(); + + await _choosePlaylistDeletion(tester, confirm: false); + expect(reloads, 0); + expect(harness.client.deleteCalls, 0); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + + await _choosePlaylistDeletion(tester, confirm: true); + expect(reloads, 0); + expect(harness.client.deleteCalls, 1); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); + + Navigator.of(tester.element(find.byType(PlaylistDetailScreen))).pop(); + await tester.pumpAndSettle(); + + expect(find.byType(PlaylistDetailScreen), findsNothing); + expect(find.byType(MediaCard), findsOneWidget); + expect(reloads, 0); + }); +} + +Future _pushPlaylistRoute(WidgetTester tester, _PlaylistHarness harness) async { + await tester.pumpWidget( + harness.wrap( + Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const PlaylistDetailScreen(playlist: _playlist))), + child: const Text('Open playlist'), + ), + ), + ), + ); + await tester.tap(find.text('Open playlist')); + await tester.pumpAndSettle(); + expect(find.byType(PlaylistDetailScreen), findsOneWidget); +} + +Future _choosePlaylistDeletion(WidgetTester tester, {required bool confirm}) async { + await tester.tap(find.byTooltip(t.playlists.delete)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + + await tester.tap(find.text(confirm ? t.common.delete : t.common.cancel)); + await tester.pumpAndSettle(); +} + +List _visiblePlaylistItemIds(WidgetTester tester) { + return tester.widgetList(find.byType(PlaylistItemCard)).map((card) => card.item.id).toList(); } const _playlist = MediaPlaylist( @@ -190,7 +418,7 @@ List _mediaItems(int count) { ); } -Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt}) async { +Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt, bool deleteResult = false}) async { await SettingsService.getInstance(); final db = AppDatabase.forTesting(NativeDatabase.memory()); @@ -206,7 +434,7 @@ Future<_PlaylistHarness> _createHarness(List items, {int? failOnceAt} final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); await downloadProvider.ensureInitialized(); - final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt); + final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); @@ -246,11 +474,13 @@ class _PlaylistHarness { class _PagedPlaylistClient implements MediaServerClient { final List items; final int? failOnceAt; + final bool deleteResult; final List requestedStarts = []; final List requestedSizes = []; + int deleteCalls = 0; bool _hasFailed = false; - _PagedPlaylistClient(this.items, {this.failOnceAt}); + _PagedPlaylistClient(this.items, {this.failOnceAt, this.deleteResult = false}); @override ServerId get serverId => ServerId('server_1'); @@ -277,6 +507,12 @@ class _PagedPlaylistClient implements MediaServerClient { return fakeLibraryPage(items, start: start, size: size); } + @override + Future deletePlaylist(MediaPlaylist playlist) async { + deleteCalls++; + return deleteResult; + } + @override void close() {} diff --git a/test/screens/profile/add_local_profile_screen_test.dart b/test/screens/profile/add_local_profile_screen_test.dart index 3eabbb2d..00f39bd9 100644 --- a/test/screens/profile/add_local_profile_screen_test.dart +++ b/test/screens/profile/add_local_profile_screen_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/screens/profile/add_local_profile_screen.dart'; @@ -69,7 +70,7 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); - await tester.tap(find.byIcon(Icons.check_rounded)); + await tester.tap(find.byIcon(Symbols.check_rounded)); await tester.pumpAndSettle(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin'); diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 3e67c6fc..5fe0ebd0 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -1,7 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/focus/focusable_text_field.dart'; +import 'package:plezy/focus/key_event_utils.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; @@ -17,6 +22,8 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/focusable_media_card.dart'; +import 'package:plezy/widgets/loading_indicator_box.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; @@ -30,18 +37,23 @@ void main() { }); setUp(() async { + _resetGlobalTestState(); resetSharedPreferencesForTest(); - SettingsService.resetForTesting(); await SettingsService.getInstance(); }); - tearDown(() { - TvDetectionService.debugSetAppleTVOverride(null); - TvDetectionService.setForceTVSync(false); - }); + tearDown(_resetGlobalTestState); testWidgets('stale callbacks are no-ops after SearchScreen is disposed', (tester) async { final key = GlobalKey>(); + final item = testMediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: 'server_1', + serverName: 'Server', + ); await tester.pumpWidget( TranslationProvider( @@ -59,7 +71,7 @@ void main() { expect(tester.takeException(), isNull); expect(() => (state as Refreshable).refresh(), returnsNormally); - expect(() => (state as dynamic).updateItem('movie_1'), returnsNormally); + expect(() => (state as dynamic).updateItem(item), returnsNormally); expect(() => (state as FullRefreshable).fullRefresh(), returnsNormally); expect(() => searchInput.submitSearchQuery('new movie'), returnsNormally); expect(() => (state as FocusableTab).focusActiveTabIfReady(), returnsNormally); @@ -67,17 +79,15 @@ void main() { }); testWidgets('TV OSK search key moves focus to the first result', (tester) async { - final (client, key) = await _pumpTvSearchScreen(tester); + final (client, _) = await _pumpTvSearchScreen(tester); await tester.pumpAndSettle(); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); - final state = key.currentState!; _searchController(tester).text = 'movie'; - // rate_limiter's Debounce compares DateTime.now() against the fake-clock - // timer, so it never invokes under FakeAsync — run the search via - // refresh() (same _performSearch path) to get results behind the dialog. - (state as Refreshable).refresh(); - await tester.pumpAndSettle(); + // Let the normal debounce populate results while the OSK remains open. + // DebouncedMediaSearch now uses a fake-clock-aware Timer. + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); expect(client.queries, ['movie']); expect(find.text('Movie 1'), findsOneWidget); @@ -87,9 +97,7 @@ void main() { expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(find.text('Movie 1'), findsOneWidget); - - // Dispose the screen so its still-armed debounce timer is cancelled. - await tester.pumpWidget(const SizedBox.shrink()); + expect(client.queries, ['movie']); }); testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async { @@ -194,6 +202,86 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + + testWidgets('card refresh stays server-qualified without restarting search', (tester) async { + final serverOneItem = testMediaItem( + id: 'shared-id', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Shared', + serverId: 'server_1', + serverName: 'Server One', + ); + final serverTwoItem = testMediaItem( + id: 'shared-id', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Shared Alternate', + serverId: 'server_2', + serverName: 'Server Two', + ); + final serverTwoClient = _FakeMediaServerClient( + serverIdValue: 'server_2', + serverNameValue: 'Server Two', + items: [serverTwoItem], + ); + final (serverOneClient, key) = await _pumpTvSearchScreen( + tester, + items: [serverOneItem], + additionalClients: [serverTwoClient], + ); + await tester.pumpAndSettle(); + + (key.currentState! as SearchInputFocusable).submitSearchQuery('shared'); + await tester.pumpAndSettle(); + + expect(serverOneClient.queries, ['shared']); + expect(serverTwoClient.queries, ['shared']); + expect(find.byType(FocusableMediaCard), findsNWidgets(2)); + + // The exact-title match is the first, focused card. Keep the fetch open + // to observe the screen while the item-only refresh is in flight. + final sourceFinder = find.byKey(Key(serverOneItem.globalKey)); + final untouchedFinder = find.byKey(Key(serverTwoItem.globalKey)); + final sourceCardState = tester.state(sourceFinder); + final untouchedCardState = tester.state(untouchedFinder); + final focusedNode = FocusManager.instance.primaryFocus; + expect(focusedNode?.debugLabel, 'SearchFirstResult'); + + final fetchGate = Completer(); + final updated = serverOneItem.copyWith(title: 'Refreshed on Server One'); + serverOneClient + ..itemResult = updated + ..fetchGate = fetchGate; + + tester.widget(sourceFinder).onRefresh!(serverOneItem); + await tester.pump(); + + // This used to fan the bare id out to every server and drive the whole + // screen through another search/loading pass. + expect(serverOneClient.fetchedItemIds, ['shared-id']); + expect(serverTwoClient.fetchedItemIds, isEmpty); + expect(serverOneClient.queries, ['shared']); + expect(serverTwoClient.queries, ['shared']); + expect(find.byWidget(LoadingIndicatorBox.sliver), findsNothing); + expect(find.byType(FocusableMediaCard), findsNWidgets(2)); + expect(tester.state(sourceFinder), same(sourceCardState)); + expect(tester.state(untouchedFinder), same(untouchedCardState)); + expect(FocusManager.instance.primaryFocus, same(focusedNode)); + + fetchGate.complete(); + await tester.pumpAndSettle(); + + expect(tester.widget(sourceFinder).item, same(updated)); + expect(tester.widget(untouchedFinder).item, same(serverTwoItem)); + expect(tester.state(sourceFinder), same(sourceCardState)); + expect(tester.state(untouchedFinder), same(untouchedCardState)); + expect(FocusManager.instance.primaryFocus, same(focusedNode)); + expect(serverOneClient.queries, ['shared']); + expect(serverTwoClient.queries, ['shared']); + + await tester.pumpWidget(const SizedBox.shrink()); + }); } Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchScreen( @@ -202,6 +290,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc // When false, no server is registered, so performSearchQuery throws — the // path a companion-remote submit hits when the search fails outright. bool registerClient = true, + List<_FakeMediaServerClient> additionalClients = const [], }) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); @@ -229,6 +318,9 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc ); final manager = MultiServerManager(); if (registerClient) manager.debugRegisterClientForTesting(client); + for (final additionalClient in additionalClients) { + manager.debugRegisterClientForTesting(additionalClient); + } final provider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(provider.dispose); @@ -244,13 +336,19 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc ), ), ); + addTearDown(() async { + // Dispose the search state (including its debounce, focus nodes, and any + // hosted OSK route) before resetting process-wide focus/keyboard state. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); return (client, key); } Finder _keyboardDoneKey() { return find.descendant( of: find.byKey(const Key('tv_virtual_keyboard_panel')), - matching: find.byIcon(Icons.search_rounded), + matching: find.byIcon(Symbols.search_rounded), ); } @@ -258,17 +356,33 @@ TextEditingController _searchController(WidgetTester tester) { return tester.widget(find.byType(FocusableTextField)).controller; } +void _resetGlobalTestState() { + FocusManager.instance.primaryFocus?.unfocus(); + HardwareKeyboard.instance.clearState(); + SelectKeyUpSuppressor.clearSuppression(); + BackKeyUpSuppressor.clearSuppression(); + BackKeyCoordinator.clear(); + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); + SettingsService.resetForTesting(); +} + class _FakeMediaServerClient implements MediaServerClient { + final String serverIdValue; + final String serverNameValue; final List items; final List queries = []; + final List fetchedItemIds = []; + MediaItem? itemResult; + Completer? fetchGate; - _FakeMediaServerClient({required this.items}); + _FakeMediaServerClient({required this.items, this.serverIdValue = 'server_1', this.serverNameValue = 'Server'}); @override - ServerId get serverId => ServerId('server_1'); + ServerId get serverId => ServerId(serverIdValue); @override - String? get serverName => 'Server'; + String? get serverName => serverNameValue; @override MediaBackend get backend => MediaBackend.plex; @@ -282,6 +396,14 @@ class _FakeMediaServerClient implements MediaServerClient { return items; } + @override + Future fetchItem(String id, {bool useCache = true}) async { + fetchedItemIds.add(id); + final gate = fetchGate; + if (gate != null) await gate.future; + return itemResult; + } + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart new file mode 100644 index 00000000..00f94ab1 --- /dev/null +++ b/test/screens/settings/settings_screen_test.dart @@ -0,0 +1,325 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/libraries_provider.dart'; +import 'package:plezy/providers/seerr_account_provider.dart'; +import 'package:plezy/providers/theme_provider.dart'; +import 'package:plezy/providers/trackers_provider.dart'; +import 'package:plezy/providers/trakt_account_provider.dart'; +import 'package:plezy/screens/settings/settings_screen.dart'; +import 'package:plezy/services/donation_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/services/update_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:plezy/widgets/focusable_list_tile.dart'; +import 'package:plezy/widgets/loading_indicator_box.dart'; +import 'package:plezy/widgets/setting_tile.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/io_fakes.dart'; +import '../../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PathProviderPlatform originalPathProvider; + late Directory temporaryDirectory; + + setUpAll(() { + originalPathProvider = PathProviderPlatform.instance; + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + DownloadStorageService.resetForTesting(); + temporaryDirectory = await Directory.systemTemp.createTemp('plezy_settings_screen_test_'); + PathProviderPlatform.instance = FakePathProvider(temporaryDirectory); + TvDetectionService.debugSetAppleTVOverride(false); + PlatformDetector.debugSetIsDesktopOSOverride(false); + await SettingsService.getInstance(); + }); + + tearDown(() async { + TvDetectionService.debugSetAppleTVOverride(null); + PlatformDetector.debugSetIsDesktopOSOverride(null); + DownloadStorageService.resetForTesting(); + SettingsService.resetForTesting(); + PathProviderPlatform.instance = originalPathProvider; + if (temporaryDirectory.existsSync()) { + await temporaryDirectory.delete(recursive: true); + } + }); + + testWidgets('migrated rows use the standard navigation geometry without losing activation', (tester) async { + final harness = await _pumpSettingsScreen(tester); + addTearDown(() => harness.dispose(tester)); + + final rows = <_MigratedRow>[ + _MigratedRow( + title: t.settings.supportDeveloper, + focusLabel: 'settings_donate', + isVisible: DonationService.isEnabled, + ), + _MigratedRow(title: t.settings.watchTogetherRelay, focusLabel: 'settings_watch_together_relay'), + _MigratedRow(title: t.settings.clearCache, focusLabel: 'settings_clear_cache'), + _MigratedRow(title: t.settings.resetSettings, focusLabel: 'settings_reset_settings'), + const _MigratedRow(title: 'Test Sentry', isVisible: kDebugMode), + const _MigratedRow(title: 'Test ANR', isVisible: kDebugMode), + _MigratedRow(title: t.settings.exportSettings, focusLabel: 'settings_export_settings'), + _MigratedRow(title: t.settings.importSettings, focusLabel: 'settings_import_settings'), + _MigratedRow( + title: t.settings.checkForUpdates, + focusLabel: 'settings_check_for_updates', + isVisible: UpdateService.isUpdateCheckEnabled && UpdateService.useNativeUpdater, + hasSubtitle: false, + ), + ]; + + final referenceHeight = tester.getSize(_focusableTileFor(t.settings.viewLogs)).height; + + for (final row in rows) { + final navigationTile = _navigationTileFor(row.title); + if (!row.isVisible) { + expect(navigationTile, findsNothing, reason: '${row.title} must honor its production gate'); + continue; + } + + expect(navigationTile, findsOneWidget, reason: '${row.title} must use SettingNavigationTile'); + final focusableFinder = _focusableTileWithin(navigationTile); + final focusable = tester.widget(focusableFinder); + final materialTile = tester.widget( + find.descendant(of: focusableFinder, matching: find.byType(ListTile)), + ); + + expect(focusable.dense, isFalse, reason: '${row.title} must not inherit the compact tile default'); + expect(focusable.visualDensity, VisualDensity.standard); + expect(materialTile.dense, isFalse); + expect(materialTile.visualDensity, VisualDensity.standard); + expect(focusable.onTap, isNotNull, reason: '${row.title} must remain pointer activatable'); + expect(materialTile.onTap, isNotNull); + expect(materialTile.focusNode, isNotNull, reason: '${row.title} must remain D-pad focusable'); + expect(materialTile.focusNode!.canRequestFocus, isTrue); + final pointerRegions = tester.widgetList( + find.descendant(of: focusableFinder, matching: find.byType(MouseRegion)), + ); + expect(pointerRegions.any((region) => region.cursor == SystemMouseCursors.click), isTrue); + + if (row.focusLabel != null) { + expect(focusable.focusNode?.debugLabel, row.focusLabel); + expect(materialTile.focusNode, same(focusable.focusNode)); + } else { + expect(focusable.focusNode, isNull, reason: '${row.title} intentionally uses the tile-owned focus node'); + } + + if (row.hasSubtitle) { + // Subtitle rows occupy the same vertical space as an existing standard + // SettingNavigationTile in this screen. + expect(tester.getSize(focusableFinder).height, referenceHeight); + } + } + + // Exercise both input paths against real callbacks rather than merely + // checking that callbacks are non-null. + final relayMaterialTile = tester.widget( + find.descendant(of: _focusableTileFor(t.settings.watchTogetherRelay), matching: find.byType(ListTile)), + ); + relayMaterialTile.focusNode!.requestFocus(); + await tester.pump(); + expect(relayMaterialTile.focusNode!.hasFocus, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.settings.watchTogetherRelay), findsWidgets); + Navigator.of(tester.element(find.byType(AlertDialog))).pop(); + await tester.pumpAndSettle(); + + await tester.tap(find.text(t.settings.clearCache)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.settings.clearCache), findsWidgets); + }); + + testWidgets('special download and generic update rows retain rich content at standard density', (tester) async { + final harness = await _pumpSettingsScreen(tester); + addTearDown(() => harness.dispose(tester)); + + if (Platform.isIOS) { + expect(find.text(t.settings.downloadLocationDefault), findsNothing); + } else { + final downloadTileFinder = _focusableTileFor(t.settings.downloadLocationDefault); + final materialDownloadTile = tester.widget( + find.descendant(of: downloadTileFinder, matching: find.byType(ListTile)), + ); + final downloadTile = tester.widget(downloadTileFinder); + final subtitle = downloadTile.subtitle! as Text; + + expect(_navigationTileFor(t.settings.downloadLocationDefault), findsNothing); + expect(materialDownloadTile.dense, isFalse); + expect(materialDownloadTile.visualDensity, VisualDensity.standard); + expect(downloadTile.dense, isFalse); + expect(downloadTile.visualDensity, VisualDensity.standard); + expect(downloadTile.leading, isA()); + expect(downloadTile.trailing, isA()); + expect(subtitle.maxLines, 2); + expect(subtitle.overflow, TextOverflow.ellipsis); + expect(downloadTile.onTap, isNotNull); + + await tester.tap(find.text(t.settings.downloadLocationDefault)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(t.settings.downloadLocationDescription), findsOneWidget); + Navigator.of(tester.element(find.byType(AlertDialog))).pop(); + await tester.pumpAndSettle(); + } + + if (!UpdateService.isUpdateCheckEnabled) { + expect(find.text(t.settings.checkForUpdates), findsNothing); + return; + } + + if (UpdateService.useNativeUpdater) { + expect(_navigationTileFor(t.settings.checkForUpdates), findsOneWidget); + return; + } + + final updateTileFinder = _focusableTileFor(t.settings.checkForUpdates); + final materialUpdateTile = tester.widget( + find.descendant(of: updateTileFinder, matching: find.byType(ListTile)), + ); + final updateTile = tester.widget(updateTileFinder); + + expect(_navigationTileFor(t.settings.checkForUpdates), findsNothing); + expect(updateTile.dense, isFalse); + expect(materialUpdateTile.dense, isFalse); + expect(materialUpdateTile.visualDensity, VisualDensity.standard); + expect(updateTile.visualDensity, VisualDensity.standard); + expect(updateTile.trailing, isA()); + expect(updateTile.onTap, isNotNull); + expect(find.descendant(of: updateTileFinder, matching: find.byType(LoadingIndicatorBox)), findsNothing); + + // The generic updater deliberately remains a FocusableListTile: unlike a + // SettingNavigationTile, its trailing slot can be replaced by the progress + // indicator and its callback disabled while a request is in flight. + expect(materialUpdateTile.focusNode, isNotNull); + }); +} + +Finder _navigationTileFor(String title) => + find.ancestor(of: find.text(title), matching: find.byType(SettingNavigationTile)); + +Finder _focusableTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(FocusableListTile)); + +Finder _focusableTileWithin(Finder navigationTile) => + find.descendant(of: navigationTile, matching: find.byType(FocusableListTile)); + +class _MigratedRow { + const _MigratedRow({required this.title, this.focusLabel, this.isVisible = true, this.hasSubtitle = true}); + + final String title; + final String? focusLabel; + final bool isVisible; + final bool hasSubtitle; +} + +class _SettingsHarness { + _SettingsHarness({ + required this.database, + required this.plexHome, + required this.activeProfile, + required this.libraries, + required this.theme, + required this.trakt, + required this.trackers, + required this.seerr, + }); + + final AppDatabase database; + final PlexHomeService plexHome; + final ActiveProfileProvider activeProfile; + final LibrariesProvider libraries; + final ThemeProvider theme; + final TraktAccountProvider trakt; + final TrackersProvider trackers; + final SeerrAccountProvider seerr; + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + libraries.dispose(); + theme.dispose(); + trakt.dispose(); + trackers.dispose(); + seerr.dispose(); + activeProfile.dispose(); + await plexHome.dispose(); + await database.close(); + } +} + +Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async { + tester.view.physicalSize = const Size(1800, 3200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final database = AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final profiles = ProfileRegistry(database); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections); + final libraries = LibrariesProvider(); + final theme = ThemeProvider(); + final trakt = TraktAccountProvider(); + final trackers = TrackersProvider(); + final seerr = SeerrAccountProvider(); + final harness = _SettingsHarness( + database: database, + plexHome: plexHome, + activeProfile: activeProfile, + libraries: libraries, + theme: theme, + trakt: trakt, + trackers: trackers, + seerr: seerr, + ); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: activeProfile), + ChangeNotifierProvider.value(value: libraries), + ChangeNotifierProvider.value(value: theme), + ChangeNotifierProvider.value(value: trakt), + ChangeNotifierProvider.value(value: trackers), + ChangeNotifierProvider.value(value: seerr), + ], + child: MaterialApp(theme: monoTheme(dark: true), home: const SettingsScreen()), + ), + ), + ); + await tester.pumpAndSettle(); + return harness; +} diff --git a/test/services/jellyfin_auth_service_test.dart b/test/services/jellyfin_auth_service_test.dart index 90015adb..b9450e22 100644 --- a/test/services/jellyfin_auth_service_test.dart +++ b/test/services/jellyfin_auth_service_test.dart @@ -90,6 +90,25 @@ void main() { await expectLater(svc.probe('https://jf.example.com'), throwsA(isA())); }); + test('applies the shared jellyfinProbe timeout to the injected auth client', () { + fakeAsync((async) { + final response = Completer(); + final svc = _service(handler: (_) => response.future); + Object? probeError; + + unawaited(_captureError(svc.probe('https://jf.example.com')).then((error) => probeError = error)); + async.flushMicrotasks(); + + async.elapse(MediaServerTimeouts.jellyfinProbe - const Duration(milliseconds: 1)); + async.flushMicrotasks(); + expect(probeError, isNull); + + async.elapse(const Duration(milliseconds: 2)); + async.flushMicrotasks(); + expect(probeError, isA()); + }); + }); + test('registers base URL redaction before the first probe request', () async { final svc = _service( handler: (req) { diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 276dc632..ceaeefd8 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -229,6 +229,26 @@ void main() { expect(info.defaultSubtitleStreamIndex, 4); }); + test('coerces Jellyfin string and numeric stream scalars', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-flexible', + 'DefaultAudioStreamIndex': '2', + 'DefaultSubtitleStreamIndex': 3.0, + 'MediaStreams': [ + {'Index': '2', 'Type': 'Audio', 'Channels': '6'}, + {'Index': 3.0, 'Type': 'Subtitle'}, + ], + }); + + expect(info.defaultAudioStreamIndex, 2); + expect(info.defaultSubtitleStreamIndex, 3); + expect(info.audioTracks.single.id, 2); + expect(info.audioTracks.single.channels, 6); + expect(info.audioTracks.single.selected, isTrue); + expect(info.subtitleTracks.single.id, 3); + expect(info.subtitleTracks.single.selected, isTrue); + }); + test('falls back to Language when DisplayLanguage absent', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ @@ -506,6 +526,30 @@ void main() { expect(versions[1].videoResolution, '1080'); }); + test('coerces source scalars and rounds bps to kbps without zero sentinels', () { + final versions = jellyfinSourcesToVersions([ + { + 'Id': 'rounded', + 'Width': '1920', + 'Height': 1080.9, + 'Bitrate': '1500', + 'Size': '987654321', + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'h264'}, + ], + }, + {'Id': 'missing-bitrate'}, + {'Id': 'zero-bitrate', 'Bitrate': 0}, + {'Id': 'negative-bitrate', 'Bitrate': -1000}, + ]); + + expect(versions.first.width, 1920); + expect(versions.first.height, 1080); + expect(versions.first.bitrate, 2); + expect(versions.first.parts.single.sizeBytes, 987654321); + expect(versions.skip(1).map((version) => version.bitrate), everyElement(isNull)); + }); + test('handles missing MediaStreams + missing Height gracefully', () { final versions = jellyfinSourcesToVersions([ {'Id': 'x', 'Name': 'X', 'Container': 'mkv'}, diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index b2f894b6..c75eac6b 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -142,4 +142,151 @@ void main() { expect(captured?.url.queryParameters, containsPair('genre[].tag.tag-', 'Science%20Fiction')); expect(captured?.url.queryParameters, containsPair('genre.locked', '1')); }); + + test('cached child fetch rejects decodable HTTP error responses before caching', () async { + for (final statusCode in [404, 500]) { + final parentId = 'parent-$statusCode'; + final endpoint = '/library/metadata/$parentId/children'; + var requestCount = 0; + final client = makeClient((request) async { + requestCount++; + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Parse'}, + ], + }, + }), + statusCode, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + await expectLater( + client.fetchChildren(parentId), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', statusCode) + .having((error) => error.requestUri?.path, 'request path', endpoint), + ), + ); + + expect(requestCount, 1); + expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), isNull); + } + }); + + test('HTTP failure falls back to the existing child cache without replacing it', () async { + const parentId = 'cached-parent'; + const endpoint = '/library/metadata/$parentId/children'; + final cachedResponse = { + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': 'cached-child', 'type': 'season', 'title': 'Cached Season'}, + ], + }, + }; + await PlexApiCache.instance.put(ServerId('server-id'), endpoint, cachedResponse); + var requestCount = 0; + final client = makeClient((request) async { + requestCount++; + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Replace Cache'}, + ], + }, + }), + 500, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final children = await client.fetchChildren(parentId); + + expect(requestCount, 1); + expect(children.map((child) => child.id), ['cached-child']); + expect(children.single.title, 'Cached Season'); + expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), cachedResponse); + }); + + test('successful child fetch parses and caches the response', () async { + const parentId = 'fresh-parent'; + const endpoint = '/library/metadata/$parentId/children'; + final responseData = { + 'MediaContainer': { + 'size': 1, + 'totalSize': 1, + 'Metadata': [ + {'ratingKey': 'fresh-child', 'type': 'season', 'title': 'Fresh Season'}, + ], + }, + }; + var requestCount = 0; + final client = makeClient((request) async { + requestCount++; + return http.Response(jsonEncode(responseData), 200, headers: {'content-type': 'application/json'}); + }); + addTearDown(client.close); + + final children = await client.fetchChildren(parentId); + + expect(requestCount, 1); + expect(children.map((child) => child.id), ['fresh-child']); + expect(children.single.title, 'Fresh Season'); + expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), responseData); + }); + + test('child retrieval walks every page and caches the combined result', () async { + const parentId = 'paged-parent'; + const endpoint = '/library/metadata/$parentId/children'; + final requests = []; + final client = makeClient((request) async { + requests.add(request.url); + final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!); + final metadata = start == 0 + ? [ + {'ratingKey': 'season-1', 'type': 'season', 'title': 'Season 1'}, + {'ratingKey': 'season-2', 'type': 'season', 'title': 'Season 2'}, + ] + : [ + {'ratingKey': 'season-3', 'type': 'season', 'title': 'Season 3'}, + ]; + return http.Response( + jsonEncode({ + 'MediaContainer': {'size': metadata.length, 'totalSize': 3, 'Metadata': metadata}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final children = await client.fetchChildren(parentId); + final cached = await PlexApiCache.instance.get(ServerId('server-id'), endpoint); + final cachedContainer = cached!['MediaContainer'] as Map; + final cachedMetadata = cachedContainer['Metadata'] as List; + + expect(children.map((child) => child.id), ['season-1', 'season-2', 'season-3']); + expect(requests.map((uri) => uri.queryParameters['X-Plex-Container-Start']), ['0', '2']); + expect(requests.every((uri) => uri.queryParameters['X-Plex-Container-Size'] == '200'), isTrue); + expect(requests.every((uri) => uri.queryParameters['includeStreams'] == '1'), isTrue); + expect(cachedContainer['size'], 3); + expect(cachedContainer['totalSize'], 3); + expect(cachedMetadata.map((item) => (item as Map)['ratingKey']), [ + 'season-1', + 'season-2', + 'season-3', + ]); + }); } diff --git a/test/utils/error_message_utils_test.dart b/test/utils/error_message_utils_test.dart new file mode 100644 index 00000000..9ecab19d --- /dev/null +++ b/test/utils/error_message_utils_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/utils/app_logger.dart' as logging; +import 'package:plezy/utils/error_message_utils.dart'; + +void main() { + late Logger originalLogger; + late _RecordingLogOutput logOutput; + + setUpAll(() async => LocaleSettings.setLocale(AppLocale.es)); + tearDownAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + setUp(() { + originalLogger = logging.appLogger; + logOutput = _RecordingLogOutput(); + logging.appLogger = Logger(printer: SimplePrinter(), output: logOutput); + }); + + tearDown(() { + logging.appLogger = originalLogger; + }); + + group('localizedLoadErrorMessage', () { + test('distinguishes connection timeouts from connection failures', () { + for (final type in [MediaServerHttpErrorType.connectionTimeout, MediaServerHttpErrorType.receiveTimeout]) { + final message = localizedLoadErrorMessage( + MediaServerHttpException(type: type, message: 'private timeout detail'), + StackTrace.current, + context: 'Biblioteca', + ); + + expect(message, 'Tiempo de conexión agotado al cargar Biblioteca'); + expect(message, isNot(contains('private timeout detail'))); + } + + final connectionMessage = localizedLoadErrorMessage( + MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'private connection detail'), + StackTrace.current, + context: 'Biblioteca', + ); + + expect(connectionMessage, 'No se puede conectar al servidor multimedia'); + expect(connectionMessage, isNot(contains('private connection detail'))); + }); + + test('logs an unexpected failure once and returns only the localized fallback', () { + const privateDetail = 'token=super-secret'; + + final message = localizedLoadErrorMessage(StateError(privateDetail), StackTrace.current, context: 'Biblioteca'); + + expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.'); + expect(message, isNot(contains(privateDetail))); + expect(logOutput.writeCount, 1); + }); + + test('never exposes server status text, response data, or exception messages', () { + const serverText = 'database failed for user@example.com'; + final error = MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: 500, + message: serverText, + responseData: {'error': serverText}, + ); + + final message = localizedLoadErrorMessage(error, StackTrace.current, context: 'Biblioteca'); + + expect(message, 'No se pudo cargar Biblioteca. Inténtalo de nuevo.'); + expect(message, isNot(contains('500'))); + expect(message, isNot(contains(serverText))); + }); + }); +} + +class _RecordingLogOutput extends LogOutput { + int writeCount = 0; + + @override + void output(OutputEvent event) { + writeCount++; + } +} diff --git a/test/utils/media_image_helper_test.dart b/test/utils/media_image_helper_test.dart index 85e431cc..4af84b23 100644 --- a/test/utils/media_image_helper_test.dart +++ b/test/utils/media_image_helper_test.dart @@ -1,9 +1,15 @@ +import 'package:cached_network_image_ce/cached_network_image.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/services/device_performance.dart'; +import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/media_image_helper.dart'; +import '../test_helpers/media_items.dart'; + /// Only [thumbnailUrl] is exercised; everything else throws via noSuchMethod. class _SizedUrlFakeClient implements MediaServerClient { @override @@ -14,6 +20,8 @@ class _SizedUrlFakeClient implements MediaServerClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +MediaItem _item(MediaKind kind) => testMediaItem(kind: kind); + void main() { group('MediaImageHelper.getOptimizedImageUrl', () { test('adds size hints to absolute Jellyfin artwork URLs', () { @@ -134,6 +142,78 @@ void main() { }); }); + group('MediaImageHelper image type budgets', () { + tearDown(DevicePerformance.debugReset); + + test('hero logos get a larger budget without changing ordinary logos', () { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.logo), + (600, 300), + ); + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo), + (1000, 500), + ); + }); + + test('card artwork follows square, wide, and poster media shapes', () { + for (final kind in [MediaKind.artist, MediaKind.album, MediaKind.track]) { + expect( + MediaImageHelper.cardImageType(_item(kind), EpisodePosterMode.episodeThumbnail), + ImageType.square, + reason: '${kind.id} artwork must keep its square music cache budget', + ); + } + + expect( + MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.episodeThumbnail), + ImageType.thumb, + ); + expect( + MediaImageHelper.cardImageType(_item(MediaKind.episode), EpisodePosterMode.seriesPoster), + ImageType.poster, + ); + expect( + MediaImageHelper.cardImageType(_item(MediaKind.movie), EpisodePosterMode.episodeThumbnail), + ImageType.poster, + ); + }); + }); + + group('MediaImageHelper.serverArtworkProvider', () { + test('reuses cache identity and disk key while bounding both decode axes', () { + const url = 'https://example.invalid/library/poster.jpg'; + + final first = MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage; + final second = + MediaImageHelper.serverArtworkProvider(imageUrl: url, memWidth: 640, memHeight: 360) as ResizeImage; + final firstCached = first.imageProvider as CachedNetworkImageProvider; + final secondCached = second.imageProvider as CachedNetworkImageProvider; + + expect(firstCached, secondCached); + expect(first.width, second.width); + expect(first.width, 640); + expect(first.height, second.height); + expect(first.height, 360); + expect(first.policy, second.policy); + expect(first.policy, ResizeImagePolicy.fit); + expect(first.allowUpscaling, second.allowUpscaling); + expect(first.allowUpscaling, isFalse); + expect(firstCached.url, secondCached.url); + expect(firstCached.url, url); + expect(firstCached.headers, secondCached.headers); + expect(firstCached.headers, const {'User-Agent': 'Plezy'}); + expect(firstCached.cacheKey, secondCached.cacheKey); + expect(firstCached.cacheKey, isNotNull); + expect(firstCached.maxWidth, secondCached.maxWidth); + expect(firstCached.maxWidth, isNull); + expect(firstCached.maxHeight, secondCached.maxHeight); + expect(firstCached.maxHeight, isNull); + }); + }); + group('MediaImageHelper.boundedDecode', () { test('bounds both axes with fit policy (no distortion, no upscale)', () { const base = NetworkImage('https://example/img'); diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index b42dd9bb..c709e8fe 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/focus/focusable_text_field.dart'; import 'package:plezy/services/gamepad_service.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -713,7 +714,7 @@ void main() { rebuild(() => onNavigateDown = () => navigateDownCalls++); await tester.pump(); - await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded)); await tester.pumpAndSettle(); expect(navigateDownCalls, 1); @@ -765,7 +766,7 @@ void main() { }); await tester.pump(); - await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded)); await tester.pumpAndSettle(); expect(submitted, 'query'); @@ -799,7 +800,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); - await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.tap(_tvKeyboardDoneKey(Symbols.search_rounded)); await tester.pumpAndSettle(); await tester.pump(); diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index bc2c23b9..6236af56 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/navigation/profile_navigation_scope.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; @@ -27,12 +28,19 @@ import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/music/album_detail_screen.dart'; +import 'package:plezy/screens/music/artist_detail_screen.dart'; import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; @@ -40,6 +48,7 @@ import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -303,6 +312,102 @@ void main() { expect(find.text(t.playlists.selectPlaylist), findsNothing); expect(find.text('picker target'), findsOneWidget); }); + + testWidgets('track album action uses the profile navigator from the sibling menu overlay', (tester) async { + final track = testMediaItem( + id: 'track-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Track', + parentId: 'album-1', + parentTitle: 'Album', + grandparentId: 'artist-1', + grandparentTitle: 'Artist', + serverId: 'srv-1', + ); + final album = testMediaItem( + id: 'album-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.album, + title: 'Album', + parentId: 'artist-1', + parentTitle: 'Artist', + serverId: 'srv-1', + ); + final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [album]); + + await _selectSiblingMusicMenuAction(tester, harness, t.music.goToAlbum); + + expect(find.byType(AlbumDetailScreen), findsOneWidget); + expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue); + expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse); + expect( + Provider.of(tester.element(find.byType(AlbumDetailScreen)), listen: false), + same(harness.music), + ); + }); + + testWidgets('album artist action uses the profile navigator from the sibling menu overlay', (tester) async { + final album = testMediaItem( + id: 'album-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.album, + title: 'Album', + parentId: 'artist-1', + parentTitle: 'Artist', + serverId: 'srv-1', + ); + final artist = testMediaItem( + id: 'artist-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.artist, + title: 'Artist', + serverId: 'srv-1', + ); + final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: [artist]); + + await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist); + + expect(find.byType(ArtistDetailScreen), findsOneWidget); + expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue); + expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse); + expect( + Provider.of(tester.element(find.byType(ArtistDetailScreen)), listen: false), + same(harness.music), + ); + }); + + testWidgets('track artist action uses the profile navigator from the sibling menu overlay', (tester) async { + final track = testMediaItem( + id: 'track-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Track', + parentId: 'album-1', + parentTitle: 'Album', + grandparentId: 'artist-1', + grandparentTitle: 'Artist', + serverId: 'srv-1', + ); + final artist = testMediaItem( + id: 'artist-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.artist, + title: 'Artist', + serverId: 'srv-1', + ); + final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: [artist]); + + await _selectSiblingMusicMenuAction(tester, harness, t.music.goToArtist); + + expect(find.byType(ArtistDetailScreen), findsOneWidget); + expect(harness.profileNavigatorKey.currentState!.canPop(), isTrue); + expect(harness.rootNavigatorKey.currentState!.canPop(), isFalse); + expect( + Provider.of(tester.element(find.byType(ArtistDetailScreen)), listen: false), + same(harness.music), + ); + }); }); } @@ -467,6 +572,170 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService { } } +class _RelatedMusicClient implements MediaServerClient { + _RelatedMusicClient(Iterable items) : _items = {for (final item in items) item.id: item}; + + final Map _items; + + @override + ServerId get serverId => ServerId('srv-1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + ServerCapabilities get capabilities => ServerCapabilities.jellyfin; + + @override + Future fetchItem(String id) async => _items[id]; + + @override + Future> fetchAlbumTracks(String albumId) async => const []; + + @override + Future> fetchArtistAlbums(String artistId) async => const []; + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _SiblingMusicMenuHarness { + const _SiblingMusicMenuHarness({ + required this.rootNavigatorKey, + required this.profileNavigatorKey, + required this.menuKey, + required this.music, + }); + + final GlobalKey rootNavigatorKey; + final GlobalKey profileNavigatorKey; + final GlobalKey menuKey; + final _RecordingMusicPlaybackService music; +} + +Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( + WidgetTester tester, { + required MediaItem item, + required List relatedItems, +}) async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + LocaleSettings.setLocaleSync(AppLocale.en); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + JellyfinApiCache.initialize(db); + final downloadManager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + ); + downloadManager.recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + final client = _RelatedMusicClient(relatedItems); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final connections = ConnectionRegistry(db); + final profileConnections = ProfileConnectionRegistry(db); + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + plexHomeUserFetcher: (_) async => const [], + ); + final activeProfileProvider = ActiveProfileProvider( + registry: ProfileRegistry(db), + plexHome: plexHome, + connections: connections, + ); + final music = _RecordingMusicPlaybackService(); + final rootNavigatorKey = GlobalKey(); + final profileNavigatorKey = GlobalKey(); + final menuKey = GlobalKey(); + + addTearDown(() async { + downloadProvider.dispose(); + downloadManager.dispose(); + activeProfileProvider.dispose(); + await plexHome.dispose(); + music.dispose(); + multiServerProvider.dispose(); + manager.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + navigatorKey: rootNavigatorKey, + theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.macOS), + home: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: music), + ], + child: ProfileNavigationScope( + navigatorKey: profileNavigatorKey, + routeObserver: RouteObserver>(), + mainScaffoldMessengerKey: GlobalKey(), + child: Stack( + fit: StackFit.expand, + children: [ + Navigator( + key: profileNavigatorKey, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const Scaffold(body: Center(child: Text('profile content'))), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Material( + child: MediaContextMenu( + key: menuKey, + item: item, + child: const SizedBox( + width: 180, + height: 64, + child: Center(child: Text('mini-player menu target')), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + return _SiblingMusicMenuHarness( + rootNavigatorKey: rootNavigatorKey, + profileNavigatorKey: profileNavigatorKey, + menuKey: menuKey, + music: music, + ); +} + +Future _selectSiblingMusicMenuAction( + WidgetTester tester, + _SiblingMusicMenuHarness harness, + String actionLabel, +) async { + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target'))); + await tester.pumpAndSettle(); + await tester.tap(find.text(actionLabel)); + await tester.pumpAndSettle(); +} + PlexHomeUser _homeUser({required bool admin}) { return PlexHomeUser( id: 0, diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 74cafee4..fd100313 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -292,8 +292,11 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); - expect(find.text(t.common.play), findsOneWidget); - final menuSurface = find.byWidgetPredicate((widget) => widget is Material && widget.elevation == 3); + final playMenuIcon = find.byWidgetPredicate( + (widget) => widget is AppIcon && widget.icon == Symbols.play_arrow_rounded, + ); + expect(playMenuIcon, findsOneWidget); + final menuSurface = find.ancestor(of: playMenuIcon, matching: find.byType(BottomSheet)); expect(menuSurface, findsOneWidget); expect(tester.getCenter(menuSurface).dx, closeTo(cardRect.center.dx, 0.1)); @@ -301,18 +304,23 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('stays hidden while the route observer suppresses it', (tester) async { + testWidgets('stays hidden while a named suppressing route is active', (tester) async { final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver(); - observer.suppress.value = true; await tester.pumpWidget(wrap(service: service, observer: observer)); await tester.pumpAndSettle(); + expect(find.text('Dawn'), findsOneWidget); + final route = MaterialPageRoute( + settings: const RouteSettings(name: kNowPlayingRouteName), + builder: (_) => const SizedBox.shrink(), + ); + observer.didPush(route, null); + await tester.pumpAndSettle(); expect(find.text('Dawn'), findsNothing); - // Suppression lifting brings it back without a rebuild from the service. - observer.suppress.value = false; + observer.didRemove(route, null); await tester.pumpAndSettle(); expect(find.text('Dawn'), findsOneWidget); }); diff --git a/test/widgets/optimized_media_image_test.dart b/test/widgets/optimized_media_image_test.dart index 2cb583b9..6d9eec25 100644 --- a/test/widgets/optimized_media_image_test.dart +++ b/test/widgets/optimized_media_image_test.dart @@ -9,28 +9,49 @@ import 'package:plezy/utils/media_image_helper.dart'; import 'package:plezy/widgets/optimized_media_image.dart'; void main() { - testWidgets('network images use decode resize without disk cache resize', (tester) async { + testWidgets('network widget matches the shared server artwork provider', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + const imageUrl = 'https://example.invalid/episode-thumb.jpg'; + await tester.pumpWidget( const MaterialApp( home: SizedBox( width: 160, height: 90, - child: OptimizedMediaImage.thumb( - imagePath: 'https://example.invalid/episode-thumb.jpg', - width: 160, - height: 90, - ), + child: OptimizedMediaImage.thumb(imagePath: imageUrl, width: 160, height: 90), ), ), ); final image = tester.widget(find.byType(Image)); - final resizeProvider = image.image as ResizeImage; - final cachedProvider = resizeProvider.imageProvider as CachedNetworkImageProvider; + final widgetProvider = image.image as ResizeImage; + final widgetCached = widgetProvider.imageProvider as CachedNetworkImageProvider; + final sharedProvider = + MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: 200, memHeight: 180) as ResizeImage; + final sharedCached = sharedProvider.imageProvider as CachedNetworkImageProvider; - expect(resizeProvider.height, isNotNull); - expect(cachedProvider.maxHeight, isNull); - expect(cachedProvider.maxWidth, isNull); + expect(widgetCached, sharedCached); + expect(widgetProvider.width, sharedProvider.width); + expect(widgetProvider.width, 200); + expect(widgetProvider.height, sharedProvider.height); + expect(widgetProvider.height, 180); + expect(widgetProvider.policy, sharedProvider.policy); + expect(widgetProvider.policy, ResizeImagePolicy.fit); + expect(widgetProvider.allowUpscaling, sharedProvider.allowUpscaling); + expect(widgetProvider.allowUpscaling, isFalse); + expect(widgetCached.url, sharedCached.url); + expect(widgetCached.url, imageUrl); + expect(widgetCached.cacheKey, sharedCached.cacheKey); + expect(widgetCached.cacheKey, isNotNull); + expect(widgetCached.headers, sharedCached.headers); + expect(widgetCached.headers, const {'User-Agent': 'Plezy'}); + expect(widgetCached.maxWidth, sharedCached.maxWidth); + expect(widgetCached.maxWidth, isNull); + expect(widgetCached.maxHeight, sharedCached.maxHeight); + expect(widgetCached.maxHeight, isNull); }); testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async { @@ -69,6 +90,10 @@ void main() { }); testWidgets('same local artwork path re-resolves after the file appears', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); final directory = Directory.systemTemp.createTempSync('plezy-image-test'); addTearDown(() => directory.deleteSync(recursive: true)); final file = File('${directory.path}/poster.png'); @@ -110,6 +135,13 @@ void main() { expect(file.existsSync(), isTrue); expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing); expect(find.byType(Image), findsOneWidget); + final localImage = tester.widget(find.byType(Image)); + final localResize = localImage.image as ResizeImage; + final localProvider = localResize.imageProvider as FileImage; + expect(localResize.width, 120); + expect(localResize.height, 180); + expect(localResize.policy, ResizeImagePolicy.fit); + expect(localProvider.file.path, file.path); await tester.pumpWidget(const SizedBox.shrink()); }); } diff --git a/test/widgets/pin_entry_dialog_test.dart b/test/widgets/pin_entry_dialog_test.dart index 48da4898..84d68751 100644 --- a/test/widgets/pin_entry_dialog_test.dart +++ b/test/widgets/pin_entry_dialog_test.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/screens/profile/pin_entry_dialog.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -197,8 +198,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('1'), findsOneWidget); - expect(find.byIcon(Icons.close_rounded), findsOneWidget); - expect(find.byIcon(Icons.backspace_outlined), findsOneWidget); + expect(find.byIcon(Symbols.close_rounded), findsOneWidget); + expect(find.byIcon(Symbols.backspace_rounded), findsOneWidget); expect(find.byType(TextField), findsNothing); expect(find.widgetWithText(TextButton, 'Cancel'), findsNothing); expect(find.byType(FilledButton), findsNothing); diff --git a/test/widgets/remote_session_dialog_test.dart b/test/widgets/remote_session_dialog_test.dart index 5d90dde6..607c3167 100644 --- a/test/widgets/remote_session_dialog_test.dart +++ b/test/widgets/remote_session_dialog_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/widgets/companion_remote/remote_session_dialog.dart'; import 'package:provider/provider.dart'; @@ -106,6 +107,7 @@ Future _pumpDialogLauncher(WidgetTester tester, CompanionRemoteProvider pr ChangeNotifierProvider.value( value: provider, child: MaterialApp( + theme: monoTheme(dark: true), home: Builder( builder: (context) { return Scaffold( diff --git a/test/widgets/tv_virtual_keyboard_test.dart b/test/widgets/tv_virtual_keyboard_test.dart index 750a73b1..7c442f1f 100644 --- a/test/widgets/tv_virtual_keyboard_test.dart +++ b/test/widgets/tv_virtual_keyboard_test.dart @@ -3,6 +3,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:plezy/focus/dpad_navigator.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/tv_virtual_keyboard.dart'; @@ -153,9 +154,9 @@ void main() { await _pumpKeyboard(tester, controller: controller); expect(find.text('='), findsOneWidget); - expect(find.byIcon(Icons.functions_rounded), findsOneWidget); + expect(find.byIcon(Symbols.functions_rounded), findsOneWidget); - await tester.tap(find.byIcon(Icons.functions_rounded)); + await tester.tap(find.byIcon(Symbols.functions_rounded)); await tester.pumpAndSettle(); expect(find.text('ABC'), findsOneWidget); diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index 6a89c81a..a203ecee 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/mpv/models.dart'; import 'package:plezy/mpv/player/player.dart'; import 'package:plezy/mpv/player/player_state.dart'; import 'package:plezy/mpv/player/player_streams.dart'; +import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; +import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; @@ -30,12 +33,24 @@ const _testTokens = MonoTokens( ); void main() { + setUpAll(() async { + await LocaleSettings.setLocale(AppLocale.ru); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + setUp(() async { + LocaleSettings.setLocaleSync(AppLocale.en); + SleepTimerService().cancelTimer(); resetSharedPreferencesForTest(); SettingsService.resetForTesting(); await SettingsService.getInstance(); }); + tearDown(() { + SleepTimerService().cancelTimer(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async { await _pumpSheet(tester); @@ -43,9 +58,45 @@ void main() { expect(find.text('Audio Passthrough'), findsOneWidget); }); + + testWidgets('localizes Off, Normal, and Active video setting values', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.ru); + + await _pumpSheet(tester, canControl: true); + expect(find.text('Обычная'), findsOneWidget); + expect(find.text('Выкл.'), findsOneWidget); + + final sleepTimer = SleepTimerService(); + sleepTimer.startTimer(const Duration(hours: 1), () {}); + try { + await tester.pump(); + expect(find.textContaining('Активен ('), findsOneWidget); + } finally { + sleepTimer.cancelTimer(); + } + }); + + testWidgets('localizes every ASS subtitle override enum label', (tester) async { + LocaleSettings.setLocaleSync(AppLocale.ru); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: const SubtitleStylingScreen(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Переопределение ASS')); + await tester.pumpAndSettle(); + + final dialog = find.byType(AlertDialog); + for (final label in ['Нет', 'Да', 'Масштабировать', 'Принудительно', 'Удалить стили']) { + expect(find.descendant(of: dialog, matching: find.text(label)), findsOneWidget); + } + }); } -Future _pumpSheet(WidgetTester tester) async { +Future _pumpSheet(WidgetTester tester, {bool canControl = false}) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(extensions: const [_testTokens]), @@ -57,7 +108,7 @@ Future _pumpSheet(WidgetTester tester) async { player: _FakeSettingsPlayer(), audioSyncOffset: 0, subtitleSyncOffset: 0, - canControl: false, + canControl: canControl, ), ), ),