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 7634317e..0204bc47 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,13 +302,12 @@ class TasksRootSection : Routes.Route() { ) Column { Text( - text = context.translation["manager.sections.tasks.delete_files_option"], + text = tasksTranslation["delete_files_option"], color = Color.White, fontWeight = FontWeight.SemiBold ) Text( - text = context.translation["manager.sections.tasks.delete_files_option_hint"] - ?: "Also remove downloaded files", + text = tasksTranslation.getOrNull("delete_files_option_hint") ?: "Permanently remove the original files from storage", color = PurrfectPalette.textSecondary, style = MaterialTheme.typography.bodySmall ) @@ -434,6 +434,7 @@ class TasksRootSection : Routes.Route() { message = messageText, showDeleteFiles = isSelection, deleteFilesChecked = alsoDeleteFiles, + tasksTranslation = translation, onToggleDeleteFiles = { alsoDeleteFiles = it }, onConfirm = { showConfirmDialog = false @@ -1050,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 935523a7..4ae79ff1 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -340,6 +340,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": "إزالة جميع المهام؟" }, @@ -3035,7 +3036,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 99e73e84..9e27c4bf 100644 --- a/common/src/main/assets/lang/bn.json +++ b/common/src/main/assets/lang/bn.json @@ -340,6 +340,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 5eef53d4..f940b1fe 100644 --- a/common/src/main/assets/lang/da.json +++ b/common/src/main/assets/lang/da.json @@ -340,6 +340,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 1c5b85a2..1133dc85 100644 --- a/common/src/main/assets/lang/de_DE.json +++ b/common/src/main/assets/lang/de_DE.json @@ -340,6 +340,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 1623f896..a347e8ac 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -340,6 +340,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 723126cb..d3f19877 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1015,6 +1015,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" @@ -3081,7 +3085,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 7768e849..4ea5a414 100644 --- a/common/src/main/assets/lang/es_ES.json +++ b/common/src/main/assets/lang/es_ES.json @@ -340,6 +340,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 3ae2dd66..616c6136 100644 --- a/common/src/main/assets/lang/fi.json +++ b/common/src/main/assets/lang/fi.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Oletko varma, että haluat poistaa valitut tehtävät?", "remove_all_tasks_title": "Oletko varma, että haluat 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 ca8d6962..c15752b9 100644 --- a/common/src/main/assets/lang/fr_FR.json +++ b/common/src/main/assets/lang/fr_FR.json @@ -340,6 +340,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 e20a13dd..ac72530d 100644 --- a/common/src/main/assets/lang/hi_IN.json +++ b/common/src/main/assets/lang/hi_IN.json @@ -340,6 +340,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 eebe9377..dc290414 100644 --- a/common/src/main/assets/lang/hu_HU.json +++ b/common/src/main/assets/lang/hu_HU.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Biztosan törölni szeretnéd a kijelölt feladatokat?", "remove_all_tasks_title": "Biztosan törölni szeretnéd az összes feladatot?", "delete_files_option": "Fájlok törlése is", + "delete_files_option_hint": "Az eredeti fájlok végleges eltávolítása a tárolóból", "remove_selected_tasks_confirm": "{count} feladat törlése?", "remove_all_tasks_confirm": "Összes feladat törlése?" }, diff --git a/common/src/main/assets/lang/id.json b/common/src/main/assets/lang/id.json index 2f381415..372ac923 100644 --- a/common/src/main/assets/lang/id.json +++ b/common/src/main/assets/lang/id.json @@ -340,6 +340,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 260abaf6..1c1cb688 100644 --- a/common/src/main/assets/lang/it_IT.json +++ b/common/src/main/assets/lang/it_IT.json @@ -340,6 +340,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 8d0917e0..60bc006c 100644 --- a/common/src/main/assets/lang/ja_JP.json +++ b/common/src/main/assets/lang/ja_JP.json @@ -340,6 +340,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 7fdcc236..295115de 100644 --- a/common/src/main/assets/lang/ko_KR.json +++ b/common/src/main/assets/lang/ko_KR.json @@ -340,6 +340,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 f1ca7dce..dd4c1fab 100644 --- a/common/src/main/assets/lang/ku_KU.json +++ b/common/src/main/assets/lang/ku_KU.json @@ -340,6 +340,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 ff5ff369..f2b554fd 100644 --- a/common/src/main/assets/lang/lv_LV.json +++ b/common/src/main/assets/lang/lv_LV.json @@ -340,6 +340,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 2371eae1..b4b06bac 100644 --- a/common/src/main/assets/lang/nb_NO.json +++ b/common/src/main/assets/lang/nb_NO.json @@ -340,6 +340,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 permanent de originale filene fra lagring", "remove_selected_tasks_confirm": "Fjerne {count} oppgaver?", "remove_all_tasks_confirm": "Fjerne alle oppgaver?" }, diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json index 0cee8ab2..474b2e0a 100644 --- a/common/src/main/assets/lang/nl.json +++ b/common/src/main/assets/lang/nl.json @@ -340,6 +340,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 9f815107..036e51c3 100644 --- a/common/src/main/assets/lang/pl.json +++ b/common/src/main/assets/lang/pl.json @@ -340,6 +340,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 b050b364..98f03311 100644 --- a/common/src/main/assets/lang/pt.json +++ b/common/src/main/assets/lang/pt.json @@ -340,6 +340,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": "Excluir arquivos também", + "delete_files_option_hint": "Remover permanentemente os arquivos originais do armazenamento", "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 5ef51485..aa748d5a 100644 --- a/common/src/main/assets/lang/ro.json +++ b/common/src/main/assets/lang/ro.json @@ -340,6 +340,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ă permanent fișierele originale din stocare", "remove_selected_tasks_confirm": "Elimină {count} sarcini?", "remove_all_tasks_confirm": "Elimină toate sarcinile?" }, diff --git a/common/src/main/assets/lang/ru.json b/common/src/main/assets/lang/ru.json index c2c28bee..77ef67bf 100644 --- a/common/src/main/assets/lang/ru.json +++ b/common/src/main/assets/lang/ru.json @@ -340,6 +340,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 f5606cb5..52dc4767 100644 --- a/common/src/main/assets/lang/sl_SI.json +++ b/common/src/main/assets/lang/sl_SI.json @@ -340,6 +340,7 @@ "remove_selected_tasks_title": "Si prepričan, da želiš odstraniti izbrana opravila?", "remove_all_tasks_title": "Si prepričan, da želiš odstraniti vsa opravila?", "delete_files_option": "Izbriši tudi datoteke", + "delete_files_option_hint": "Trajno odstrani izvirne datoteke iz pomnilnika", "remove_selected_tasks_confirm": "Odstrani {count} opravil?", "remove_all_tasks_confirm": "Odstrani vsa opravila?" }, diff --git a/common/src/main/assets/lang/sv.json b/common/src/main/assets/lang/sv.json index 2f7391c9..bd73bf5b 100644 --- a/common/src/main/assets/lang/sv.json +++ b/common/src/main/assets/lang/sv.json @@ -340,6 +340,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 f74e1e70..cf0c9d0f 100644 --- a/common/src/main/assets/lang/tr_TR.json +++ b/common/src/main/assets/lang/tr_TR.json @@ -340,6 +340,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": "Depolamadan özgün dosyaları kalıcı olarak kaldır", "remove_selected_tasks_confirm": "{count} görevi kaldır?", "remove_all_tasks_confirm": "Tüm görevleri kaldır?" }, diff --git a/common/src/main/assets/lang/uk_UA.json b/common/src/main/assets/lang/uk_UA.json index fed67e10..85531268 100644 --- a/common/src/main/assets/lang/uk_UA.json +++ b/common/src/main/assets/lang/uk_UA.json @@ -340,6 +340,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 2b2bb77b..25566132 100644 --- a/common/src/main/assets/lang/zh_SIMPLIFIED.json +++ b/common/src/main/assets/lang/zh_SIMPLIFIED.json @@ -340,6 +340,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 fb567a53..5093b81a 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 d376f298..71b87a1d 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.ContentType @@ -47,6 +75,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.core.wrapper.impl.media.EncryptionWrapper import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode @@ -76,6 +105,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") } @@ -166,14 +199,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() { @@ -642,6 +888,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 +}