diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 90f5567e..534f05de 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -195,6 +195,7 @@ class TasksRootSection : Routes.Route() { message: String, showDeleteFiles: Boolean, deleteFilesChecked: Boolean, + tasksTranslation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper, onToggleDeleteFiles: (Boolean) -> Unit, onConfirm: () -> Unit, onDismiss: () -> Unit @@ -301,12 +302,12 @@ class TasksRootSection : Routes.Route() { ) Column { Text( - text = context.translation["delete_files_option"], + text = tasksTranslation["delete_files_option"], color = Color.White, fontWeight = FontWeight.SemiBold ) Text( - text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files", + text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Also remove downloaded files from device", color = PurrfectPalette.textSecondary, style = MaterialTheme.typography.bodySmall ) @@ -433,6 +434,7 @@ class TasksRootSection : Routes.Route() { message = messageText, showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false @@ -1049,6 +1051,7 @@ class TasksRootSection : Routes.Route() { message = messageText, showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index b5b7e8db..b845aa18 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "هل أنت متأكد أنك تريد إزالة المهام المحددة؟", "remove_all_tasks_title": "هل أنت متأكد أنك تريد إزالة جميع المهام؟", "delete_files_option": "حذف الملفات أيضاً", + "delete_files_option_hint": "إزالة الملفات المحملة من الجهاز أيضاً", "remove_selected_tasks_confirm": "إزالة {count} مهام؟", "remove_all_tasks_confirm": "إزالة جميع المهام؟" }, @@ -3012,7 +3013,15 @@ "title": "تنزيل وسائط dash", "download_all": "تنزيل الكل", "segment_text": "جزء {from} - {to}" - } + }, + "story_snap_dialog": { + "title": "تنزيل سنابات القصة", + "select_all": "تحديد الكل", + "deselect_all": "إلغاء التحديد", + "snap_item": "السناب {index} من {total}" + }, + "batch_download_complete_toast": "تم تنزيل جميع السنابات", + "batch_download_jump_failed_toast": "تعذر الانتقال للسناب التالي. تأكد من أن عرض القصة مرئي." }, "streaks_reminder": { "notification_title": "Streaks (سلاسل)", diff --git a/common/src/main/assets/lang/bn.json b/common/src/main/assets/lang/bn.json index 4531d417..4b70ffbb 100644 --- a/common/src/main/assets/lang/bn.json +++ b/common/src/main/assets/lang/bn.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "আপনি কি নির্বাচিত টাস্কগুলি মুছে ফেলতে নিশ্চিত?", "remove_all_tasks_title": "আপনি কি সমস্ত টাস্ক মুছে ফেলতে নিশ্চিত?", "delete_files_option": "ফাইলগুলিও মুছে ফেলুন", + "delete_files_option_hint": "ডিভাইস থেকে ডাউনলোড করা ফাইলও সরান", "remove_selected_tasks_confirm": "{count} টাস্ক মুছে ফেলবেন?", "remove_all_tasks_confirm": "সমস্ত টাস্ক মুছে ফেলবেন?" }, diff --git a/common/src/main/assets/lang/da.json b/common/src/main/assets/lang/da.json index 77816383..b9d236f7 100644 --- a/common/src/main/assets/lang/da.json +++ b/common/src/main/assets/lang/da.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Er du sikker på, at du vil fjerne valgte opgaver?", "remove_all_tasks_title": "Er du sikker på, at du vil fjerne alle opgaver?", "delete_files_option": "Slet også filer", + "delete_files_option_hint": "Fjern også downloadede filer fra enheden", "remove_selected_tasks_confirm": "Fjern {count} opgaver?", "remove_all_tasks_confirm": "Fjern alle opgaver?" }, diff --git a/common/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json index 04a6890f..94b9c233 100644 --- a/common/src/main/assets/lang/de_DE.json +++ b/common/src/main/assets/lang/de_DE.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Möchtest du die ausgewählten Aufgaben wirklich entfernen?", "remove_all_tasks_title": "Möchtest du wirklich alle Aufgaben entfernen?", "delete_files_option": "Auch Dateien löschen", + "delete_files_option_hint": "Heruntergeladene Dateien auch vom Gerät entfernen", "remove_selected_tasks_confirm": "{count} Aufgaben entfernen?", "remove_all_tasks_confirm": "Alle Aufgaben entfernen?" }, diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index ef346c7e..8c809c75 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", "remove_all_tasks_title": "Are you sure you want to remove all tasks?", "delete_files_option": "Also delete files", + "delete_files_option_hint": "Also remove downloaded files from device", "remove_selected_tasks_confirm": "Remove {count} tasks?", "remove_all_tasks_confirm": "Remove all tasks?" }, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 9df764a5..d3707ae0 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", "remove_all_tasks_title": "Are you sure you want to remove all tasks?", "delete_files_option": "Also delete files", + "delete_files_option_hint": "Also remove downloaded files from device", "remove_selected_tasks_confirm": "Remove {count} tasks?", "remove_all_tasks_confirm": "Remove all tasks?" }, @@ -993,6 +994,10 @@ "name": "Opera Download Button", "description": "Adds a download button on the top right corner when viewing a Snap.\nLong press on buttons will force download" }, + "story_snap_list_download": { + "name": "Story Snap List Download", + "description": "When viewing a multi-snap story, shows a dialog to select which snaps to download" + }, "download_context_menu": { "name": "Download Context Menu", "description": "Allows you to download/preview messages from a conversation or a story using the context menu.\nLong press on buttons will force download" @@ -3011,7 +3016,15 @@ "title": "Download dash media", "download_all": "Download All", "segment_text": "Segment {from} - {to}" - } + }, + "story_snap_dialog": { + "title": "Download story snaps", + "select_all": "Select All", + "deselect_all": "Deselect All", + "snap_item": "Snap {index} of {total}" + }, + "batch_download_complete_toast": "All snaps downloaded", + "batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible." }, "streaks_reminder": { "notification_title": "Streaks", diff --git a/common/src/main/assets/lang/es_ES.json b/common/src/main/assets/lang/es_ES.json index 1f2656c2..dd90fccc 100644 --- a/common/src/main/assets/lang/es_ES.json +++ b/common/src/main/assets/lang/es_ES.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "¿Estás seguro de que quieres eliminar las tareas seleccionadas?", "remove_all_tasks_title": "¿Estás seguro de que quieres eliminar todas las tareas?", "delete_files_option": "También eliminar archivos", + "delete_files_option_hint": "También eliminar archivos descargados del dispositivo", "remove_selected_tasks_confirm": "¿Eliminar {count} tareas?", "remove_all_tasks_confirm": "¿Eliminar todas las tareas?" }, diff --git a/common/src/main/assets/lang/fi.json b/common/src/main/assets/lang/fi.json index 2f05ea51..58824748 100644 --- a/common/src/main/assets/lang/fi.json +++ b/common/src/main/assets/lang/fi.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Haluatko varmasti poistaa valitut tehtävät?", "remove_all_tasks_title": "Haluatko varmasti poistaa kaikki tehtävät?", "delete_files_option": "Poista myös tiedostot", + "delete_files_option_hint": "Poista myös ladatut tiedostot laitteelta", "remove_selected_tasks_confirm": "Poista {count} tehtävää?", "remove_all_tasks_confirm": "Poista kaikki tehtävät?" }, diff --git a/common/src/main/assets/lang/fr_FR.json b/common/src/main/assets/lang/fr_FR.json index 3837de07..f494c0e0 100644 --- a/common/src/main/assets/lang/fr_FR.json +++ b/common/src/main/assets/lang/fr_FR.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Êtes-vous sûr de vouloir supprimer les tâches sélectionnées ?", "remove_all_tasks_title": "Êtes-vous sûr de vouloir supprimer toutes les tâches ?", "delete_files_option": "Supprimer aussi les fichiers", + "delete_files_option_hint": "Supprimer aussi les fichiers téléchargés de l'appareil", "remove_selected_tasks_confirm": "Supprimer {count} tâches ?", "remove_all_tasks_confirm": "Supprimer toutes les tâches ?" }, diff --git a/common/src/main/assets/lang/hi_IN.json b/common/src/main/assets/lang/hi_IN.json index f6b00f0b..3984a489 100644 --- a/common/src/main/assets/lang/hi_IN.json +++ b/common/src/main/assets/lang/hi_IN.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "क्या आप वाकई चयनित कार्यों को हटाना चाहते हैं?", "remove_all_tasks_title": "क्या आप वाकई सभी कार्यों को हटाना चाहते हैं?", "delete_files_option": "फ़ाइलें भी हटाएँ", + "delete_files_option_hint": "डिवाइस से डाउनलोड की गई फ़ाइलें भी हटाएँ", "remove_selected_tasks_confirm": "{count} कार्यों को हटाएँ?", "remove_all_tasks_confirm": "सभी कार्यों को हटाएँ?" }, diff --git a/common/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json index 1dd7c0b7..eea1854b 100644 --- a/common/src/main/assets/lang/hu_HU.json +++ b/common/src/main/assets/lang/hu_HU.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Biztosan el akarod távolítani a kiválasztott feladatokat?", "remove_all_tasks_title": "Biztosan el akarod távolítani az összes feladatot?", "delete_files_option": "Fájlok törlése is", + "delete_files_option_hint": "Letöltött fájlok eltávolítása az eszközről is", "remove_selected_tasks_confirm": "{count} feladat eltávolítása?", "remove_all_tasks_confirm": "Összes feladat eltávolítása?" }, diff --git a/common/src/main/assets/lang/id.json b/common/src/main/assets/lang/id.json index f6e842b6..2b233775 100644 --- a/common/src/main/assets/lang/id.json +++ b/common/src/main/assets/lang/id.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Apakah Anda yakin ingin menghapus tugas yang dipilih?", "remove_all_tasks_title": "Apakah Anda yakin ingin menghapus semua tugas?", "delete_files_option": "Hapus juga file", + "delete_files_option_hint": "Hapus juga file yang diunduh dari perangkat", "remove_selected_tasks_confirm": "Hapus {count} tugas?", "remove_all_tasks_confirm": "Hapus semua tugas?" }, diff --git a/common/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json index 0395bdf8..c233c6c9 100644 --- a/common/src/main/assets/lang/it_IT.json +++ b/common/src/main/assets/lang/it_IT.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Sei sicuro di voler rimuovere le attività selezionate?", "remove_all_tasks_title": "Sei sicuro di voler rimuovere tutte le attività?", "delete_files_option": "Elimina anche i file", + "delete_files_option_hint": "Rimuovi anche i file scaricati dal dispositivo", "remove_selected_tasks_confirm": "Rimuovere {count} attività?", "remove_all_tasks_confirm": "Rimuovere tutte le attività?" }, diff --git a/common/src/main/assets/lang/ja_JP.json b/common/src/main/assets/lang/ja_JP.json index 71b6d1b9..a480b592 100644 --- a/common/src/main/assets/lang/ja_JP.json +++ b/common/src/main/assets/lang/ja_JP.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "選択したタスクを削除してもよろしいですか?", "remove_all_tasks_title": "すべてのタスクを削除してもよろしいですか?", "delete_files_option": "ファイルも削除する", + "delete_files_option_hint": "デバイスからダウンロードしたファイルも削除する", "remove_selected_tasks_confirm": "{count} 件のタスクを削除しますか?", "remove_all_tasks_confirm": "すべてのタスクを削除しますか?" }, diff --git a/common/src/main/assets/lang/ko_KR.json b/common/src/main/assets/lang/ko_KR.json index c9d6b42d..2efcbe7c 100644 --- a/common/src/main/assets/lang/ko_KR.json +++ b/common/src/main/assets/lang/ko_KR.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "선택한 작업을 제거하시겠습니까?", "remove_all_tasks_title": "모든 작업을 제거하시겠습니까?", "delete_files_option": "파일도 삭제", + "delete_files_option_hint": "기기에서 다운로드한 파일도 삭제", "remove_selected_tasks_confirm": "{count}개의 작업을 제거할까요?", "remove_all_tasks_confirm": "모든 작업을 제거할까요?" }, diff --git a/common/src/main/assets/lang/ku_KU.json b/common/src/main/assets/lang/ku_KU.json index 6ba90a63..6209edd6 100644 --- a/common/src/main/assets/lang/ku_KU.json +++ b/common/src/main/assets/lang/ku_KU.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "دڵنیایت دەتەوێت ئەرکە دیاریکراوەکان بسڕیتەوە؟", "remove_all_tasks_title": "دڵنیایت دەتەوێت هەموو ئەرکەکان بسڕیتەوە؟", "delete_files_option": "فایلەکانیش بسڕەوە", + "delete_files_option_hint": "فایلە داگراوەکانیش لە ئامێرەوە بسڕەوە", "remove_selected_tasks_confirm": "{count} ئەرک دەسڕیتەوە؟", "remove_all_tasks_confirm": "هەموو ئەرکەکان دەسڕیتەوە؟" }, diff --git a/common/src/main/assets/lang/lv_LV.json b/common/src/main/assets/lang/lv_LV.json index ddcadb54..09e9badc 100644 --- a/common/src/main/assets/lang/lv_LV.json +++ b/common/src/main/assets/lang/lv_LV.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Vai tiešām vēlaties noņemt atlasītos uzdevumus?", "remove_all_tasks_title": "Vai tiešām vēlaties noņemt visus uzdevumus?", "delete_files_option": "Dzēst arī failus", + "delete_files_option_hint": "Noņemt arī lejupielādētos failus no ierīces", "remove_selected_tasks_confirm": "Noņemt {count} uzdevumus?", "remove_all_tasks_confirm": "Noņemt visus uzdevumus?" }, diff --git a/common/src/main/assets/lang/nb_NO.json b/common/src/main/assets/lang/nb_NO.json index 68f7120b..21633064 100644 --- a/common/src/main/assets/lang/nb_NO.json +++ b/common/src/main/assets/lang/nb_NO.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Er du sikker på at du vil fjerne valgte oppgaver?", "remove_all_tasks_title": "Er du sikker på at du vil fjerne alle oppgaver?", "delete_files_option": "Slett også filer", + "delete_files_option_hint": "Fjern også nedlastede filer fra enheten", "remove_selected_tasks_confirm": "Fjern {count} oppgaver?", "remove_all_tasks_confirm": "Fjern alle oppgaver?" }, diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json index 661086b1..5902b3d0 100644 --- a/common/src/main/assets/lang/nl.json +++ b/common/src/main/assets/lang/nl.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Weet je zeker dat je de geselecteerde taken wilt verwijderen?", "remove_all_tasks_title": "Weet je zeker dat je alle taken wilt verwijderen?", "delete_files_option": "Ook bestanden verwijderen", + "delete_files_option_hint": "Ook gedownloade bestanden van apparaat verwijderen", "remove_selected_tasks_confirm": "{count} taken verwijderen?", "remove_all_tasks_confirm": "Alle taken verwijderen?" }, diff --git a/common/src/main/assets/lang/pl.json b/common/src/main/assets/lang/pl.json index 7eacd0d6..7b34f9de 100644 --- a/common/src/main/assets/lang/pl.json +++ b/common/src/main/assets/lang/pl.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Czy na pewno chcesz usunąć wybrane zadania?", "remove_all_tasks_title": "Czy na pewno chcesz usunąć wszystkie zadania?", "delete_files_option": "Usuń również pliki", + "delete_files_option_hint": "Usuń również pobrane pliki z urządzenia", "remove_selected_tasks_confirm": "Usunąć {count} zadań?", "remove_all_tasks_confirm": "Usunąć wszystkie zadania?" }, diff --git a/common/src/main/assets/lang/pt.json b/common/src/main/assets/lang/pt.json index 10cec78b..52e0792f 100644 --- a/common/src/main/assets/lang/pt.json +++ b/common/src/main/assets/lang/pt.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Tem certeza que deseja remover as tarefas selecionadas?", "remove_all_tasks_title": "Tem certeza que deseja remover todas as tarefas?", "delete_files_option": "Também excluir arquivos", + "delete_files_option_hint": "Também remover arquivos baixados do dispositivo", "remove_selected_tasks_confirm": "Remover {count} tarefas?", "remove_all_tasks_confirm": "Remover todas as tarefas?" }, diff --git a/common/src/main/assets/lang/ro.json b/common/src/main/assets/lang/ro.json index a03904b8..19c725e2 100644 --- a/common/src/main/assets/lang/ro.json +++ b/common/src/main/assets/lang/ro.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Ești sigur că vrei să elimini sarcinile selectate?", "remove_all_tasks_title": "Ești sigur că vrei să elimini toate sarcinile?", "delete_files_option": "Șterge și fișierele", + "delete_files_option_hint": "Elimină și fișierele descărcate de pe dispozitiv", "remove_selected_tasks_confirm": "Elimini {count} sarcini?", "remove_all_tasks_confirm": "Elimini toate sarcinile?" }, diff --git a/common/src/main/assets/lang/ru.json b/common/src/main/assets/lang/ru.json index caacc02d..6e8ac161 100644 --- a/common/src/main/assets/lang/ru.json +++ b/common/src/main/assets/lang/ru.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Вы уверены, что хотите удалить выбранные задачи?", "remove_all_tasks_title": "Вы уверены, что хотите удалить все задачи?", "delete_files_option": "Также удалить файлы", + "delete_files_option_hint": "Также удалить загруженные файлы с устройства", "remove_selected_tasks_confirm": "Удалить {count} задач?", "remove_all_tasks_confirm": "Удалить все задачи?" }, diff --git a/common/src/main/assets/lang/sl_SI.json b/common/src/main/assets/lang/sl_SI.json index 9735cb16..d5ddead9 100644 --- a/common/src/main/assets/lang/sl_SI.json +++ b/common/src/main/assets/lang/sl_SI.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Ali ste prepričani, da želite odstraniti izbrane naloge?", "remove_all_tasks_title": "Ali ste prepričani, da želite odstraniti vse naloge?", "delete_files_option": "Izbriši tudi datoteke", + "delete_files_option_hint": "Odstrani tudi prenesene datoteke iz naprave", "remove_selected_tasks_confirm": "Odstrani {count} nalog?", "remove_all_tasks_confirm": "Odstrani vse naloge?" }, diff --git a/common/src/main/assets/lang/sv.json b/common/src/main/assets/lang/sv.json index 3ff47dd9..77671d80 100644 --- a/common/src/main/assets/lang/sv.json +++ b/common/src/main/assets/lang/sv.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Är du säker på att du vill ta bort valda uppgifter?", "remove_all_tasks_title": "Är du säker på att du vill ta bort alla uppgifter?", "delete_files_option": "Radera även filer", + "delete_files_option_hint": "Ta bort nedladdade filer från enheten", "remove_selected_tasks_confirm": "Ta bort {count} uppgifter?", "remove_all_tasks_confirm": "Ta bort alla uppgifter?" }, diff --git a/common/src/main/assets/lang/tr_TR.json b/common/src/main/assets/lang/tr_TR.json index 32266130..78a9525c 100644 --- a/common/src/main/assets/lang/tr_TR.json +++ b/common/src/main/assets/lang/tr_TR.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Seçili görevleri kaldırmak istediğinizden emin misiniz?", "remove_all_tasks_title": "Tüm görevleri kaldırmak istediğinizden emin misiniz?", "delete_files_option": "Dosyaları da sil", + "delete_files_option_hint": "Cihazdan indirilen dosyaları da kaldır", "remove_selected_tasks_confirm": "{count} görev kaldırılsın mı?", "remove_all_tasks_confirm": "Tüm görevler kaldırılsın mı?" }, diff --git a/common/src/main/assets/lang/uk_UA.json b/common/src/main/assets/lang/uk_UA.json index 948ace16..570ba397 100644 --- a/common/src/main/assets/lang/uk_UA.json +++ b/common/src/main/assets/lang/uk_UA.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "Ви впевнені, що хочете видалити вибрані завдання?", "remove_all_tasks_title": "Ви впевнені, що хочете видалити всі завдання?", "delete_files_option": "Також видалити файли", + "delete_files_option_hint": "Також видалити завантажені файли з пристрою", "remove_selected_tasks_confirm": "Видалити {count} завдань?", "remove_all_tasks_confirm": "Видалити всі завдання?" }, diff --git a/common/src/main/assets/lang/zh_SIMPLIFIED.json b/common/src/main/assets/lang/zh_SIMPLIFIED.json index 414e065a..6f9e5836 100644 --- a/common/src/main/assets/lang/zh_SIMPLIFIED.json +++ b/common/src/main/assets/lang/zh_SIMPLIFIED.json @@ -309,6 +309,7 @@ "remove_selected_tasks_title": "您确定要移除选定的任务吗?", "remove_all_tasks_title": "您确定要移除所有任务吗?", "delete_files_option": "同时删除文件", + "delete_files_option_hint": "同时从设备中删除已下载的文件", "remove_selected_tasks_confirm": "移除 {count} 个任务?", "remove_all_tasks_confirm": "移除所有任务?" }, diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt index 7c3b9c33..6ca1eeb4 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt @@ -44,6 +44,7 @@ class DownloaderConfig : ConfigContainer() { val autoDownloadVoiceNotes = boolean("auto_download_voice_notes") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val downloadProfilePictures = boolean("download_profile_pictures") { requireRestart() } val operaDownloadButton = boolean("opera_download_button") { requireRestart() } + val storySnapListDownload = boolean("story_snap_list_download", true) val downloadContextMenu = boolean("download_context_menu") val ffmpegOptions = container("ffmpeg_options", FFMpegOptions()) { addNotices(FeatureNotice.UNSTABLE) } val logging = multiple("logging", "started", "success", "progress", "failure").apply { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 81db8a99..5ce61f64 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -88,6 +88,7 @@ class FeatureManager( AutoReply(), UITweaks(), OperaStoryCounter(), + OperaStoryOverlay(), ConfigurationOverride(), COFOverride(), UnsaveableMessages(), @@ -124,7 +125,7 @@ class FeatureManager( PreventForcedLogout(), ConversationToolbox(), SpotlightCommentsUsername(), - SpotlightCreatorInfo(), + OperaStoryCounter(), OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 5a82c4e9..a58d2cd9 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -10,9 +10,37 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog +import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import kotlinx.coroutines.runBlocking import me.eternal.purrfectsnap.bridge.DownloadCallback import me.eternal.purrfectsnap.common.data.FileType @@ -46,6 +74,7 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair +import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper import java.nio.file.Paths import java.util.UUID @@ -62,6 +91,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private var lastSeenMediaInfoMap: MutableMap? = null var lastSeenMapParams: ParamMap? = null private set + @Volatile + private var pendingBatchDownloadIndices: MutableList? = null + @Volatile + private var batchForceAllowDuplicate: Boolean = false private val translations by lazy { context.translation.getCategory("download_processor") } @@ -152,14 +185,227 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) } + private fun ParamMap.getStorySnapIndex(): Int? = + this["snap_index_in_story"]?.toString()?.toIntOrNull() + ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + + private fun ParamMap.getStorySnapTotal(): Int? = + this["snap_story_length"]?.toString()?.toIntOrNull() + ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + + private fun isMultiSnapStory(paramMap: ParamMap): Boolean { + if (paramMap.containsKey("MESSAGE_ID") || paramMap["SNAP_SOURCE"]?.toString() == "SINGLE_SNAP_STORY") return false + if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM")) return false + val total = paramMap.getStorySnapTotal() ?: return false + return total > 1 + } + /* * Download the last seen media */ fun downloadLastOperaMediaAsync(allowDuplicate: Boolean) { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - context.executeAsync { - handleOperaMedia(lastSeenMapParams!!, lastSeenMediaInfoMap!!, true, allowDuplicate) + val paramMap = lastSeenMapParams!! + val mediaInfoMap = lastSeenMediaInfoMap!! + + if (isMultiSnapStory(paramMap) && context.config.downloader.storySnapListDownload.get()) { + context.runOnUiThread { + showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) + } + return } + + context.executeAsync { + handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) + } + } + + private fun showStorySnapSelectionDialog(paramMap: ParamMap, mediaInfoMap: Map, allowDuplicate: Boolean) { + val totalCount = paramMap.getStorySnapTotal() ?: return + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val tr = context.translation.getCategory("download_processor.story_snap_dialog") + val cancelStr = context.translation["button.cancel"] + val downloadStr = context.translation["button.download"] + + context.runOnUiThread { + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + PurrfectOverlayTheme { + val selected = remember { mutableStateListOf().apply { add(currentIndex) } } + + LaunchedEffect(Unit) { + if (!selected.contains(currentIndex)) selected.add(currentIndex) + } + + PurrfectGlassCard( + title = tr["title"], + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 320.dp) + .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + itemsIndexed((0 until totalCount).toList()) { index, _ -> + val label = tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = selected.contains(index), + onCheckedChange = { checked -> + if (checked) selected.add(index) else selected.remove(index) + }, + colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) + ) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = PurrfectOverlayPalette.textPrimary + ) + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected.size == totalCount, + onCheckedChange = { checked -> + if (checked) { + selected.clear() + selected.addAll(0 until totalCount) + } else { + selected.clear() + } + }, + colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) + ) + Text( + tr["select_all"], + style = MaterialTheme.typography.bodyMedium, + color = PurrfectOverlayPalette.textPrimary + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { alertDialog.dismiss() }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) + ) { + Text(cancelStr) + } + Button( + onClick = { + if (selected.isNotEmpty()) { + startBatchDownload(selected.sorted().toMutableList(), allowDuplicate) + alertDialog.dismiss() + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) + ) { + Text(downloadStr) + } + } + } + } + } + }.apply { + window?.setBackgroundDrawableResource(android.R.color.transparent) + show() + } + } + } + + private fun startBatchDownload(indices: MutableList, allowDuplicate: Boolean) { + if (indices.isEmpty()) return + val paramMap = lastSeenMapParams ?: return + val mediaInfoMap = lastSeenMediaInfoMap ?: return + + pendingBatchDownloadIndices = indices + batchForceAllowDuplicate = allowDuplicate + + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val targetIndex = indices.first() + val totalCount = paramMap.getStorySnapTotal() + + if (currentIndex == targetIndex) { + processNextBatchDownload(paramMap, mediaInfoMap) + } else { + val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount) + if (!jumped) { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_jump_failed_toast"]) + } + } + } + + private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map) { + context.executeAsync { + runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) } + .onFailure { + context.log.error("Batch download failed", it) + context.shortToast(translations["failed_generic_toast"]) + } + } + } + + private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map) { + val queue = pendingBatchDownloadIndices ?: return + if (queue.isEmpty()) { + flushPendingMergeAndComplete() + return + } + + val currentIndex = paramMap.getStorySnapIndex() ?: -1 + if (currentIndex != queue.first()) return + + queue.removeAt(0) + downloadSingleSnap(paramMap, mediaInfoMap) + + if (queue.isEmpty()) { + flushPendingMergeAndComplete() + } else { + val totalCount = paramMap.getStorySnapTotal() + context.runOnUiThread { + fun tryJump(retryCount: Int = 0) { + val delayMs = if (retryCount == 0) 120L else 220L + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + val jumped = runCatching { + context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) + }.getOrNull() == true + if (!jumped && retryCount < 1) { + tryJump(retryCount + 1) + } else if (!jumped) { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_jump_failed_toast"]) + } + }, delayMs) + } + tryJump() + } + } + } + + private fun flushPendingMergeAndComplete() { + pendingBatchDownloadIndices = null + context.shortToast(translations["batch_download_complete_toast"]) } fun showLastOperaDebugMediaInfo() { @@ -536,6 +782,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp lastSeenMapParams = mediaParamMap lastSeenMediaInfoMap = mediaInfoMap + if (pendingBatchDownloadIndices != null) { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + if (pendingBatchDownloadIndices != null) { + processNextBatchDownload(mediaParamMap, mediaInfoMap) + } + }, 80L) + return@onOperaViewStateCallback + } + if (!shouldAutoDownload) { return@onOperaViewStateCallback } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt new file mode 100644 index 00000000..85517573 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt @@ -0,0 +1,57 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.ui.children +import java.lang.ref.WeakReference + +/** + * Provides snap jump logic for Story Snap List Download batch downloads. + * Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps. + */ +class OperaStoryOverlay : Feature("OperaStoryOverlay") { + private val overlayState = OperaStoryOverlayState() + private var storyFrameLayout = WeakReference(null) + private lateinit var snapJump: OperaStorySnapJump + + override fun init() { + val storySnapListDownload = context.config.downloader.storySnapListDownload.get() + + if (!storySnapListDownload) return + + snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() } + + context.event.subscribe(AddViewEvent::class) { event -> + if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { + val viewGroup = event.view as FrameLayout + + if (viewGroup.findViewWithTag("story_counter") != null || + event.parent.findViewWithTag("story_counter") != null) return@subscribe + + if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe + + storyFrameLayout = WeakReference(viewGroup) + } + } + + onNextActivityCreate { + overlayState.setupDisplayStateHook( + context = context, + showCounter = false, + showSourceIndicator = false, + onSnapFullyDisplayed = { + if (snapJump.isJumping()) { + snapJump.onSnapFullyDisplayed(it) + } + }, + onClearState = { snapJump.removeJumpOverlay() } + ) + } + } + + fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean = + snapJump.requestJumpToSnap(targetIndex, totalCountOverride) +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt new file mode 100644 index 00000000..fcb28c68 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt @@ -0,0 +1,98 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import me.eternal.purrfectsnap.core.ModContext +import me.eternal.purrfectsnap.core.util.ktx.getObjectField +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import java.util.ArrayList +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf + +/** + * Shared state for Opera Story overlay (snap jump for batch download). + * Provides story index, total count, and hook setup for display state changes. + */ +class OperaStoryOverlayState { + val counterState = mutableStateOf("") + val sourceState = mutableStateOf("") + val currentIndexState = mutableIntStateOf(-1) + val totalCountState = mutableIntStateOf(0) + + fun setupDisplayStateHook( + context: ModContext, + showCounter: Boolean, + showSourceIndicator: Boolean, + onSnapFullyDisplayed: ((Int) -> Unit)?, + onClearState: (() -> Unit)? = null + ) { + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + classReference.get()?.hook( + methodName.get() ?: return@forEach, + HookStage.AFTER + ) { param -> + val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() + + if (viewState != "FULLY_DISPLAYED") { + return@hook + } + + val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString() + + if (mediaParamMap.containsKey("MESSAGE_ID")) { + context.runOnUiThread { + counterState.value = "" + sourceState.value = "" + currentIndexState.intValue = -1 + totalCountState.intValue = 0 + onClearState?.invoke() + } + return@hook + } + + if (snapSource == "SINGLE_SNAP_STORY") { + context.runOnUiThread { + counterState.value = "" + sourceState.value = "" + currentIndexState.intValue = -1 + totalCountState.intValue = 0 + onClearState?.invoke() + } + return@hook + } + + val currentIndex = mediaParamMap["snap_index_in_story"]?.toString()?.toIntOrNull() + ?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull() + ?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + + var mediaOrigin = "" + if (showSourceIndicator) { + val snapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() ?: "" + mediaOrigin = if (snapRecord.contains("mediaOrigins=")) { + if (snapRecord.contains("mediaOrigins=[CAMERA]")) "CAMERA" else "GALLERY" + } else "" + } + + context.runOnUiThread { + counterState.value = if (showCounter && currentIndex != null && totalCount != null && totalCount > 0) { + "${currentIndex + 1} / $totalCount" + } else "" + sourceState.value = mediaOrigin + currentIndexState.intValue = currentIndex ?: -1 + totalCountState.intValue = totalCount ?: 0 + + onSnapFullyDisplayed?.let { callback -> + if (currentIndex != null) callback(currentIndex) + } + } + } + } + } + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt new file mode 100644 index 00000000..eed81245 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJump.kt @@ -0,0 +1,194 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.graphics.Color as AndroidColor +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import kotlin.math.abs + +/** + * Handles snap jump logic: tap simulation, overlay, and navigation to target snap index. + */ +class OperaStorySnapJump( + private val context: me.eternal.purrfectsnap.core.ModContext, + private val overlayState: OperaStoryOverlayState, + private val storyFrameLayout: () -> ViewGroup? +) { + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile private var isJumping = false + @Volatile private var jumpTargetIndex = -1 + private var jumpGeneration = 0 + private var retryRunnable: Runnable? = null + private var nextTapRunnable: Runnable? = null + private var lastHandledIndex = -1 + + fun simulateTap(forward: Boolean) { + val activity = context.mainActivity ?: return + val decorView = activity.window.decorView + val x = if (forward) decorView.width * 0.88f else decorView.width * 0.12f + val y = decorView.height * 0.5f + val downTime = SystemClock.uptimeMillis() + val tapDurationMs = 50L + + val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0) + decorView.dispatchTouchEvent(downEvent) + downEvent.recycle() + + val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0) + decorView.dispatchTouchEvent(upEvent) + upEvent.recycle() + } + + private fun navigateStory(forward: Boolean) { + if (storyFrameLayout()?.isAttachedToWindow != true) return + simulateTap(forward) + } + + fun showJumpOverlay() { + val frameLayout = storyFrameLayout() ?: return + frameLayout.findViewWithTag("jump_overlay")?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + frameLayout.addView(View(frameLayout.context).apply { + tag = "jump_overlay" + setBackgroundColor(AndroidColor.BLACK) + isClickable = false + isFocusable = false + setOnTouchListener { _, _ -> false } + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + }, 0) + } + + private fun cancelPendingRetry() { + retryRunnable?.let { mainHandler.removeCallbacks(it) } + retryRunnable = null + } + + private fun cancelPendingNextTap() { + nextTapRunnable?.let { mainHandler.removeCallbacks(it) } + nextTapRunnable = null + } + + fun removeJumpOverlay() { + cancelPendingRetry() + cancelPendingNextTap() + isJumping = false + jumpTargetIndex = -1 + lastHandledIndex = -1 + mainHandler.postDelayed({ + val overlay = storyFrameLayout()?.findViewWithTag("jump_overlay") ?: return@postDelayed + overlay.animate() + .alpha(0f) + .setDuration(120) + .withEndAction { (overlay.parent as? ViewGroup)?.removeView(overlay) } + .start() + }, 150) + } + + private fun dispatchTapAndWaitForChange(fromIndex: Int, gen: Int, retryCount: Int = 0) { + if (!isJumping || jumpTargetIndex < 0 || gen != jumpGeneration) return + if (storyFrameLayout()?.isAttachedToWindow != true) { + removeJumpOverlay() + return + } + + val forward = jumpTargetIndex > fromIndex + simulateTap(forward) + + val remainingDistance = abs(jumpTargetIndex - fromIndex) + val maxRetries = maxOf(36, remainingDistance * 6) + val retryDelayMs = when { + retryCount < 5 -> 180L + retryCount < 12 -> 280L + else -> 400L + } + + val retry = Runnable { + if (!isJumping || gen != jumpGeneration) return@Runnable + val currentIdx = overlayState.currentIndexState.intValue + if (currentIdx == fromIndex) { + if (retryCount >= maxRetries) { + removeJumpOverlay() + } else { + dispatchTapAndWaitForChange(fromIndex, gen, retryCount + 1) + } + } + } + retryRunnable = retry + mainHandler.postDelayed(retry, retryDelayMs) + } + + fun onSnapFullyDisplayed(currentIndex: Int) { + if (!isJumping || jumpTargetIndex < 0) return + if (currentIndex == lastHandledIndex) return + + cancelPendingRetry() + cancelPendingNextTap() + + if (currentIndex == jumpTargetIndex) { + removeJumpOverlay() + return + } + + lastHandledIndex = currentIndex + val gen = jumpGeneration + val tapRunnable = Runnable { + nextTapRunnable = null + if (isJumping && gen == jumpGeneration && overlayState.currentIndexState.intValue == currentIndex) { + dispatchTapAndWaitForChange(currentIndex, gen) + } + } + nextTapRunnable = tapRunnable + mainHandler.postDelayed(tapRunnable, 45L) + } + + fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean { + if (storyFrameLayout()?.isAttachedToWindow != true) return false + val totalCount = totalCountOverride ?: overlayState.totalCountState.intValue + if (totalCount <= 1) return false + if (targetIndex < 0 || targetIndex >= totalCount) return false + mainHandler.post { jumpToSnap(targetIndex) } + return true + } + + fun jumpToSnap(targetIndex: Int) { + val current = overlayState.currentIndexState.intValue + if (current < 0 || targetIndex == current) return + + if (isJumping) { + cancelPendingRetry() + cancelPendingNextTap() + isJumping = false + jumpTargetIndex = -1 + } + + if (abs(targetIndex - current) == 1) { + navigateStory(targetIndex > current) + return + } + + jumpGeneration++ + jumpTargetIndex = targetIndex + lastHandledIndex = -1 + isJumping = true + + showJumpOverlay() + + val gen = jumpGeneration + mainHandler.postDelayed({ + if (gen == jumpGeneration && isJumping) { + dispatchTapAndWaitForChange(current, gen) + } + }, 50L) + } + + fun isJumping(): Boolean = isJumping +}