From d0a93d57ddae2bd70342590357160e772a351d50 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:16:11 +0200 Subject: [PATCH] refactor: codebase review cleanup --- lib/database/app_database.dart | 7 +- lib/i18n/da.i18n.json | 5 + lib/i18n/de.i18n.json | 5 + lib/i18n/en.i18n.json | 5 + lib/i18n/es.i18n.json | 5 + lib/i18n/fr.i18n.json | 5 + lib/i18n/it.i18n.json | 5 + lib/i18n/ja.i18n.json | 5 + lib/i18n/ko.i18n.json | 5 + lib/i18n/nb.i18n.json | 5 + lib/i18n/nl.i18n.json | 5 + lib/i18n/pl.i18n.json | 5 + lib/i18n/pt.i18n.json | 5 + lib/i18n/ru.i18n.json | 5 + lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_da.g.dart | 10 ++ lib/i18n/strings_de.g.dart | 10 ++ lib/i18n/strings_en.g.dart | 20 ++++ lib/i18n/strings_es.g.dart | 10 ++ lib/i18n/strings_fr.g.dart | 10 ++ lib/i18n/strings_it.g.dart | 10 ++ lib/i18n/strings_ja.g.dart | 10 ++ lib/i18n/strings_ko.g.dart | 10 ++ lib/i18n/strings_nb.g.dart | 10 ++ lib/i18n/strings_nl.g.dart | 10 ++ lib/i18n/strings_pl.g.dart | 10 ++ lib/i18n/strings_pt.g.dart | 10 ++ lib/i18n/strings_ru.g.dart | 10 ++ lib/i18n/strings_sv.g.dart | 10 ++ lib/i18n/strings_zh.g.dart | 10 ++ lib/i18n/sv.i18n.json | 5 + lib/i18n/zh.i18n.json | 5 + lib/main.dart | 13 +- lib/mixins/context_menu_tap_mixin.dart | 26 ++++ .../disposable_change_notifier_mixin.dart | 22 ++++ lib/mixins/grid_focus_node_mixin.dart | 6 + lib/mpv/font_loader.dart | 9 +- lib/mpv/player/player_base.dart | 8 +- lib/providers/download_provider.dart | 33 ++--- lib/providers/libraries_provider.dart | 10 +- lib/providers/multi_server_provider.dart | 11 +- lib/providers/offline_mode_provider.dart | 11 +- lib/screens/discover_screen.dart | 8 +- .../focusable_detail_screen_mixin.dart | 16 +-- lib/screens/hub_detail_screen.dart | 16 +-- lib/screens/livetv/program_details_sheet.dart | 9 +- lib/screens/main_screen.dart | 4 +- lib/screens/media_detail_screen.dart | 15 +-- lib/screens/playlist/playlist_item_card.dart | 24 ++-- lib/screens/video_player_screen.dart | 44 ++++--- lib/services/ambient_lighting_service.dart | 26 ++-- .../companion_remote_peer_service.dart | 20 +++- .../lan_discovery_service.dart | 5 +- lib/services/discord_rpc_service.dart | 8 +- lib/services/download_manager_service.dart | 19 ++- lib/services/gamepad_service.dart | 3 +- lib/services/multi_server_manager.dart | 15 ++- lib/services/offline_watch_sync_service.dart | 16 +-- lib/services/plex_auth_service.dart | 31 +++-- lib/services/plex_client.dart | 4 +- lib/services/saf_storage_service.dart | 16 +-- lib/services/settings_service.dart | 7 +- lib/services/shader_asset_loader.dart | 14 +-- lib/services/shader_service.dart | 37 ++---- lib/services/video_pip_manager.dart | 9 +- lib/theme/mono_theme.dart | 39 +++--- lib/utils/layout_constants.dart | 9 ++ lib/utils/navigation_transitions.dart | 6 +- lib/utils/plex_image_helper.dart | 36 ++++++ lib/utils/provider_extensions.dart | 113 ++++++++---------- lib/utils/smart_deletion_handler.dart | 5 +- lib/utils/snackbar_helper.dart | 12 +- .../screens/watch_together_screen.dart | 8 +- .../services/watch_together_peer_service.dart | 8 +- .../widgets/watch_together_overlay.dart | 5 +- lib/widgets/episode_card.dart | 26 ++-- lib/widgets/media_card.dart | 39 ++---- lib/widgets/media_context_menu.dart | 2 +- lib/widgets/overlay_sheet.dart | 2 +- lib/widgets/pill_input_decoration.dart | 9 +- lib/widgets/plex_optimized_image.dart | 50 +++----- .../video_controls/video_controls.dart | 48 +++++--- .../widgets/track_chapter_controls.dart | 4 +- 83 files changed, 763 insertions(+), 419 deletions(-) create mode 100644 lib/mixins/context_menu_tap_mixin.dart create mode 100644 lib/mixins/disposable_change_notifier_mixin.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index bad8e638..eab6249b 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -11,6 +11,9 @@ import '../utils/global_key_utils.dart'; part 'app_database.g.dart'; +/// String values stored in [OfflineWatchProgress.actionType] (use `.name`). +enum OfflineActionType { progress, watched, unwatched } + // Simplified database with API cache for offline support @DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules]) class AppDatabase extends _$AppDatabase { @@ -150,7 +153,7 @@ class AppDatabase extends _$AppDatabase { // Check for existing progress entry final existing = await (select(offlineWatchProgress) - ..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress')) + ..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals(OfflineActionType.progress.name)) ..limit(1)) .getSingleOrNull(); @@ -171,7 +174,7 @@ class AppDatabase extends _$AppDatabase { serverId: serverId, ratingKey: ratingKey, globalKey: globalKey, - actionType: 'progress', + actionType: OfflineActionType.progress.name, viewOffset: Value(viewOffset), duration: Value(duration), shouldMarkWatched: Value(shouldMarkWatched), diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 3878c714..d7121a51 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Download slettet", "deleteConfirm": "Er du sikker på, at du vil slette \"${title}\"? Den downloadede fil fjernes fra din enhed.", "deletingWithProgress": "Sletter ${title}... (${current} af ${total})", + "deleting": "Sletter...", + "queuedTooltip": "I kø", + "queuedFilesTooltip": "I kø: ${files}", + "downloadingTooltip": "Downloader...", + "downloadingFilesTooltip": "Downloader ${files}", "noDownloadsTree": "Ingen downloads", "pauseAll": "Pause alle", "resumeAll": "Genoptag alle", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 0b4ef368..e3b09c97 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Download gelöscht", "deleteConfirm": "Möchtest du \"${title}\" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.", "deletingWithProgress": "Lösche ${title}... (${current} von ${total})", + "deleting": "Lösche...", + "queuedTooltip": "In Warteschlange", + "queuedFilesTooltip": "In Warteschlange: ${files}", + "downloadingTooltip": "Lädt herunter...", + "downloadingFilesTooltip": "Lädt ${files} herunter", "noDownloadsTree": "Keine Downloads", "pauseAll": "Alle pausieren", "resumeAll": "Alle fortsetzen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index a86155c2..d72b50bf 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -778,7 +778,12 @@ "episodesQueued": "${count} episodes queued for download", "downloadDeleted": "Download deleted", "deleteConfirm": "Are you sure you want to delete \"${title}\"? This will remove the downloaded file from your device.", + "deleting": "Deleting...", "deletingWithProgress": "Deleting ${title}... (${current} of ${total})", + "queuedTooltip": "Queued", + "queuedFilesTooltip": "Queued ${files}", + "downloadingTooltip": "Downloading...", + "downloadingFilesTooltip": "Downloading ${files}", "noDownloadsTree": "No downloads", "pauseAll": "Pause all", "resumeAll": "Resume all", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 5e48bcff..d1aa9f49 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Descarga eliminada", "deleteConfirm": "¿Estás seguro de que quieres eliminar \"${title}\"? Esto borrará el archivo descargado de tu dispositivo.", "deletingWithProgress": "Eliminando ${title}... (${current} de ${total})", + "deleting": "Eliminando...", + "queuedTooltip": "En cola", + "queuedFilesTooltip": "En cola: ${files}", + "downloadingTooltip": "Descargando...", + "downloadingFilesTooltip": "Descargando ${files}", "noDownloadsTree": "Sin descargas", "pauseAll": "Pausar todo", "resumeAll": "Reanudar todo", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4264d7af..55f6563f 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Télécharger supprimé", "deleteConfirm": "Êtes-vous sûr de vouloir supprimer \"${title}\" ? Cela supprimera le fichier téléchargé de votre appareil.", "deletingWithProgress": "Suppression de ${title}... (${current} sur ${total})", + "deleting": "Suppression...", + "queuedTooltip": "En attente", + "queuedFilesTooltip": "En attente : ${files}", + "downloadingTooltip": "Téléchargement...", + "downloadingFilesTooltip": "Téléchargement de ${files}", "noDownloadsTree": "Aucun téléchargement", "pauseAll": "Tout mettre en pause", "resumeAll": "Tout reprendre", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index e5421d12..1d3573da 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Download eliminato", "deleteConfirm": "Sei sicuro di voler eliminare \"${title}\"? Il file scaricato verrà rimosso dal tuo dispositivo.", "deletingWithProgress": "Eliminazione di ${title}... (${current} di ${total})", + "deleting": "Eliminazione...", + "queuedTooltip": "In coda", + "queuedFilesTooltip": "In coda: ${files}", + "downloadingTooltip": "Download in corso...", + "downloadingFilesTooltip": "Download di ${files}", "noDownloadsTree": "Nessun download", "pauseAll": "Metti tutto in pausa", "resumeAll": "Riprendi tutto", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 0c862609..2cb224aa 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "ダウンロードを削除しました", "deleteConfirm": "\"${title}\"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。", "deletingWithProgress": "${title}を削除中... (${current}/${total})", + "deleting": "削除中...", + "queuedTooltip": "キュー", + "queuedFilesTooltip": "キュー: ${files}", + "downloadingTooltip": "ダウンロード中...", + "downloadingFilesTooltip": "${files} をダウンロード中", "noDownloadsTree": "ダウンロードなし", "pauseAll": "すべて一時停止", "resumeAll": "すべて再開", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index a06e8615..48936acb 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "다운로드 삭제됨", "deleteConfirm": "\"${title}\"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.", "deletingWithProgress": "${title} 삭제 중... (${current}/${total})", + "deleting": "삭제 중...", + "queuedTooltip": "대기 중", + "queuedFilesTooltip": "대기 중: ${files}", + "downloadingTooltip": "다운로드 중...", + "downloadingFilesTooltip": "${files} 다운로드 중", "noDownloadsTree": "다운로드 없음", "pauseAll": "모두 일시정지", "resumeAll": "모두 재개", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 4260d6b6..680bd41c 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Nedlasting slettet", "deleteConfirm": "Er du sikker på at du vil slette \"${title}\"? Dette vil fjerne den nedlastede filen fra enheten din.", "deletingWithProgress": "Sletter ${title}... (${current} av ${total})", + "deleting": "Sletter...", + "queuedTooltip": "I kø", + "queuedFilesTooltip": "I kø: ${files}", + "downloadingTooltip": "Laster ned...", + "downloadingFilesTooltip": "Laster ned ${files}", "noDownloadsTree": "Ingen nedlastinger", "pauseAll": "Pause alle", "resumeAll": "Gjenoppta alle", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 89d25833..26cda289 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Download verwijderd", "deleteConfirm": "Weet je zeker dat je \"${title}\" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.", "deletingWithProgress": "Verwijderen van ${title}... (${current} van ${total})", + "deleting": "Verwijderen...", + "queuedTooltip": "In wachtrij", + "queuedFilesTooltip": "In wachtrij: ${files}", + "downloadingTooltip": "Downloaden...", + "downloadingFilesTooltip": "Downloaden ${files}", "noDownloadsTree": "Geen downloads", "pauseAll": "Alles pauzeren", "resumeAll": "Alles hervatten", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0860a6a1..13daa6bb 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Pobranie usunięte", "deleteConfirm": "Czy na pewno chcesz usunąć \"${title}\"? Spowoduje to usunięcie pobranego pliku z urządzenia.", "deletingWithProgress": "Usuwanie ${title}... (${current} z ${total})", + "deleting": "Usuwanie...", + "queuedTooltip": "W kolejce", + "queuedFilesTooltip": "W kolejce: ${files}", + "downloadingTooltip": "Pobieranie...", + "downloadingFilesTooltip": "Pobieranie ${files}", "noDownloadsTree": "Brak pobrań", "pauseAll": "Wstrzymaj wszystko", "resumeAll": "Wznów wszystko", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 7ae6081b..1d69fb6f 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Download excluído", "deleteConfirm": "Tem certeza que deseja excluir \"${title}\"? Isso removerá o arquivo baixado do seu dispositivo.", "deletingWithProgress": "Excluindo ${title}... (${current} de ${total})", + "deleting": "Excluindo...", + "queuedTooltip": "Na fila", + "queuedFilesTooltip": "Na fila: ${files}", + "downloadingTooltip": "Baixando...", + "downloadingFilesTooltip": "Baixando ${files}", "noDownloadsTree": "Nenhum download", "pauseAll": "Pausar todos", "resumeAll": "Retomar todos", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 90e08e81..22535041 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Загрузка удалена", "deleteConfirm": "Вы уверены, что хотите удалить \"${title}\"? Загруженный файл будет удалён с устройства.", "deletingWithProgress": "Удаление ${title}... (${current} из ${total})", + "deleting": "Удаление...", + "queuedTooltip": "В очереди", + "queuedFilesTooltip": "В очереди: ${files}", + "downloadingTooltip": "Загрузка...", + "downloadingFilesTooltip": "Загрузка ${files}", "noDownloadsTree": "Нет загрузок", "pauseAll": "Приостановить все", "resumeAll": "Возобновить все", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 197252ff..2931b21f 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 15 -/// Strings: 14430 (962 per locale) +/// Strings: 14505 (967 per locale) /// -/// Built on 2026-04-24 at 16:44 UTC +/// Built on 2026-04-25 at 01:00 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 177c09ed..88e8878c 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Download slettet'; @override String deleteConfirm({required Object title}) => 'Er du sikker på, at du vil slette "${title}"? Den downloadede fil fjernes fra din enhed.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} af ${total})'; + @override String get deleting => 'Sletter...'; + @override String get queuedTooltip => 'I kø'; + @override String queuedFilesTooltip({required Object files}) => 'I kø: ${files}'; + @override String get downloadingTooltip => 'Downloader...'; + @override String downloadingFilesTooltip({required Object files}) => 'Downloader ${files}'; @override String get noDownloadsTree => 'Ingen downloads'; @override String get pauseAll => 'Pause alle'; @override String get resumeAll => 'Genoptag alle'; @@ -2240,6 +2245,11 @@ extension on TranslationsDa { 'downloads.downloadDeleted' => 'Download slettet', 'downloads.deleteConfirm' => ({required Object title}) => 'Er du sikker på, at du vil slette "${title}"? Den downloadede fil fjernes fra din enhed.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} af ${total})', + 'downloads.deleting' => 'Sletter...', + 'downloads.queuedTooltip' => 'I kø', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kø: ${files}', + 'downloads.downloadingTooltip' => 'Downloader...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloader ${files}', 'downloads.noDownloadsTree' => 'Ingen downloads', 'downloads.pauseAll' => 'Pause alle', 'downloads.resumeAll' => 'Genoptag alle', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 3b47cac0..da0e53f6 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Download gelöscht'; @override String deleteConfirm({required Object title}) => 'Möchtest du "${title}" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Lösche ${title}... (${current} von ${total})'; + @override String get deleting => 'Lösche...'; + @override String get queuedTooltip => 'In Warteschlange'; + @override String queuedFilesTooltip({required Object files}) => 'In Warteschlange: ${files}'; + @override String get downloadingTooltip => 'Lädt herunter...'; + @override String downloadingFilesTooltip({required Object files}) => 'Lädt ${files} herunter'; @override String get noDownloadsTree => 'Keine Downloads'; @override String get pauseAll => 'Alle pausieren'; @override String get resumeAll => 'Alle fortsetzen'; @@ -2240,6 +2245,11 @@ extension on TranslationsDe { 'downloads.downloadDeleted' => 'Download gelöscht', 'downloads.deleteConfirm' => ({required Object title}) => 'Möchtest du "${title}" wirklich löschen? Die heruntergeladene Datei wird von deinem Gerät entfernt.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Lösche ${title}... (${current} von ${total})', + 'downloads.deleting' => 'Lösche...', + 'downloads.queuedTooltip' => 'In Warteschlange', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'In Warteschlange: ${files}', + 'downloads.downloadingTooltip' => 'Lädt herunter...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Lädt ${files} herunter', 'downloads.noDownloadsTree' => 'Keine Downloads', 'downloads.pauseAll' => 'Alle pausieren', 'downloads.resumeAll' => 'Alle fortsetzen', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 6740eaef..3905d057 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2384,9 +2384,24 @@ class TranslationsDownloadsEn { /// en: 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.' String deleteConfirm({required Object title}) => 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.'; + /// en: 'Deleting...' + String get deleting => 'Deleting...'; + /// en: 'Deleting ${title}... (${current} of ${total})' String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Deleting ${title}... (${current} of ${total})'; + /// en: 'Queued' + String get queuedTooltip => 'Queued'; + + /// en: 'Queued ${files}' + String queuedFilesTooltip({required Object files}) => 'Queued ${files}'; + + /// en: 'Downloading...' + String get downloadingTooltip => 'Downloading...'; + + /// en: 'Downloading ${files}' + String downloadingFilesTooltip({required Object files}) => 'Downloading ${files}'; + /// en: 'No downloads' String get noDownloadsTree => 'No downloads'; @@ -4171,7 +4186,12 @@ extension on Translations { 'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download', 'downloads.downloadDeleted' => 'Download deleted', 'downloads.deleteConfirm' => ({required Object title}) => 'Are you sure you want to delete "${title}"? This will remove the downloaded file from your device.', + 'downloads.deleting' => 'Deleting...', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Deleting ${title}... (${current} of ${total})', + 'downloads.queuedTooltip' => 'Queued', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'Queued ${files}', + 'downloads.downloadingTooltip' => 'Downloading...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloading ${files}', 'downloads.noDownloadsTree' => 'No downloads', 'downloads.pauseAll' => 'Pause all', 'downloads.resumeAll' => 'Resume all', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index c6b25f6b..a0ae8398 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Descarga eliminada'; @override String deleteConfirm({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esto borrará el archivo descargado de tu dispositivo.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Eliminando ${title}... (${current} de ${total})'; + @override String get deleting => 'Eliminando...'; + @override String get queuedTooltip => 'En cola'; + @override String queuedFilesTooltip({required Object files}) => 'En cola: ${files}'; + @override String get downloadingTooltip => 'Descargando...'; + @override String downloadingFilesTooltip({required Object files}) => 'Descargando ${files}'; @override String get noDownloadsTree => 'Sin descargas'; @override String get pauseAll => 'Pausar todo'; @override String get resumeAll => 'Reanudar todo'; @@ -2240,6 +2245,11 @@ extension on TranslationsEs { 'downloads.downloadDeleted' => 'Descarga eliminada', 'downloads.deleteConfirm' => ({required Object title}) => '¿Estás seguro de que quieres eliminar "${title}"? Esto borrará el archivo descargado de tu dispositivo.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Eliminando ${title}... (${current} de ${total})', + 'downloads.deleting' => 'Eliminando...', + 'downloads.queuedTooltip' => 'En cola', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'En cola: ${files}', + 'downloads.downloadingTooltip' => 'Descargando...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Descargando ${files}', 'downloads.noDownloadsTree' => 'Sin descargas', 'downloads.pauseAll' => 'Pausar todo', 'downloads.resumeAll' => 'Reanudar todo', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 7918f038..2e73d743 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Télécharger supprimé'; @override String deleteConfirm({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cela supprimera le fichier téléchargé de votre appareil.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Suppression de ${title}... (${current} sur ${total})'; + @override String get deleting => 'Suppression...'; + @override String get queuedTooltip => 'En attente'; + @override String queuedFilesTooltip({required Object files}) => 'En attente : ${files}'; + @override String get downloadingTooltip => 'Téléchargement...'; + @override String downloadingFilesTooltip({required Object files}) => 'Téléchargement de ${files}'; @override String get noDownloadsTree => 'Aucun téléchargement'; @override String get pauseAll => 'Tout mettre en pause'; @override String get resumeAll => 'Tout reprendre'; @@ -2240,6 +2245,11 @@ extension on TranslationsFr { 'downloads.downloadDeleted' => 'Télécharger supprimé', 'downloads.deleteConfirm' => ({required Object title}) => 'Êtes-vous sûr de vouloir supprimer "${title}" ? Cela supprimera le fichier téléchargé de votre appareil.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Suppression de ${title}... (${current} sur ${total})', + 'downloads.deleting' => 'Suppression...', + 'downloads.queuedTooltip' => 'En attente', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'En attente : ${files}', + 'downloads.downloadingTooltip' => 'Téléchargement...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Téléchargement de ${files}', 'downloads.noDownloadsTree' => 'Aucun téléchargement', 'downloads.pauseAll' => 'Tout mettre en pause', 'downloads.resumeAll' => 'Tout reprendre', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 544f9a9b..f3d82934 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Download eliminato'; @override String deleteConfirm({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Il file scaricato verrà rimosso dal tuo dispositivo.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Eliminazione di ${title}... (${current} di ${total})'; + @override String get deleting => 'Eliminazione...'; + @override String get queuedTooltip => 'In coda'; + @override String queuedFilesTooltip({required Object files}) => 'In coda: ${files}'; + @override String get downloadingTooltip => 'Download in corso...'; + @override String downloadingFilesTooltip({required Object files}) => 'Download di ${files}'; @override String get noDownloadsTree => 'Nessun download'; @override String get pauseAll => 'Metti tutto in pausa'; @override String get resumeAll => 'Riprendi tutto'; @@ -2240,6 +2245,11 @@ extension on TranslationsIt { 'downloads.downloadDeleted' => 'Download eliminato', 'downloads.deleteConfirm' => ({required Object title}) => 'Sei sicuro di voler eliminare "${title}"? Il file scaricato verrà rimosso dal tuo dispositivo.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Eliminazione di ${title}... (${current} di ${total})', + 'downloads.deleting' => 'Eliminazione...', + 'downloads.queuedTooltip' => 'In coda', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'In coda: ${files}', + 'downloads.downloadingTooltip' => 'Download in corso...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Download di ${files}', 'downloads.noDownloadsTree' => 'Nessun download', 'downloads.pauseAll' => 'Metti tutto in pausa', 'downloads.resumeAll' => 'Riprendi tutto', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 69d7d20b..aea4a927 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn { @override String get downloadDeleted => 'ダウンロードを削除しました'; @override String deleteConfirm({required Object title}) => '"${title}"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => '${title}を削除中... (${current}/${total})'; + @override String get deleting => '削除中...'; + @override String get queuedTooltip => 'キュー'; + @override String queuedFilesTooltip({required Object files}) => 'キュー: ${files}'; + @override String get downloadingTooltip => 'ダウンロード中...'; + @override String downloadingFilesTooltip({required Object files}) => '${files} をダウンロード中'; @override String get noDownloadsTree => 'ダウンロードなし'; @override String get pauseAll => 'すべて一時停止'; @override String get resumeAll => 'すべて再開'; @@ -2240,6 +2245,11 @@ extension on TranslationsJa { 'downloads.downloadDeleted' => 'ダウンロードを削除しました', 'downloads.deleteConfirm' => ({required Object title}) => '"${title}"を削除してもよろしいですか?ダウンロードしたファイルがデバイスから削除されます。', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '${title}を削除中... (${current}/${total})', + 'downloads.deleting' => '削除中...', + 'downloads.queuedTooltip' => 'キュー', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'キュー: ${files}', + 'downloads.downloadingTooltip' => 'ダウンロード中...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => '${files} をダウンロード中', 'downloads.noDownloadsTree' => 'ダウンロードなし', 'downloads.pauseAll' => 'すべて一時停止', 'downloads.resumeAll' => 'すべて再開', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 501d3ae5..1231b79e 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn { @override String get downloadDeleted => '다운로드 삭제됨'; @override String deleteConfirm({required Object title}) => '"${title}"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => '${title} 삭제 중... (${current}/${total})'; + @override String get deleting => '삭제 중...'; + @override String get queuedTooltip => '대기 중'; + @override String queuedFilesTooltip({required Object files}) => '대기 중: ${files}'; + @override String get downloadingTooltip => '다운로드 중...'; + @override String downloadingFilesTooltip({required Object files}) => '${files} 다운로드 중'; @override String get noDownloadsTree => '다운로드 없음'; @override String get pauseAll => '모두 일시정지'; @override String get resumeAll => '모두 재개'; @@ -2240,6 +2245,11 @@ extension on TranslationsKo { 'downloads.downloadDeleted' => '다운로드 삭제됨', 'downloads.deleteConfirm' => ({required Object title}) => '"${title}"를 삭제 하시겠습니까? 다운로드한 파일이 기기에서 삭제됩니다.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '${title} 삭제 중... (${current}/${total})', + 'downloads.deleting' => '삭제 중...', + 'downloads.queuedTooltip' => '대기 중', + 'downloads.queuedFilesTooltip' => ({required Object files}) => '대기 중: ${files}', + 'downloads.downloadingTooltip' => '다운로드 중...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => '${files} 다운로드 중', 'downloads.noDownloadsTree' => '다운로드 없음', 'downloads.pauseAll' => '모두 일시정지', 'downloads.resumeAll' => '모두 재개', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index b39a59b4..2ac829d8 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Nedlasting slettet'; @override String deleteConfirm({required Object title}) => 'Er du sikker på at du vil slette "${title}"? Dette vil fjerne den nedlastede filen fra enheten din.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} av ${total})'; + @override String get deleting => 'Sletter...'; + @override String get queuedTooltip => 'I kø'; + @override String queuedFilesTooltip({required Object files}) => 'I kø: ${files}'; + @override String get downloadingTooltip => 'Laster ned...'; + @override String downloadingFilesTooltip({required Object files}) => 'Laster ned ${files}'; @override String get noDownloadsTree => 'Ingen nedlastinger'; @override String get pauseAll => 'Pause alle'; @override String get resumeAll => 'Gjenoppta alle'; @@ -2240,6 +2245,11 @@ extension on TranslationsNb { 'downloads.downloadDeleted' => 'Nedlasting slettet', 'downloads.deleteConfirm' => ({required Object title}) => 'Er du sikker på at du vil slette "${title}"? Dette vil fjerne den nedlastede filen fra enheten din.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Sletter ${title}... (${current} av ${total})', + 'downloads.deleting' => 'Sletter...', + 'downloads.queuedTooltip' => 'I kø', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kø: ${files}', + 'downloads.downloadingTooltip' => 'Laster ned...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Laster ned ${files}', 'downloads.noDownloadsTree' => 'Ingen nedlastinger', 'downloads.pauseAll' => 'Pause alle', 'downloads.resumeAll' => 'Gjenoppta alle', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 436ce598..b21f3e04 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Download verwijderd'; @override String deleteConfirm({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Verwijderen van ${title}... (${current} van ${total})'; + @override String get deleting => 'Verwijderen...'; + @override String get queuedTooltip => 'In wachtrij'; + @override String queuedFilesTooltip({required Object files}) => 'In wachtrij: ${files}'; + @override String get downloadingTooltip => 'Downloaden...'; + @override String downloadingFilesTooltip({required Object files}) => 'Downloaden ${files}'; @override String get noDownloadsTree => 'Geen downloads'; @override String get pauseAll => 'Alles pauzeren'; @override String get resumeAll => 'Alles hervatten'; @@ -2240,6 +2245,11 @@ extension on TranslationsNl { 'downloads.downloadDeleted' => 'Download verwijderd', 'downloads.deleteConfirm' => ({required Object title}) => 'Weet je zeker dat je "${title}" wilt verwijderen? Het gedownloade bestand wordt van je apparaat verwijderd.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Verwijderen van ${title}... (${current} van ${total})', + 'downloads.deleting' => 'Verwijderen...', + 'downloads.queuedTooltip' => 'In wachtrij', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'In wachtrij: ${files}', + 'downloads.downloadingTooltip' => 'Downloaden...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Downloaden ${files}', 'downloads.noDownloadsTree' => 'Geen downloads', 'downloads.pauseAll' => 'Alles pauzeren', 'downloads.resumeAll' => 'Alles hervatten', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 21bab4a5..ad64d42f 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Pobranie usunięte'; @override String deleteConfirm({required Object title}) => 'Czy na pewno chcesz usunąć "${title}"? Spowoduje to usunięcie pobranego pliku z urządzenia.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Usuwanie ${title}... (${current} z ${total})'; + @override String get deleting => 'Usuwanie...'; + @override String get queuedTooltip => 'W kolejce'; + @override String queuedFilesTooltip({required Object files}) => 'W kolejce: ${files}'; + @override String get downloadingTooltip => 'Pobieranie...'; + @override String downloadingFilesTooltip({required Object files}) => 'Pobieranie ${files}'; @override String get noDownloadsTree => 'Brak pobrań'; @override String get pauseAll => 'Wstrzymaj wszystko'; @override String get resumeAll => 'Wznów wszystko'; @@ -2240,6 +2245,11 @@ extension on TranslationsPl { 'downloads.downloadDeleted' => 'Pobranie usunięte', 'downloads.deleteConfirm' => ({required Object title}) => 'Czy na pewno chcesz usunąć "${title}"? Spowoduje to usunięcie pobranego pliku z urządzenia.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Usuwanie ${title}... (${current} z ${total})', + 'downloads.deleting' => 'Usuwanie...', + 'downloads.queuedTooltip' => 'W kolejce', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'W kolejce: ${files}', + 'downloads.downloadingTooltip' => 'Pobieranie...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Pobieranie ${files}', 'downloads.noDownloadsTree' => 'Brak pobrań', 'downloads.pauseAll' => 'Wstrzymaj wszystko', 'downloads.resumeAll' => 'Wznów wszystko', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 2f6de77e..1871a41f 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Download excluído'; @override String deleteConfirm({required Object title}) => 'Tem certeza que deseja excluir "${title}"? Isso removerá o arquivo baixado do seu dispositivo.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Excluindo ${title}... (${current} de ${total})'; + @override String get deleting => 'Excluindo...'; + @override String get queuedTooltip => 'Na fila'; + @override String queuedFilesTooltip({required Object files}) => 'Na fila: ${files}'; + @override String get downloadingTooltip => 'Baixando...'; + @override String downloadingFilesTooltip({required Object files}) => 'Baixando ${files}'; @override String get noDownloadsTree => 'Nenhum download'; @override String get pauseAll => 'Pausar todos'; @override String get resumeAll => 'Retomar todos'; @@ -2240,6 +2245,11 @@ extension on TranslationsPt { 'downloads.downloadDeleted' => 'Download excluído', 'downloads.deleteConfirm' => ({required Object title}) => 'Tem certeza que deseja excluir "${title}"? Isso removerá o arquivo baixado do seu dispositivo.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Excluindo ${title}... (${current} de ${total})', + 'downloads.deleting' => 'Excluindo...', + 'downloads.queuedTooltip' => 'Na fila', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'Na fila: ${files}', + 'downloads.downloadingTooltip' => 'Baixando...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Baixando ${files}', 'downloads.noDownloadsTree' => 'Nenhum download', 'downloads.pauseAll' => 'Pausar todos', 'downloads.resumeAll' => 'Retomar todos', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 7fbc4882..622fd57d 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Загрузка удалена'; @override String deleteConfirm({required Object title}) => 'Вы уверены, что хотите удалить "${title}"? Загруженный файл будет удалён с устройства.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Удаление ${title}... (${current} из ${total})'; + @override String get deleting => 'Удаление...'; + @override String get queuedTooltip => 'В очереди'; + @override String queuedFilesTooltip({required Object files}) => 'В очереди: ${files}'; + @override String get downloadingTooltip => 'Загрузка...'; + @override String downloadingFilesTooltip({required Object files}) => 'Загрузка ${files}'; @override String get noDownloadsTree => 'Нет загрузок'; @override String get pauseAll => 'Приостановить все'; @override String get resumeAll => 'Возобновить все'; @@ -2240,6 +2245,11 @@ extension on TranslationsRu { 'downloads.downloadDeleted' => 'Загрузка удалена', 'downloads.deleteConfirm' => ({required Object title}) => 'Вы уверены, что хотите удалить "${title}"? Загруженный файл будет удалён с устройства.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Удаление ${title}... (${current} из ${total})', + 'downloads.deleting' => 'Удаление...', + 'downloads.queuedTooltip' => 'В очереди', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'В очереди: ${files}', + 'downloads.downloadingTooltip' => 'Загрузка...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Загрузка ${files}', 'downloads.noDownloadsTree' => 'Нет загрузок', 'downloads.pauseAll' => 'Приостановить все', 'downloads.resumeAll' => 'Возобновить все', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 78ef5afa..7bf30d52 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn { @override String get downloadDeleted => 'Nedladdning borttagen'; @override String deleteConfirm({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Den nedladdade filen kommer att tas bort från din enhet.'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => 'Tar bort ${title}... (${current} av ${total})'; + @override String get deleting => 'Tar bort...'; + @override String get queuedTooltip => 'I kö'; + @override String queuedFilesTooltip({required Object files}) => 'I kö: ${files}'; + @override String get downloadingTooltip => 'Laddar ned...'; + @override String downloadingFilesTooltip({required Object files}) => 'Laddar ned ${files}'; @override String get noDownloadsTree => 'Inga nedladdningar'; @override String get pauseAll => 'Pausa alla'; @override String get resumeAll => 'Återuppta alla'; @@ -2240,6 +2245,11 @@ extension on TranslationsSv { 'downloads.downloadDeleted' => 'Nedladdning borttagen', 'downloads.deleteConfirm' => ({required Object title}) => 'Är du säker på att du vill ta bort "${title}"? Den nedladdade filen kommer att tas bort från din enhet.', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => 'Tar bort ${title}... (${current} av ${total})', + 'downloads.deleting' => 'Tar bort...', + 'downloads.queuedTooltip' => 'I kö', + 'downloads.queuedFilesTooltip' => ({required Object files}) => 'I kö: ${files}', + 'downloads.downloadingTooltip' => 'Laddar ned...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => 'Laddar ned ${files}', 'downloads.noDownloadsTree' => 'Inga nedladdningar', 'downloads.pauseAll' => 'Pausa alla', 'downloads.resumeAll' => 'Återuppta alla', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 02a0aaad..ff2683c5 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1040,6 +1040,11 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn { @override String get downloadDeleted => '下载已删除'; @override String deleteConfirm({required Object title}) => '确定要删除 "${title}" 吗?下载的文件将从您的设备中删除。'; @override String deletingWithProgress({required Object title, required Object current, required Object total}) => '正在删除 ${title}... (${current}/${total})'; + @override String get deleting => '正在删除...'; + @override String get queuedTooltip => '已排队'; + @override String queuedFilesTooltip({required Object files}) => '已排队:${files}'; + @override String get downloadingTooltip => '正在下载...'; + @override String downloadingFilesTooltip({required Object files}) => '正在下载 ${files}'; @override String get noDownloadsTree => '暂无下载'; @override String get pauseAll => '全部暂停'; @override String get resumeAll => '全部继续'; @@ -2240,6 +2245,11 @@ extension on TranslationsZh { 'downloads.downloadDeleted' => '下载已删除', 'downloads.deleteConfirm' => ({required Object title}) => '确定要删除 "${title}" 吗?下载的文件将从您的设备中删除。', 'downloads.deletingWithProgress' => ({required Object title, required Object current, required Object total}) => '正在删除 ${title}... (${current}/${total})', + 'downloads.deleting' => '正在删除...', + 'downloads.queuedTooltip' => '已排队', + 'downloads.queuedFilesTooltip' => ({required Object files}) => '已排队:${files}', + 'downloads.downloadingTooltip' => '正在下载...', + 'downloads.downloadingFilesTooltip' => ({required Object files}) => '正在下载 ${files}', 'downloads.noDownloadsTree' => '暂无下载', 'downloads.pauseAll' => '全部暂停', 'downloads.resumeAll' => '全部继续', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index b5736813..2f484303 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "Nedladdning borttagen", "deleteConfirm": "Är du säker på att du vill ta bort \"${title}\"? Den nedladdade filen kommer att tas bort från din enhet.", "deletingWithProgress": "Tar bort ${title}... (${current} av ${total})", + "deleting": "Tar bort...", + "queuedTooltip": "I kö", + "queuedFilesTooltip": "I kö: ${files}", + "downloadingTooltip": "Laddar ned...", + "downloadingFilesTooltip": "Laddar ned ${files}", "noDownloadsTree": "Inga nedladdningar", "pauseAll": "Pausa alla", "resumeAll": "Återuppta alla", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index e97233cf..ff12bf64 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -779,6 +779,11 @@ "downloadDeleted": "下载已删除", "deleteConfirm": "确定要删除 \"${title}\" 吗?下载的文件将从您的设备中删除。", "deletingWithProgress": "正在删除 ${title}... (${current}/${total})", + "deleting": "正在删除...", + "queuedTooltip": "已排队", + "queuedFilesTooltip": "已排队:${files}", + "downloadingTooltip": "正在下载...", + "downloadingFilesTooltip": "正在下载 ${files}", "noDownloadsTree": "暂无下载", "pauseAll": "全部暂停", "resumeAll": "全部继续", diff --git a/lib/main.dart b/lib/main.dart index 84bc3b33..7b9d8c32 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'dart:io' show Platform; +import 'dart:io' show Platform, ProcessInfo; import 'dart:ui' show AppExitResponse; import 'package:flutter/foundation.dart'; import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; -import 'dart:io' show Platform, ProcessInfo; import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; import 'package:provider/provider.dart'; @@ -145,7 +144,7 @@ Future _bootstrapApp() async { await initializeDateFormatting(savedLocale.languageCode, null); // Configure image cache — keep budget modest to leave headroom for Skia decode buffers - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { PaintingBinding.instance.imageCache.maximumSize = 1000; PaintingBinding.instance.imageCache.maximumSizeBytes = 150 << 20; // 150MB } else { @@ -157,7 +156,7 @@ Future _bootstrapApp() async { final futures = >[]; // Initialize window_manager for desktop platforms - if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { futures.add(windowManager.ensureInitialized()); } @@ -212,7 +211,7 @@ Future _bootstrapApp() async { GamepadService.instance.start(); // Desktop-only services - if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { DiscordRPCService.instance.initialize(); } @@ -415,7 +414,7 @@ class _MainAppState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); // On desktop, periodically check RSS and evict image cache if too high - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { _memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) { final rss = ProcessInfo.currentRss; if (rss > 1536 * 1024 * 1024) { @@ -592,7 +591,7 @@ class _MainAppState extends State with WidgetsBindingObserver { // (sync, downloads, cache) still hold references to the executor. // SQLite WAL mode handles process death; desktop uses onExitRequested. InAppReviewService.instance.endSession(); - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { if (ProcessInfo.currentRss > 1024 * 1024 * 1024) { // 1GB _evictImageCaches(); diff --git a/lib/mixins/context_menu_tap_mixin.dart b/lib/mixins/context_menu_tap_mixin.dart new file mode 100644 index 00000000..cb71abd4 --- /dev/null +++ b/lib/mixins/context_menu_tap_mixin.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../widgets/media_context_menu.dart'; + +/// Tracks tap position and exposes show-context-menu helpers for media cards +/// that wrap their tappable area in a [MediaContextMenu]. +mixin ContextMenuTapMixin on State { + final GlobalKey contextMenuKey = GlobalKey(); + Offset? _tapPosition; + + void storeTapPosition(TapDownDetails details) { + _tapPosition = details.globalPosition; + } + + bool get isContextMenuOpen => contextMenuKey.currentState?.isContextMenuOpen ?? false; + + /// Show at the last tap position (long-press, mouse). + void showContextMenuFromTap() { + contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition); + } + + /// Show without a tap position (keyboard, gamepad). + void showContextMenu() { + contextMenuKey.currentState?.showContextMenu(context); + } +} diff --git a/lib/mixins/disposable_change_notifier_mixin.dart b/lib/mixins/disposable_change_notifier_mixin.dart new file mode 100644 index 00000000..b0d50e57 --- /dev/null +++ b/lib/mixins/disposable_change_notifier_mixin.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; + +/// Adds [safeNotifyListeners] which no-ops after [dispose]. Use in providers +/// that fire from async paths where a late callback could otherwise trip +/// Flutter's debug-only "used after dispose" assert. +mixin DisposableChangeNotifierMixin on ChangeNotifier { + bool _disposed = false; + + bool get isDisposed => _disposed; + + bool safeNotifyListeners() { + if (_disposed) return false; + notifyListeners(); + return true; + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/mixins/grid_focus_node_mixin.dart b/lib/mixins/grid_focus_node_mixin.dart index 94d92d45..5bcf6048 100644 --- a/lib/mixins/grid_focus_node_mixin.dart +++ b/lib/mixins/grid_focus_node_mixin.dart @@ -19,6 +19,12 @@ mixin GridFocusNodeMixin on State { return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index')); } + /// Get the focus node for [index], routing index 0 through [firstNode] when + /// the grid pins a dedicated node for the first item (e.g. `firstItemFocusNode`). + FocusNode focusNodeForIndex(int index, FocusNode firstNode, {required String prefix}) { + return index == 0 ? firstNode : getGridItemFocusNode(index, prefix: prefix); + } + /// Record that the item at [index] received focus. void trackGridItemFocus(int index, bool hasFocus) { if (hasFocus) { diff --git a/lib/mpv/font_loader.dart b/lib/mpv/font_loader.dart index 331ef682..4e360b7c 100644 --- a/lib/mpv/font_loader.dart +++ b/lib/mpv/font_loader.dart @@ -1,9 +1,10 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; +import '../utils/app_logger.dart'; + /// Utility class for loading font assets for libass subtitle rendering. /// /// Extracts font files from Flutter assets to the app's cache directory to ensure @@ -44,11 +45,9 @@ class SubtitleFontLoader { } return fontDir.path; - } catch (e) { + } catch (e, st) { // Return null if font loading fails - libass will fall back gracefully - if (kDebugMode) { - debugPrint('Failed to load subtitle font: $e'); - } + appLogger.w('Failed to load subtitle font', error: e, stackTrace: st); return null; } } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 95a29491..74eef2f6 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -220,8 +220,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { try { final parsed = jsonDecode(value); if (parsed is List) trackList = parsed; - } catch (_) { - // Ignore parse errors + } catch (e) { + appLogger.d('Player: track-list parse failed', error: e); } } if (trackList != null) { @@ -260,7 +260,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { try { final parsed = jsonDecode(value); if (parsed is List) deviceList = parsed; - } catch (_) {} + } catch (e) { + appLogger.d('Player: device-list parse failed', error: e); + } } if (deviceList != null) { final devices = deviceList diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 884d614a..a56d27b4 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -18,6 +18,7 @@ import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; import '../utils/episode_collection.dart'; import '../utils/global_key_utils.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; /// Filter mode for batch downloads (shows/seasons). /// Use [all] to download everything, or [unwatched] with an optional maxCount. @@ -39,7 +40,7 @@ class DownloadedArtwork { } /// Provider for managing download state and operations. -class DownloadProvider extends ChangeNotifier { +class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin { final DownloadManagerService _downloadManager; final AppDatabase _database; final SyncRuleExecutor _syncRuleExecutor; @@ -158,7 +159,7 @@ class DownloadProvider extends ChangeNotifier { 'Loaded ${_downloads.length} downloads, ${_metadata.length} metadata entries, ' '${_totalEpisodeCounts.length} episode counts, and ${_syncRules.length} sync rules', ); - notifyListeners(); + safeNotifyListeners(); } catch (e) { appLogger.e('Failed to load persisted downloads', error: e); } @@ -236,7 +237,7 @@ class DownloadProvider extends ChangeNotifier { } appLogger.d('Notifying listeners for ${progress.globalKey}'); - notifyListeners(); + safeNotifyListeners(); } @override @@ -606,7 +607,7 @@ class DownloadProvider extends ChangeNotifier { try { // Mark as queueing to show loading state in UI _queueing.add(globalKey); - notifyListeners(); + safeNotifyListeners(); final mt = metadata.mediaType; @@ -624,7 +625,7 @@ class DownloadProvider extends ChangeNotifier { } } finally { _queueing.remove(globalKey); - notifyListeners(); + safeNotifyListeners(); } } @@ -752,7 +753,7 @@ class DownloadProvider extends ChangeNotifier { // Update local state immediately for UI feedback _downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued); - notifyListeners(); + safeNotifyListeners(); // Actually trigger download via DownloadManagerService await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex); @@ -945,7 +946,7 @@ class DownloadProvider extends ChangeNotifier { await _downloadManager.cancelDownload(globalKey); _downloads.remove(globalKey); _metadata.remove(globalKey); - notifyListeners(); + safeNotifyListeners(); } } @@ -975,11 +976,11 @@ class DownloadProvider extends ChangeNotifier { _metadata.remove(globalKey); _artworkPaths.remove(globalKey); - notifyListeners(); + safeNotifyListeners(); } catch (e) { // Remove from deletion tracking on error _deletionProgress.remove(globalKey); - notifyListeners(); + safeNotifyListeners(); rethrow; } } @@ -993,7 +994,7 @@ class DownloadProvider extends ChangeNotifier { // Update progress _deletionProgress[progress.globalKey] = progress; } - notifyListeners(); + safeNotifyListeners(); } /// Get deletion progress for an item @@ -1038,7 +1039,7 @@ class DownloadProvider extends ChangeNotifier { if (updatedCount > 0) { appLogger.i('Refreshed metadata from cache for $updatedCount items'); - notifyListeners(); + safeNotifyListeners(); } } @@ -1123,7 +1124,7 @@ class DownloadProvider extends ChangeNotifier { final rule = await _database.getSyncRule(globalKey); if (rule != null) { _syncRules[globalKey] = rule; - notifyListeners(); + safeNotifyListeners(); } appLogger.i('Created sync rule: $globalKey ($targetType, filter=$downloadFilter, keep $episodeCount)'); } @@ -1134,7 +1135,7 @@ class DownloadProvider extends ChangeNotifier { final existing = _syncRules[globalKey]; if (existing != null) { _syncRules[globalKey] = existing.copyWith(episodeCount: episodeCount); - notifyListeners(); + safeNotifyListeners(); } appLogger.i('Updated sync rule $globalKey: keep $episodeCount'); } @@ -1145,7 +1146,7 @@ class DownloadProvider extends ChangeNotifier { final existing = _syncRules[globalKey]; if (existing != null) { _syncRules[globalKey] = existing.copyWith(downloadFilter: downloadFilter); - notifyListeners(); + safeNotifyListeners(); } appLogger.i('Updated sync rule $globalKey: filter=$downloadFilter'); } @@ -1156,7 +1157,7 @@ class DownloadProvider extends ChangeNotifier { final existing = _syncRules[globalKey]; if (existing != null) { _syncRules[globalKey] = existing.copyWith(enabled: enabled); - notifyListeners(); + safeNotifyListeners(); } appLogger.i('${enabled ? 'Enabled' : 'Disabled'} sync rule: $globalKey'); } @@ -1165,7 +1166,7 @@ class DownloadProvider extends ChangeNotifier { Future deleteSyncRule(String globalKey) async { await _database.deleteSyncRule(globalKey); _syncRules.remove(globalKey); - notifyListeners(); + safeNotifyListeners(); appLogger.i('Deleted sync rule: $globalKey'); } diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 64ea08df..3c39c0e9 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -18,6 +18,10 @@ class LibrariesProvider extends ChangeNotifier { LibrariesLoadState _loadState = LibrariesLoadState.initial; String? _errorMessage; + /// Coalesces concurrent `loadLibraries()` calls so two simultaneous callers + /// see the same in-flight result instead of racing two separate fetches. + Future? _inFlightLoad; + /// Unmodifiable list of all libraries (filtered for supported types, ordered) List get libraries => List.unmodifiable(_libraries); @@ -44,7 +48,11 @@ class LibrariesProvider extends ChangeNotifier { /// Load libraries from all connected servers. /// Filters out music libraries and applies saved ordering. - Future loadLibraries() async { + Future loadLibraries() { + return _inFlightLoad ??= _loadLibrariesInternal().whenComplete(() => _inFlightLoad = null); + } + + Future _loadLibrariesInternal() async { if (_aggregationService == null) { appLogger.w('LibrariesProvider: Cannot load libraries - not initialized'); return; diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index c2461a2f..9af1bf18 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/livetv_dvr.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; import '../services/plex_client.dart'; import '../services/data_aggregation_service.dart'; import '../services/multi_server_manager.dart'; @@ -23,7 +24,7 @@ class LiveTvServerInfo { /// Provider for multi-server Plex connections /// Manages multiple PlexClient instances and provides data aggregation -class MultiServerProvider extends ChangeNotifier { +class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMixin { final MultiServerManager _serverManager; final DataAggregationService _aggregationService; StreamSubscription? _statusSubscription; @@ -46,7 +47,7 @@ class MultiServerProvider extends ChangeNotifier { final hasNewServer = currentOnline.any((id) => !_previousOnlineServerIds.contains(id)); _previousOnlineServerIds = currentOnline; - notifyListeners(); + safeNotifyListeners(); // Only re-check live TV when a new server came online if (hasNewServer) { @@ -90,7 +91,7 @@ class MultiServerProvider extends ChangeNotifier { void clearAllConnections() { _serverManager.disconnectAll(); appLogger.d('MultiServerProvider: All connections cleared'); - notifyListeners(); + safeNotifyListeners(); } /// Reconnect all servers after a profile switch @@ -104,7 +105,7 @@ class MultiServerProvider extends ChangeNotifier { final connectedCount = await _serverManager.connectToAllServers(servers, clientIdentifier: clientIdentifier); appLogger.i('MultiServerProvider: Reconnected to $connectedCount/${servers.length} servers after profile switch'); - notifyListeners(); + safeNotifyListeners(); return connectedCount; } @@ -142,7 +143,7 @@ class MultiServerProvider extends ChangeNotifier { // Notify when availability changes OR when the server set changes if (hadLiveTv != _hasLiveTv || !oldServerIds.containsAll(newServerIds) || !newServerIds.containsAll(oldServerIds)) { - notifyListeners(); + safeNotifyListeners(); } } diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index f5a8fd82..2affff20 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import '../mixins/disposable_change_notifier_mixin.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; /// Tracks offline mode status based on network connectivity and server reachability. -class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource { +class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMixin implements OfflineModeSource { final MultiServerManager _serverManager; StreamSubscription>? _connectivitySubscription; @@ -61,7 +62,7 @@ class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource { _hasNetworkConnection = !results.contains(ConnectivityResult.none); if (wasOffline != isOffline) { - notifyListeners(); + safeNotifyListeners(); } }, onError: (e) { @@ -81,17 +82,17 @@ class OfflineModeProvider extends ChangeNotifier implements OfflineModeSource { _hasServerConnection = statusMap.values.any((isOnline) => isOnline); if (wasOffline != isOffline) { - notifyListeners(); + safeNotifyListeners(); } }); - notifyListeners(); + safeNotifyListeners(); } /// Force a refresh of connectivity status Future refresh() async { await _updateConnectionFlags(); - notifyListeners(); + safeNotifyListeners(); } @override diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 88a1f9f2..c3e9c931 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1368,7 +1368,7 @@ class _DiscoverScreenState extends State child: Builder( builder: (context) { if (heroClient == null) { - return Container(color: Theme.of(context).colorScheme.surfaceContainerHighest); + return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest); } final mediaQuery = MediaQuery.of(context); final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); @@ -1388,9 +1388,9 @@ class _DiscoverScreenState extends State cacheManager: PlexImageCacheManager.instance, fit: BoxFit.cover, placeholder: (context, url) => - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), errorWidget: (context, url, error) => - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), ), ); }, @@ -1399,7 +1399,7 @@ class _DiscoverScreenState extends State ), ) else - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), // Gradient Overlay - blends into scaffold background Positioned( diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 100c8efe..e02b58fb 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -75,13 +75,11 @@ mixin FocusableDetailScreenMixin on State, GridFocu isAppBarFocused = false; }); - if (targetIndex == 0) { - firstItemFocusNode.requestFocus(); - } else { - getGridItemFocusNode(targetIndex, prefix: 'detail_grid_item').requestFocus(); - } + _focusNodeForIndex(targetIndex).requestFocus(); } + 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 to [scrollController]. Callers build the slivers themselves @@ -167,9 +165,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - final focusNode = index == 0 - ? firstItemFocusNode - : getGridItemFocusNode(index, prefix: 'detail_grid_item'); + final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( key: Key(item.ratingKey), @@ -203,9 +199,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu itemBuilder: (context, index) { final item = items[index]; final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); - final focusNode = index == 0 - ? firstItemFocusNode - : getGridItemFocusNode(index, prefix: 'detail_grid_item'); + final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( key: Key(item.ratingKey), diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 30f6eeaf..b463374b 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -71,13 +71,11 @@ class _HubDetailScreenState extends State isAppBarFocused = false; }); - if (targetIndex == 0) { - firstItemFocusNode.requestFocus(); - } else { - getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus(); - } + _focusNodeForIndex(targetIndex).requestFocus(); } + FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'hub_detail_item'); + /// Get the correct PlexClient for this hub's server PlexClient _getClientForHub() { return context.getClientForServer(widget.hub.serverId!); @@ -342,9 +340,7 @@ class _HubDetailScreenState extends State itemCount: _filteredItems.length, itemBuilder: (context, index) { final item = _filteredItems[index]; - final focusNode = index == 0 - ? firstItemFocusNode - : getGridItemFocusNode(index, prefix: 'hub_detail_item'); + final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( focusNode: focusNode, @@ -385,9 +381,7 @@ class _HubDetailScreenState extends State ), delegate: SliverChildBuilderDelegate((context, index) { final item = _filteredItems[index]; - final focusNode = index == 0 - ? firstItemFocusNode - : getGridItemFocusNode(index, prefix: 'hub_detail_item'); + final focusNode = _focusNodeForIndex(index); final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index 4730d052..36bd772b 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -5,6 +6,7 @@ import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; +import '../../services/image_cache_service.dart'; import '../../utils/formatters.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/overlay_sheet.dart'; @@ -159,12 +161,13 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), child: blurArtwork( - Image.network( - widget.posterUrl!, + CachedNetworkImage( + imageUrl: widget.posterUrl!, + cacheManager: PlexImageCacheManager.instance, width: 80, height: 120, fit: BoxFit.cover, - errorBuilder: (_, _, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 72e66cea..9ad8add5 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -139,7 +139,7 @@ class _MainScreenState extends State with RouteAware, WindowListener WidgetsBinding.instance.addObserver(this); - if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + if (PlatformDetector.isDesktopOS()) { windowManager.addListener(this); windowManager.setPreventClose(true); } @@ -465,7 +465,7 @@ class _MainScreenState extends State with RouteAware, WindowListener void dispose() { WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); - if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + if (PlatformDetector.isDesktopOS()) { windowManager.removeListener(this); windowManager.setPreventClose(false); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 75932082..ff6e502d 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -348,8 +348,8 @@ class _MediaDetailScreenState extends State } }); } - } catch (_) { - // Silently fail + } catch (e) { + appLogger.d('Episode cache sync skipped', error: e); } } @@ -608,8 +608,8 @@ class _MediaDetailScreenState extends State if (progress?.status == DownloadStatus.queued) { final currentFile = progress?.currentFile; final tooltip = currentFile != null && currentFile.contains('episodes') - ? 'Queued $currentFile' - : 'Queued'; + ? t.downloads.queuedFilesTooltip(files: currentFile) + : t.downloads.queuedTooltip; return IconButton.filledTonal( onPressed: null, @@ -625,8 +625,8 @@ class _MediaDetailScreenState extends State // Show episode count in tooltip for shows/seasons final currentFile = progress?.currentFile; final tooltip = currentFile != null && currentFile.contains('episodes') - ? 'Downloading $currentFile' - : 'Downloading...'; + ? t.downloads.downloadingFilesTooltip(files: currentFile) + : t.downloads.downloadingTooltip; return IconButton.filledTonal( onPressed: null, @@ -2182,7 +2182,8 @@ class _MediaDetailScreenState extends State _episodes = episodeLists.expand((e) => e).toList(); _isLoadingEpisodes = false; }); - } catch (_) { + } catch (e, st) { + appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st); setStateIfMounted(() => _isLoadingEpisodes = false); } } diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 7b676356..01c3d59e 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../mixins/context_menu_tap_mixin.dart'; import '../../services/plex_client.dart'; import '../../models/plex_metadata.dart'; import '../../utils/formatters.dart'; @@ -42,18 +43,7 @@ class PlaylistItemCard extends StatefulWidget { State createState() => _PlaylistItemCardState(); } -class _PlaylistItemCardState extends State { - final _contextMenuKey = GlobalKey(); - Offset? _tapPosition; - - void _storeTapPosition(TapDownDetails details) { - _tapPosition = details.globalPosition; - } - - void _showContextMenu() { - _contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition); - } - +class _PlaylistItemCardState extends State with ContextMenuTapMixin { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -80,7 +70,7 @@ class _PlaylistItemCardState extends State { } return MediaContextMenu( - key: _contextMenuKey, + key: contextMenuKey, item: widget.item, onRefresh: widget.onRefresh, onTap: widget.onTap, @@ -90,10 +80,10 @@ class _PlaylistItemCardState extends State { shape: cardShape, child: InkWell( onTap: widget.onTap, - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, child: Padding( padding: const EdgeInsets.all(8.0), child: Row( diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a01c5b52..6e07933f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -741,7 +741,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } // Audio passthrough (desktop only - sends bitstream to receiver) - if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + if (PlatformDetector.isDesktopOS()) { if (settingsService.getAudioPassthrough()) { await player!.setAudioPassthrough(true); } @@ -1030,7 +1030,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Set MPV video-sync mode for smoother playback when display is synced try { await player!.setProperty('video-sync', 'display-tempo'); - } catch (_) {} + } catch (e) { + appLogger.d('video-sync property unsupported', error: e); + } if (mounted && player != null) { await player!.play(); @@ -1619,14 +1621,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin final partId = _currentMediaInfo!.partId!; final client = _getClientForMetadata(context); final service = BifThumbnailService(); - service.load(client, partId).then((_) { - // Guard against media having changed while the download was in flight - if (mounted && _currentMediaInfo?.partId == partId) { - setState(() => _bifService = service); - } else { - service.dispose(); - } - }); + unawaited( + service + .load(client, partId) + .then((_) { + // Guard against media having changed while the download was in flight + if (mounted && _currentMediaInfo?.partId == partId) { + setState(() => _bifService = service); + } else { + service.dispose(); + } + }) + .catchError((e, st) { + appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st); + service.dispose(); + }), + ); } await _initVideoFilterAndPip(); @@ -1716,7 +1726,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // MPV video-sync tuning (no-op on ExoPlayer). try { await player!.setProperty('video-sync', 'display-tempo'); - } catch (_) {} + } catch (e) { + appLogger.d('video-sync property unsupported on this player', error: e); + } } catch (e) { appLogger.w('Failed to apply pre-playback frame rate matching', error: e); } @@ -2167,7 +2179,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { _companionRemoteProvider = context.read(); _companionRemoteProvider!.sendCommand(RemoteCommandType.syncState, data: {'playerActive': true}); - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote provider unavailable', error: e); + } } void _cleanupCompanionRemoteCallbacks() { @@ -3313,7 +3327,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { playbackState.setCurrentItem(episodeMetadata); - } catch (_) {} + } catch (e) { + appLogger.d('playbackState.setCurrentItem failed', error: e); + } await _loadAdjacentEpisodes(); @@ -3931,7 +3947,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin valueListenable: _isExiting, builder: (context, isExiting, child) { if (!isExiting) return const SizedBox.shrink(); - return Positioned.fill(child: Container(color: Colors.black)); + return const Positioned.fill(child: ColoredBox(color: Colors.black)); }, ), ], diff --git a/lib/services/ambient_lighting_service.dart b/lib/services/ambient_lighting_service.dart index bab4dc74..13fb9d12 100644 --- a/lib/services/ambient_lighting_service.dart +++ b/lib/services/ambient_lighting_service.dart @@ -1,10 +1,10 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../mpv/player/player.dart'; +import '../utils/app_logger.dart'; /// Generates and manages an ambient lighting GLSL shader that fills letterbox/pillarbox /// bars with a blurred, dimmed version of the video edges. @@ -40,9 +40,7 @@ class AmbientLightingService { // Write static shader (only needs to happen once) _shaderPath ??= await _writeShaderToTemp(_generateShader()); - if (kDebugMode) { - debugPrint('AmbientLightingService: Shader path: $_shaderPath'); - } + appLogger.d('AmbientLightingService: Shader path: $_shaderPath'); // Set video-aspect-override to fill the entire output area await _player.setProperty('video-aspect-override', outputAspect.toString()); @@ -52,13 +50,9 @@ class AmbientLightingService { _enabled = true; - if (kDebugMode) { - debugPrint('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)'); - } - } catch (e) { - if (kDebugMode) { - debugPrint('AmbientLightingService: Failed to enable: $e'); - } + appLogger.d('AmbientLightingService: Enabled (video=$videoAspect, output=$outputAspect)'); + } catch (e, st) { + appLogger.w('AmbientLightingService: Failed to enable', error: e, stackTrace: st); } } @@ -75,13 +69,9 @@ class AmbientLightingService { _enabled = false; - if (kDebugMode) { - debugPrint('AmbientLightingService: Disabled'); - } - } catch (e) { - if (kDebugMode) { - debugPrint('AmbientLightingService: Failed to disable: $e'); - } + appLogger.d('AmbientLightingService: Disabled'); + } catch (e, st) { + appLogger.w('AmbientLightingService: Failed to disable', error: e, stackTrace: st); } } diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 179c57b8..0cbc9fed 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -546,7 +546,9 @@ class CompanionRemotePeerService with KeepaliveMixin { if (_channel != null) { try { await _channel!.sink.close(); - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote: channel close on timeout failed', error: e); + } _channel = null; } throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session'); @@ -591,7 +593,9 @@ class CompanionRemotePeerService with KeepaliveMixin { for (final ch in channels) { try { ch.sink.close(); - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote: race-loser close ignored', error: e); + } } } @@ -610,7 +614,9 @@ class CompanionRemotePeerService with KeepaliveMixin { appLogger.d('CompanionRemote: Race winner: $address'); completer.complete(address); } - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote: race message parse skipped', error: e); + } }, onError: (_) {}, onDone: () {}, @@ -762,14 +768,18 @@ class CompanionRemotePeerService with KeepaliveMixin { if (_clientSocket != null) { try { await _clientSocket!.close(); - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote: client socket close ignored', error: e); + } _clientSocket = null; } if (_channel != null) { try { await _channel!.sink.close(); - } catch (_) {} + } catch (e) { + appLogger.d('CompanionRemote: channel close ignored', error: e); + } _channel = null; } diff --git a/lib/services/companion_remote/lan_discovery_service.dart b/lib/services/companion_remote/lan_discovery_service.dart index 5e624bd1..b89e2d90 100644 --- a/lib/services/companion_remote/lan_discovery_service.dart +++ b/lib/services/companion_remote/lan_discovery_service.dart @@ -42,6 +42,7 @@ class LanDiscoveryService { // Listener state (client) RawDatagramSocket? _listenSocket; + StreamSubscription? _listenSubscription; Timer? _staleCleanupTimer; final Map _discoveredHosts = {}; final _hostsController = StreamController>.broadcast(); @@ -175,7 +176,7 @@ class LanDiscoveryService { appLogger.d('LanDiscovery: Listening on port $discoveryPort'); - _listenSocket!.listen((RawSocketEvent event) { + _listenSubscription = _listenSocket!.listen((RawSocketEvent event) { if (event == RawSocketEvent.read) { final datagram = _listenSocket?.receive(); if (datagram != null) { @@ -269,6 +270,8 @@ class LanDiscoveryService { void _stopListeningInternal() { _staleCleanupTimer?.cancel(); _staleCleanupTimer = null; + _listenSubscription?.cancel(); + _listenSubscription = null; _listenSocket?.close(); _listenSocket = null; appLogger.d('LanDiscovery: Listening stopped'); diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index 75645558..5937d078 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:dart_discord_presence/dart_discord_presence.dart'; import 'package:http/http.dart' as http; @@ -7,6 +6,7 @@ import 'package:http/http.dart' as http; import '../models/plex_metadata.dart'; import '../utils/app_logger.dart'; import '../utils/future_extensions.dart'; +import '../utils/platform_detector.dart'; import '../utils/plex_http_client.dart'; import 'plex_client.dart'; import 'settings_service.dart'; @@ -59,7 +59,7 @@ class DiscordRPCService { /// Check if Discord RPC is available on this platform static bool get isAvailable { - if (!Platform.isMacOS && !Platform.isWindows && !Platform.isLinux) { + if (!PlatformDetector.isDesktopOS()) { return false; } return DiscordRPC.isAvailable; @@ -238,7 +238,9 @@ class DiscordRPCService { _errorSubscription = null; try { _rpc?.dispose(); - } catch (_) {} + } catch (e) { + appLogger.d('DiscordRPC: dispose ignored', error: e); + } _rpc = null; _scheduleReconnect(); } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index d1c49f5c..3cbfa0a0 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -305,12 +305,19 @@ class DownloadManagerService { // Attempt deferred supplementary downloads for recovered items _processPendingSupplementaryDownloads(client); - _database.getNextQueueItem().then((item) { - if (item != null) { - appLogger.i('Resuming queued downloads after app restart'); - _processQueue(client); - } - }); + unawaited( + _database + .getNextQueueItem() + .then((item) { + if (item != null) { + appLogger.i('Resuming queued downloads after app restart'); + _processQueue(client); + } + }) + .catchError((e, st) { + appLogger.e('Failed to resume queued downloads', error: e, stackTrace: st); + }), + ); } /// Attempt supplementary downloads (artwork, subtitles) for items that were diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 1a446507..dbe4108e 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart'; import '../utils/app_logger.dart'; import '../utils/key_event_simulator.dart' as key_sim; +import '../utils/platform_detector.dart'; /// Service that bridges gamepad input to Flutter's focus navigation system. /// @@ -62,7 +63,7 @@ class GamepadService with WindowListener { /// Start listening to gamepad events. /// Only active on desktop platforms (macOS, Windows, Linux). - static bool get _isDesktop => Platform.isMacOS || Platform.isWindows || Platform.isLinux; + static bool get _isDesktop => PlatformDetector.isDesktopOS(); void start() async { appLogger.i('GamepadService: Starting on ${Platform.operatingSystem}'); diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 8543c02b..19407a70 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -237,10 +237,17 @@ class MultiServerManager { appLogger.i('Successfully connected to ${server.name}'); // Fire-and-forget: fetch server prefs and cache watched threshold - client.fetchServerPrefs().then((_) { - final threshold = client.watchedThresholdPercent; - SettingsService.instanceOrNull?.setWatchedThreshold(serverId, threshold); - }); + unawaited( + client + .fetchServerPrefs() + .then((_) { + final threshold = client.watchedThresholdPercent; + SettingsService.instanceOrNull?.setWatchedThreshold(serverId, threshold); + }) + .catchError((Object e, StackTrace st) { + appLogger.w('fetchServerPrefs failed for ${server.name}', error: e, stackTrace: st); + }), + ); return serverId; } on TimeoutException { diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index b153698a..5b1a1ec2 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -191,13 +191,13 @@ class OfflineWatchSyncService extends ChangeNotifier { /// /// Removes any conflicting actions for the same item. Future queueMarkWatched({required String serverId, required String ratingKey}) => - _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'watched'); + _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.watched.name); /// Queue a manual "mark as unwatched" action. /// /// Removes any conflicting actions for the same item. Future queueMarkUnwatched({required String serverId, required String ratingKey}) => - _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: 'unwatched'); + _queueWatchStatusAction(serverId: serverId, ratingKey: ratingKey, actionType: OfflineActionType.unwatched.name); /// Internal helper to queue watch/unwatch actions. Future _queueWatchStatusAction({ @@ -281,7 +281,7 @@ class OfflineWatchSyncService extends ChangeNotifier { if (action == null) return null; // Only return offset for progress actions - if (action.actionType == 'progress') { + if (action.actionType == OfflineActionType.progress.name) { return action.viewOffset; } @@ -402,9 +402,9 @@ class OfflineWatchSyncService extends ChangeNotifier { // listeners (UI invalidation, Trakt sync). Best-effort: a missed metadata // fetch only suppresses the event, not the Plex API call. final emitsEvent = - action.actionType == 'watched' || - action.actionType == 'unwatched' || - (action.actionType == 'progress' && action.shouldMarkWatched); + action.actionType == OfflineActionType.watched.name || + action.actionType == OfflineActionType.unwatched.name || + (action.actionType == OfflineActionType.progress.name && action.shouldMarkWatched); PlexMetadata? metadata; if (emitsEvent) { try { @@ -488,7 +488,9 @@ class OfflineWatchSyncService extends ChangeNotifier { if (existingMeta['Media'] == null) { try { await client.getMetadataWithImages(episode.ratingKey); - } catch (_) {} + } catch (e) { + appLogger.d('Cache repair fetch skipped for ${episode.ratingKey}', error: e); + } } } else { // No existing entry — write what we have diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 60952ffa..fc093651 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -80,7 +80,11 @@ class PlexAuthService { } Future _getUser(String authToken) { - return _http.get('$_plexApiBase/user', headers: _getCommonHeaders(authToken: authToken)); + return _http.get( + '$_plexApiBase/user', + headers: _getCommonHeaders(authToken: authToken), + timeout: ConnectionTimeouts.plexTvReceive, + ); } void _checkStatus(PlexResponse response) => throwIfHttpError(response); @@ -97,7 +101,11 @@ class PlexAuthService { /// Create a PIN for authentication Future> createPin() async { - final response = await _http.post('$_plexApiBase/pins?strong=true', headers: _getCommonHeaders()); + final response = await _http.post( + '$_plexApiBase/pins?strong=true', + headers: _getCommonHeaders(), + timeout: ConnectionTimeouts.plexTvReceive, + ); _checkStatus(response); return response.data as Map; } @@ -116,7 +124,11 @@ class PlexAuthService { /// Poll the PIN to check if it has been claimed Future checkPin(int pinId) async { try { - final response = await _http.get('$_plexApiBase/pins/$pinId', headers: _getCommonHeaders()); + final response = await _http.get( + '$_plexApiBase/pins/$pinId', + headers: _getCommonHeaders(), + timeout: ConnectionTimeouts.plexTvReceive, + ); final data = response.data as Map; return data['authToken'] as String?; @@ -125,16 +137,20 @@ class PlexAuthService { } } - /// Poll the PIN until it's claimed or timeout + /// Poll the PIN until it's claimed or timeout. + /// + /// Uses an exponential backoff (1s → 2s → 4s, capped at 5s) so a stalled + /// claim doesn't hammer plex.tv every second for two minutes. Future pollPinUntilClaimed( int pinId, { Duration timeout = const Duration(minutes: 2), bool Function()? shouldCancel, }) async { final endTime = DateTime.now().add(timeout); + var backoff = const Duration(seconds: 1); + const maxBackoff = Duration(seconds: 5); while (DateTime.now().isBefore(endTime)) { - // Check if polling should be cancelled if (shouldCancel != null && shouldCancel()) { return null; } @@ -144,8 +160,9 @@ class PlexAuthService { return token; } - // Wait 1 second before polling again - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(backoff); + final next = backoff * 2; + backoff = next > maxBackoff ? maxBackoff : next; } return null; // Timeout diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 981653b1..e5b65fb2 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2541,7 +2541,9 @@ class PlexClient { } else { programs.add(LiveTvProgram.fromJson(map)); } - } catch (_) {} + } catch (e, st) { + appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st); + } } return programs; } diff --git a/lib/services/saf_storage_service.dart b/lib/services/saf_storage_service.dart index 837ddda2..1dc74fa3 100644 --- a/lib/services/saf_storage_service.dart +++ b/lib/services/saf_storage_service.dart @@ -1,7 +1,7 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:saf_util/saf_util.dart'; +import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; @@ -27,7 +27,7 @@ class SafStorageService { final doc = await _safUtil.pickDirectory(writePermission: true, persistablePermission: true); return doc?.uri; } catch (e) { - debugPrint('SAF pickDirectory error: $e'); + appLogger.w('SAF pickDirectory error', error: e); return null; } } @@ -40,7 +40,7 @@ class SafStorageService { final result = await _safUtil.mkdirp(parentUri, [name]); return result.uri; } catch (e) { - debugPrint('SAF createDirectory error: $e'); + appLogger.w('SAF createDirectory error', error: e); return null; } } @@ -53,7 +53,7 @@ class SafStorageService { try { return await _safUtil.child(parentUri, names); } catch (e) { - debugPrint('SAF getChild error: $e'); + appLogger.w('SAF getChild error', error: e); return null; } } @@ -66,7 +66,7 @@ class SafStorageService { final result = await _safUtil.mkdirp(parentUri, pathComponents); return result.uri; } catch (e) { - debugPrint('SAF createNestedDirectories error: $e'); + appLogger.w('SAF createNestedDirectories error', error: e); return null; } } @@ -78,7 +78,7 @@ class SafStorageService { await _safUtil.delete(uri, isDir); return true; } catch (e) { - debugPrint('SAF delete error: $e'); + appLogger.w('SAF delete error', error: e); return false; } } @@ -89,7 +89,7 @@ class SafStorageService { try { return await _safUtil.exists(uri, isDir); } catch (e) { - debugPrint('SAF exists error: $e'); + appLogger.w('SAF exists error', error: e); return false; } } @@ -101,7 +101,7 @@ class SafStorageService { try { return await _safUtil.list(uri); } catch (e) { - debugPrint('SAF list error: $e'); + appLogger.w('SAF list error', error: e); return null; } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index a9a09ccb..1b80c9dd 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1063,7 +1063,9 @@ class SettingsService extends BaseSharedPreferencesService { final migrated = lines.join('\n'); prefs.setString(_keyMpvConfigText, migrated); return migrated; - } catch (_) {} + } catch (e, st) { + appLogger.w('SettingsService: failed to migrate mpv config', error: e, stackTrace: st); + } } return ''; @@ -1242,8 +1244,7 @@ class SettingsService extends BaseSharedPreferencesService { bool getEnableCompanionRemoteServer() { // Default enabled on desktop/TV, disabled on mobile - return prefs.getBool(_keyEnableCompanionRemoteServer) ?? - (Platform.isMacOS || Platform.isWindows || Platform.isLinux); + return prefs.getBool(_keyEnableCompanionRemoteServer) ?? (PlatformDetector.isDesktopOS()); } // Auto Picture-in-Picture (Android & iOS) diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index 0f31e107..7f470c6f 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -1,11 +1,11 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../models/shader_preset.dart'; +import '../utils/app_logger.dart'; /// Utility class for loading GLSL shader assets for MPV video enhancement. /// @@ -70,10 +70,8 @@ class ShaderAssetLoader { } return targetFile.path; - } catch (e) { - if (kDebugMode) { - debugPrint('Failed to extract shader $assetPath: $e'); - } + } catch (e, st) { + appLogger.w('Failed to extract shader $assetPath', error: e, stackTrace: st); return null; } } @@ -245,10 +243,8 @@ class ShaderAssetLoader { for (final shaderPath in _anime4kShaders.values) { await _extractShader(shaderPath); } - } catch (e) { - if (kDebugMode) { - debugPrint('Failed to preload shaders: $e'); - } + } catch (e, st) { + appLogger.w('Failed to preload shaders', error: e, stackTrace: st); } } diff --git a/lib/services/shader_service.dart b/lib/services/shader_service.dart index 416bc076..0a79c0c7 100644 --- a/lib/services/shader_service.dart +++ b/lib/services/shader_service.dart @@ -1,7 +1,6 @@ -import 'package:flutter/foundation.dart'; - import '../models/shader_preset.dart'; import '../mpv/player/player.dart'; +import '../utils/app_logger.dart'; import 'ambient_lighting_service.dart'; import 'shader_asset_loader.dart'; @@ -31,9 +30,7 @@ class ShaderService { /// and skip shader application for HDR content. Future applyPreset(ShaderPreset preset) async { if (!isSupported) { - if (kDebugMode) { - debugPrint('ShaderService: Shaders not supported on ${_player.playerType}'); - } + appLogger.d('ShaderService: Shaders not supported on ${_player.playerType}'); return; } @@ -42,9 +39,7 @@ class ShaderService { if (preset.type == ShaderPresetType.nvscaler && preset.nvscalerConfig?.autoHdrSkip == true) { final isHdr = await _isHdrContent(); if (isHdr) { - if (kDebugMode) { - debugPrint('ShaderService: Skipping NVScaler on HDR content'); - } + appLogger.d('ShaderService: Skipping NVScaler on HDR content'); await _clearShaders(); _currentPreset = ShaderPreset.none; await _reappendAmbientLighting(); @@ -76,13 +71,9 @@ class ShaderService { // Re-append ambient lighting shader at end of chain await _reappendAmbientLighting(); - if (kDebugMode) { - debugPrint('ShaderService: Applied ${preset.name} with ${shaderPaths.length} shaders'); - } - } catch (e) { - if (kDebugMode) { - debugPrint('ShaderService: Failed to apply preset: $e'); - } + appLogger.d('ShaderService: Applied ${preset.name} with ${shaderPaths.length} shaders'); + } catch (e, st) { + appLogger.w('ShaderService: Failed to apply preset', error: e, stackTrace: st); // Don't rethrow - shader failure shouldn't stop playback } } @@ -91,10 +82,8 @@ class ShaderService { Future _clearShaders() async { try { await _player.command(['change-list', 'glsl-shaders', 'clr', '']); - } catch (e) { - if (kDebugMode) { - debugPrint('ShaderService: Failed to clear shaders: $e'); - } + } catch (e, st) { + appLogger.w('ShaderService: Failed to clear shaders', error: e, stackTrace: st); } } @@ -106,10 +95,8 @@ class ShaderService { try { await service.reappendShader(); - } catch (e) { - if (kDebugMode) { - debugPrint('ShaderService: Failed to re-append ambient lighting: $e'); - } + } catch (e, st) { + appLogger.w('ShaderService: Failed to re-append ambient lighting', error: e, stackTrace: st); } } @@ -136,9 +123,7 @@ class ShaderService { return false; } catch (e) { - if (kDebugMode) { - debugPrint('ShaderService: HDR detection failed: $e'); - } + appLogger.d('ShaderService: HDR detection failed', error: e); return false; } } diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index a38532e1..71ddf5fb 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../mpv/mpv.dart'; import '../services/pip_service.dart'; +import '../utils/app_logger.dart'; /// Manages video Picture-in-Picture mode class VideoPIPManager { @@ -36,7 +37,9 @@ class VideoPIPManager { width = int.tryParse(dwidth); height = int.tryParse(dheight); } - } catch (_) {} + } catch (e) { + appLogger.d('VideoPipManager: dwidth/dheight unavailable', error: e); + } if (width == null || height == null) { try { @@ -46,7 +49,9 @@ class VideoPIPManager { width = int.tryParse(videoWidth); height = int.tryParse(videoHeight); } - } catch (_) {} + } catch (e) { + appLogger.d('VideoPipManager: width/height unavailable', error: e); + } } width ??= _playerSize?.width.toInt(); diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 26272bce..49e2767b 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -98,25 +98,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { margin: EdgeInsets.zero, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))), ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: c.text.withValues(alpha: 0.08), - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - borderSide: BorderSide.none, - ), - hintStyle: TextStyle(color: c.textMuted), - ), + inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted), elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), filledButtonTheme: FilledButtonThemeData(style: buttonStyle), sliderTheme: SliderThemeData( @@ -179,3 +161,22 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { ], ); } + +/// Brighter fill on focus so input focus is visible inside TV overscan. +InputDecorationTheme _inputDecorationTheme(Color text, Color textMuted) { + final unfocusedFill = text.withValues(alpha: 0.08); + final focusedFill = text.withValues(alpha: 0.18); + const border = OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide.none); + return InputDecorationTheme( + filled: true, + fillColor: WidgetStateColor.resolveWith( + (states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill, + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: border, + enabledBorder: border, + focusedBorder: border, + hintStyle: TextStyle(color: textMuted), + ); +} diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 1f0e8376..77945aa0 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -42,6 +42,15 @@ class ScreenBreakpoints { static bool isWideTabletOrLarger(double width) => width >= wideTablet; } +/// Animation and notification durations. +class AppDurations { + static const Duration animFast = Duration(milliseconds: 200); + static const Duration animMedium = Duration(milliseconds: 300); + static const Duration animSlow = Duration(milliseconds: 500); + static const Duration snackBarDefault = Duration(seconds: 3); + static const Duration snackBarLong = Duration(seconds: 4); +} + /// Grid layout constants class GridLayoutConstants { /// Default aspect ratio for media card grid cells (poster + text) diff --git a/lib/utils/navigation_transitions.dart b/lib/utils/navigation_transitions.dart index b729af77..519caa71 100644 --- a/lib/utils/navigation_transitions.dart +++ b/lib/utils/navigation_transitions.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'layout_constants.dart'; + Route fadeRoute(Widget page) { return PageRouteBuilder( opaque: false, pageBuilder: (context, animation, secondaryAnimation) => page, transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), - transitionDuration: const Duration(milliseconds: 500), - reverseTransitionDuration: const Duration(milliseconds: 500), + transitionDuration: AppDurations.animSlow, + reverseTransitionDuration: AppDurations.animSlow, ); } diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/plex_image_helper.dart index 4f84165a..4a2a90fa 100644 --- a/lib/utils/plex_image_helper.dart +++ b/lib/utils/plex_image_helper.dart @@ -249,4 +249,40 @@ class PlexImageHelper { return true; } + + /// Optimized URL for hero/background art ([ImageType.art]). + static String heroArtUrl({ + required PlexClient? client, + required String? thumbPath, + required BuildContext context, + required double containerWidth, + required double containerHeight, + }) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.art); + + /// Optimized URL for clear-logo overlays ([ImageType.logo]). + static String logoUrl({ + required PlexClient? client, + required String? thumbPath, + required BuildContext context, + required double containerWidth, + required double containerHeight, + }) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo); + + static String _typedUrl( + PlexClient? client, + String? thumbPath, + BuildContext context, + double containerWidth, + double containerHeight, + ImageType type, + ) { + return getOptimizedImageUrl( + client: client, + thumbPath: thumbPath, + maxWidth: containerWidth, + maxHeight: containerHeight, + devicePixelRatio: effectiveDevicePixelRatio(context), + imageType: type, + ); + } } diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 6db1ce23..8e6d203d 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -18,86 +18,71 @@ extension ProviderExtensions on BuildContext { // Direct profile settings access (nullable) PlexUserProfile? get profileSettings => userProfile.profileSettings; - /// Get PlexClient for a specific server ID - /// Throws an exception if no client is available for the given serverId - PlexClient getClientForServer(String serverId) { - final multiServerProvider = Provider.of(this, listen: false); + /// Internal: resolve a [PlexClient] from a serverId or fall back to the + /// first online server. Returns null if neither yields a client. + PlexClient? _resolveClient(String? serverId) { + final provider = Provider.of(this, listen: false); + if (serverId != null) { + final client = provider.getClientForServer(serverId); + if (client != null) return client; + } + final fallbackId = provider.onlineServerIds.firstOrNull; + if (fallbackId == null) return null; + return provider.getClientForServer(fallbackId); + } - final serverClient = multiServerProvider.getClientForServer(serverId); - - if (serverClient == null) { - appLogger.e('No client found for server $serverId'); + /// Internal: like [_resolveClient] but throws a localized exception when + /// no client is available. The thrown message is the canonical + /// `t.errors.noClientAvailable` so callers can surface it directly. + PlexClient _requireClient(String? serverId, {bool fallback = true}) { + final provider = Provider.of(this, listen: false); + if (serverId != null) { + final client = provider.getClientForServer(serverId); + if (client != null) return client; + if (!fallback) { + appLogger.e('No client found for server $serverId'); + throw Exception(t.errors.noClientAvailable); + } + } + final fallbackId = provider.onlineServerIds.firstOrNull; + final client = fallbackId == null ? null : provider.getClientForServer(fallbackId); + if (client == null) { throw Exception(t.errors.noClientAvailable); } - - return serverClient; + return client; } + /// Get PlexClient for a specific server ID. Throws if unavailable. + PlexClient getClientForServer(String serverId) => _requireClient(serverId, fallback: false); + /// Get PlexClient for a specific server ID, or null if unavailable. PlexClient? tryGetClientForServer(String? serverId) { if (serverId == null) return null; - final multiServerProvider = Provider.of(this, listen: false); - return multiServerProvider.getClientForServer(serverId); + final provider = Provider.of(this, listen: false); + return provider.getClientForServer(serverId); } - /// Get PlexClient for a library - /// Throws an exception if no client is available - PlexClient getClientForLibrary(PlexLibrary library) { - // If library doesn't have a serverId, fall back to first available server - if (library.serverId == null) { - final multiServerProvider = Provider.of(this, listen: false); - final serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) { - throw Exception(t.errors.noClientAvailable); - } - return getClientForServer(serverId); - } - return getClientForServer(library.serverId!); - } + /// Get PlexClient for a library, falling back to the first online server + /// when the library has no serverId. Throws if no client is available. + PlexClient getClientForLibrary(PlexLibrary library) => _requireClient(library.serverId); - /// Get PlexClient for metadata, with fallback to first available server - /// Throws an exception if no servers are available - PlexClient getClientForMetadata(PlexMetadata metadata) { - if (metadata.serverId != null) { - return getClientForServer(metadata.serverId!); - } - return getFirstAvailableClient(); - } + /// Get PlexClient for metadata, falling back to the first online server. + /// Throws if no client is available. + PlexClient getClientForMetadata(PlexMetadata metadata) => _requireClient(metadata.serverId); - /// Get PlexClient for metadata, or null if offline mode or no serverId - /// Use this for screens that support offline mode + /// Get PlexClient for metadata, or null in offline mode / when no serverId. PlexClient? getClientForMetadataOrNull(PlexMetadata metadata, {bool isOffline = false}) { - if (isOffline || metadata.serverId == null) { - return null; - } + if (isOffline) return null; return tryGetClientForServer(metadata.serverId); } - /// Get the first available client from connected servers - /// Throws an exception if no servers are available - PlexClient getFirstAvailableClient() { - final multiServerProvider = Provider.of(this, listen: false); - final serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) { - throw Exception(t.errors.noClientAvailable); - } - return getClientForServer(serverId); - } + /// Get the first online server's client. Throws if none available. + PlexClient getFirstAvailableClient() => _requireClient(null); - /// Get the first available client, or null if no servers are connected - PlexClient? tryGetFirstAvailableClient() { - final multiServerProvider = Provider.of(this, listen: false); - final serverId = multiServerProvider.onlineServerIds.firstOrNull; - if (serverId == null) return null; - return multiServerProvider.getClientForServer(serverId); - } + /// Get the first online server's client, or null. + PlexClient? tryGetFirstAvailableClient() => _resolveClient(null); - /// Get client for a serverId with fallback to first available server - /// Useful for items that might not have a serverId - PlexClient getClientWithFallback(String? serverId) { - if (serverId != null) { - return getClientForServer(serverId); - } - return getFirstAvailableClient(); - } + /// Get client for a serverId, falling back to the first online server. + /// Throws if no client is available. + PlexClient getClientWithFallback(String? serverId) => _requireClient(serverId); } diff --git a/lib/utils/smart_deletion_handler.dart b/lib/utils/smart_deletion_handler.dart index 5ca7229b..e14c5545 100644 --- a/lib/utils/smart_deletion_handler.dart +++ b/lib/utils/smart_deletion_handler.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../i18n/strings.g.dart'; import '../providers/download_provider.dart'; import '../widgets/deletion_progress_dialog.dart'; @@ -45,10 +46,10 @@ class SmartDeletionHandler { // If no progress, show simple fallback if (progress == null) { - return const AlertDialog( + return AlertDialog( content: Row( mainAxisSize: MainAxisSize.min, - children: [CircularProgressIndicator(), SizedBox(width: 20), Text('Deleting...')], + children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)], ), ); } diff --git a/lib/utils/snackbar_helper.dart b/lib/utils/snackbar_helper.dart index f3b59c1f..6aa75210 100644 --- a/lib/utils/snackbar_helper.dart +++ b/lib/utils/snackbar_helper.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'layout_constants.dart'; + /// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation. final rootScaffoldMessengerKey = GlobalKey(); @@ -31,9 +33,9 @@ void showSnackBar(BuildContext context, String message, {SnackBarType type = Sna if (!context.mounted) return; final (backgroundColor, defaultDuration) = switch (type) { - SnackBarType.info => (null, const Duration(seconds: 3)), - SnackBarType.success => (Colors.green, const Duration(seconds: 3)), - SnackBarType.error => (Colors.red, const Duration(seconds: 4)), + SnackBarType.info => (null, AppDurations.snackBarDefault), + SnackBarType.success => (Colors.green, AppDurations.snackBarDefault), + SnackBarType.error => (Colors.red, AppDurations.snackBarLong), }; ScaffoldMessenger.of(context).showSnackBar( @@ -61,14 +63,14 @@ void showErrorSnackBar(BuildContext context, String message) { /// Shows an error snackbar using the root ScaffoldMessenger (survives navigation). void showGlobalErrorSnackBar(String message) { rootScaffoldMessengerKey.currentState?.showSnackBar( - SnackBar(content: Text(message), backgroundColor: Colors.red, duration: const Duration(seconds: 4)), + SnackBar(content: Text(message), backgroundColor: Colors.red, duration: AppDurations.snackBarLong), ); } /// Shows an info snackbar through the main-screen messenger when available /// (so it floats above the mobile NavigationBar), falling back to the root /// messenger when the main screen is not mounted. -void showMainSnackBar(String message, {Duration duration = const Duration(seconds: 3)}) { +void showMainSnackBar(String message, {Duration duration = AppDurations.snackBarDefault}) { final messenger = mainScaffoldMessengerKey.currentState ?? rootScaffoldMessengerKey.currentState; messenger ?..removeCurrentSnackBar() diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 45be7d9c..1161d9a3 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -232,7 +232,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { } catch (e) { appLogger.e('Failed to create session', error: e); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToCreate}: $e'))); + showErrorSnackBar(context, '${t.watchTogether.failedToCreate}: $e'); } } finally { if (mounted) { @@ -276,7 +276,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { } catch (e) { appLogger.e('Failed to join session', error: e); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToJoin}: $e'))); + showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); } } finally { if (mounted) { @@ -299,7 +299,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> { } catch (e) { appLogger.e('Failed to enter room', error: e); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${t.watchTogether.failedToJoin}: $e'))); + showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); } } finally { if (mounted) { @@ -729,6 +729,6 @@ class _SessionCodeRow extends StatelessWidget { void _copySessionCode(BuildContext context) { Clipboard.setData(ClipboardData(text: sessionId)); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.watchTogether.sessionCodeCopied))); + showSnackBar(context, t.watchTogether.sessionCodeCopied); } } diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index f54cb23c..c4ed1f09 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -242,7 +242,9 @@ class WatchTogetherPeerService with KeepaliveMixin { appLogger.w('WatchTogether: Pong timeout — closing WebSocket'); try { _channel?.sink.close(); - } catch (_) {} + } catch (e) { + appLogger.d('WatchTogether: pong-timeout close ignored', error: e); + } } /// Send a raw JSON map to the relay. @@ -435,7 +437,9 @@ class WatchTogetherPeerService with KeepaliveMixin { try { await _channel?.sink.close(); - } catch (_) {} + } catch (e) { + appLogger.d('WatchTogether: channel close ignored', error: e); + } _channel = null; _connectedPeers.clear(); diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index dc22c077..477a7542 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; @@ -301,7 +302,9 @@ class _ParticipantNotificationOverlayState extends State(); _subscription = provider.participantEvents.listen(_onEvent); - } catch (_) {} + } catch (e) { + appLogger.d('WatchTogetherOverlay: provider unavailable', error: e); + } } void _onEvent(ParticipantEvent event) { diff --git a/lib/widgets/episode_card.dart b/lib/widgets/episode_card.dart index 10b6a96f..994956f5 100644 --- a/lib/widgets/episode_card.dart +++ b/lib/widgets/episode_card.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focus_theme.dart'; import '../focus/focusable_wrapper.dart'; +import '../mixins/context_menu_tap_mixin.dart'; import '../models/download_models.dart'; import '../providers/download_provider.dart'; import '../providers/settings_provider.dart'; @@ -52,18 +53,7 @@ class EpisodeCard extends StatefulWidget { State createState() => _EpisodeCardState(); } -class _EpisodeCardState extends State { - final _contextMenuKey = GlobalKey(); - Offset? _tapPosition; - - void _storeTapPosition(TapDownDetails details) { - _tapPosition = details.globalPosition; - } - - void _showContextMenu() { - _contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition); - } - +class _EpisodeCardState extends State with ContextMenuTapMixin { Widget _buildEpisodeMetaRow(BuildContext context) { final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12); final dot = Padding( @@ -119,10 +109,10 @@ class _EpisodeCardState extends State { enableLongPress: true, onNavigateUp: widget.onNavigateUp, onSelect: widget.onTap, - onLongPress: _showContextMenu, + onLongPress: showContextMenuFromTap, disableScale: true, child: MediaContextMenu( - key: _contextMenuKey, + key: contextMenuKey, item: widget.episode, onRefresh: widget.onRefresh, onListRefresh: widget.onListRefresh, @@ -131,10 +121,10 @@ class _EpisodeCardState extends State { key: Key(widget.episode.ratingKey), borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius), onTap: widget.onTap, - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05), child: Container( decoration: BoxDecoration( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index e92c64a2..d1610e74 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/input_mode_tracker.dart'; +import '../mixins/context_menu_tap_mixin.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../providers/download_provider.dart'; @@ -59,28 +60,12 @@ class MediaCard extends StatefulWidget { State createState() => MediaCardState(); } -class MediaCardState extends State { - final _contextMenuKey = GlobalKey(); - Offset? _tapPosition; - - void _storeTapPosition(TapDownDetails details) { - _tapPosition = details.globalPosition; - } - - void _showContextMenu() { - _contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition); - } - +class MediaCardState extends State with ContextMenuTapMixin { /// Public method to trigger tap action (for keyboard/gamepad SELECT) void handleTap() { _handleTap(context); } - /// Public method to show context menu (for keyboard/gamepad context menu key) - void showContextMenu() { - _contextMenuKey.currentState?.showContextMenu(context); - } - String _buildSemanticLabel() { final item = widget.item; @@ -128,7 +113,7 @@ class MediaCardState extends State { void _handleTap(BuildContext context) async { // Ignore taps while context menu is open to avoid double-activating - if (_contextMenuKey.currentState?.isContextMenuOpen == true) { + if (contextMenuKey.currentState?.isContextMenuOpen == true) { return; } @@ -190,10 +175,10 @@ class MediaCardState extends State { item: widget.item, semanticLabel: semanticLabel, onTap: () => _handleTap(context), - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, density: context.select((s) => s.libraryDensity), isOffline: widget.isOffline, localPosterPath: localPosterPath, @@ -203,7 +188,7 @@ class MediaCardState extends State { // MediaContextMenu as a non-widget helper — only wrap with its key for // programmatic context menu access; gesture callbacks are on InkWell directly. return MediaContextMenu( - key: _contextMenuKey, + key: contextMenuKey, item: widget.item, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, @@ -228,10 +213,10 @@ class MediaCardState extends State { child: InkWell( canRequestFocus: false, onTap: () => _handleTap(context), - onTapDown: _storeTapPosition, - onLongPress: _showContextMenu, - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: _showContextMenu, + onTapDown: storeTapPosition, + onLongPress: showContextMenuFromTap, + onSecondaryTapDown: storeTapPosition, + onSecondaryTap: showContextMenuFromTap, borderRadius: BorderRadius.circular(tokens(context).radiusSm), child: Padding( padding: const EdgeInsets.fromLTRB(3, 3, 3, 1), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index f107749e..b0bf128f 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1756,7 +1756,7 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { child: GestureDetector( onTap: () => Navigator.pop(context), behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + child: const ColoredBox(color: Colors.transparent), ), ), // Menu diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index f904d72c..69e38ed0 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -468,7 +468,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr builder: (context, child) { return GestureDetector( onTap: _barrierDismissible ? () => _close() : null, - child: Container(color: Colors.black.withValues(alpha: _barrierAnimation.value)), + child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), ); }, ), diff --git a/lib/widgets/pill_input_decoration.dart b/lib/widgets/pill_input_decoration.dart index e91bdd72..7055b36c 100644 --- a/lib/widgets/pill_input_decoration.dart +++ b/lib/widgets/pill_input_decoration.dart @@ -2,15 +2,20 @@ import 'package:flutter/material.dart'; const pillInputRadius = BorderRadius.all(Radius.circular(100)); +/// Brighter fill on focus so input focus is visible inside TV overscan. InputDecoration pillInputDecoration(BuildContext context, {String? hintText, Widget? prefixIcon, Widget? suffixIcon}) { - final fillColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08); + final onSurface = Theme.of(context).colorScheme.onSurface; + final unfocusedFill = onSurface.withValues(alpha: 0.08); + final focusedFill = onSurface.withValues(alpha: 0.18); const border = OutlineInputBorder(borderRadius: pillInputRadius, borderSide: BorderSide.none); return InputDecoration( hintText: hintText, prefixIcon: prefixIcon, suffixIcon: suffixIcon, filled: true, - fillColor: fillColor, + fillColor: WidgetStateColor.resolveWith( + (states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill, + ), border: border, enabledBorder: border, focusedBorder: border, diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 312b10cd..ecb26d50 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -346,46 +346,26 @@ class PlexOptimizedImage extends StatelessWidget { ); } - Widget _buildPlaceholder(BuildContext context) { + Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) { + final theme = Theme.of(context).colorScheme; return Container( - width: width, - height: height, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: fallbackIcon != null - ? Center(child: AppIcon(fallbackIcon!, fill: 1, size: 40, color: Colors.white54)) - : null, + width: fillParent ? null : width, + height: fillParent ? null : height, + color: theme.surfaceContainerHighest, + child: icon == null + ? null + : Center(child: AppIcon(icon, fill: 1, size: 40, color: iconColor ?? theme.onSurfaceVariant)), ); } - Widget _buildErrorWidget(BuildContext context, dynamic _) { - return Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center( - child: AppIcon( - fallbackIcon ?? Symbols.broken_image_rounded, - fill: 1, - size: 40, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } + Widget _buildPlaceholder(BuildContext context) => + _surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54); - Widget _buildFallback(BuildContext context) { - return Container( - width: width, - height: height, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center( - child: AppIcon( - fallbackIcon ?? Symbols.image_not_supported_rounded, - fill: 1, - size: 40, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } + Widget _buildErrorWidget(BuildContext context, dynamic _) => + _surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.broken_image_rounded, fillParent: true); + + Widget _buildFallback(BuildContext context) => + _surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded); String _generateCacheKey(String imageUrl) { // URL already encodes bucketed transcode dimensions via roundDimensions, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a9ad9c81..d1c5f10a 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -391,7 +391,7 @@ class _PlexVideoControlsState extends State with WindowListen // Add lifecycle observer to reload settings when app resumes WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + if (PlatformDetector.isDesktopOS()) { windowManager.addListener(this); _initAlwaysOnTopState(); } @@ -705,11 +705,18 @@ class _PlexVideoControlsState extends State with WindowListen if (shaderService.currentPreset.isEnabled) { // Currently active - disable temporarily - shaderService.applyPreset(ShaderPreset.none).then((_) { - // ignore: no-empty-block - setState triggers rebuild to reflect disabled shader - if (mounted) setState(() {}); - widget.onShaderChanged?.call(); - }); + unawaited( + shaderService + .applyPreset(ShaderPreset.none) + .then((_) { + // ignore: no-empty-block - setState triggers rebuild to reflect disabled shader + if (mounted) setState(() {}); + widget.onShaderChanged?.call(); + }) + .catchError((Object e, StackTrace st) { + appLogger.w('Failed to disable shader', error: e, stackTrace: st); + }), + ); } else { // Currently off - restore saved preset final shaderProvider = context.read(); @@ -718,12 +725,19 @@ class _PlexVideoControlsState extends State with WindowListen final targetPreset = saved.isEnabled ? saved : allPresets.firstWhere((p) => p.isEnabled, orElse: () => allPresets[1]); - shaderService.applyPreset(targetPreset).then((_) { - shaderProvider.setCurrentPreset(targetPreset); - // ignore: no-empty-block - setState triggers rebuild to reflect restored shader - if (mounted) setState(() {}); - widget.onShaderChanged?.call(); - }); + unawaited( + shaderService + .applyPreset(targetPreset) + .then((_) { + shaderProvider.setCurrentPreset(targetPreset); + // ignore: no-empty-block - setState triggers rebuild to reflect restored shader + if (mounted) setState(() {}); + widget.onShaderChanged?.call(); + }) + .catchError((Object e, StackTrace st) { + appLogger.w('Failed to apply shader preset', error: e, stackTrace: st); + }), + ); } } @@ -766,7 +780,7 @@ class _PlexVideoControlsState extends State with WindowListen // Remove lifecycle observer WidgetsBinding.instance.removeObserver(this); // Remove window listener and reset always-on-top if it was enabled - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + if (PlatformDetector.isDesktopOS()) { windowManager.removeListener(this); if (_isAlwaysOnTop) { windowManager.setAlwaysOnTop(false); @@ -926,7 +940,7 @@ class _PlexVideoControlsState extends State with WindowListen final maxVol = _keyboardService!.maxVolume.toDouble(); final newVolume = (volume - delta / 20).clamp(0.0, maxVol); widget.player.setVolume(newVolume); - SettingsService.getInstance().then((s) => s.setVolume(newVolume)); + unawaited(SettingsService.getInstance().then((s) => s.setVolume(newVolume))); _showControlsFromPointerActivity(); } } @@ -2033,7 +2047,7 @@ class _PlexVideoControlsState extends State with WindowListen onLongPressEnd: (_) => _handleLongPressEnd(), onLongPressCancel: _handleLongPressCancel, behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + child: const ColoredBox(color: Colors.transparent), ), ), // Mobile double-tap zones for skip forward/backward @@ -2061,7 +2075,7 @@ class _PlexVideoControlsState extends State with WindowListen onLongPressEnd: (_) => _handleLongPressEnd(), onLongPressCancel: _handleLongPressCancel, behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + child: const ColoredBox(color: Colors.transparent), ), ), // Right zone - skip forward (custom double-tap detection) @@ -2076,7 +2090,7 @@ class _PlexVideoControlsState extends State with WindowListen onLongPressEnd: (_) => _handleLongPressEnd(), onLongPressCancel: _handleLongPressCancel, behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + child: const ColoredBox(color: Colors.transparent), ), ), ], diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 6769b70b..31691f10 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -1,5 +1,3 @@ -import 'dart:io' show Platform; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; @@ -185,7 +183,7 @@ class TrackChapterControls extends StatelessWidget { builder: (context, snapshot) { final tracks = snapshot.data; final isMobile = PlatformDetector.isMobile(context); - final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS; + final isDesktop = PlatformDetector.isDesktopOS(); // Build list of buttons dynamically to track indices final buttons = [];