From 281d55689af04374aa5a27e7cbe3260d12422dc5 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:09:33 +0530 Subject: [PATCH] Spotlight/Story video download fix --- .../purrfectsnap/download/FFMpegProcessor.kt | 4 +- common/src/main/assets/lang/en_US.json | 5 +- .../purrfectsnap/common/data/FileType.kt | 22 +- .../impl/downloader/MediaDownloader.kt | 188 ++++++++++++------ .../impl/experiments/AutoOpenSnaps.kt | 6 +- 5 files changed, 141 insertions(+), 84 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 7b00da81..69762b99 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -146,8 +146,8 @@ class FFMpegProcessor( val outputArguments = ArgumentList().apply { this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") - this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264") - this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy") + this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264") + this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy") this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K" diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 489679ea..4f9bedbe 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -3681,12 +3681,13 @@ "failed_gallery_toast": "Failed saving to gallery {error}", "dash_no_chapter": "No chapter found", "dash_dialog": { - "title": "Download dash media", + "title": "DASH Download", "download_all": "Download All", - "segment_text": "Segment {from} - {to}" + "snap_text": "Snap {from} - {to}" }, "story_snap_dialog": { "title": "Download story snaps", + "download_all": "Download All", "select_all": "Select All", "deselect_all": "Deselect All", "snap_item": "Snap {index} of {total}" diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt index 305b07f9..95295bc8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/FileType.kt @@ -72,24 +72,10 @@ enum class FileType( if (majorBrand in imageBrands) return false return majorBrand in setOf( - "mp41", - "mp42", - "isom", - "iso2", - "iso3", - "iso4", - "iso5", - "iso6", - "avc1", - "dash", - "cmfc", - "msnv", - "3gp4", - "3gp5", - "3gp6", - "3g2a", - "3g2b" - ) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video + "mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6", + "avc1", "dash", "cmfc", "msnv", "3gp4", "3gp5", "3gp6", "3g2a", "3g2b", + "mp4v", "mp4a", "m4v ", "m4a ", "f4v ", "f4a " + ) || majorBrand.isNotEmpty() } fun fromFile(file: File): FileType { 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 a30fba59..8f91aeb6 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 @@ -166,7 +166,29 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp callback = object: DownloadCallback.Stub() { override fun onSuccess(outputFile: String) { if (!downloadLogging.contains("success")) return - context.log.verbose("onSuccess: outputFile=$outputFile") + + var finalOutputFile = outputFile + runCatching { + val file = java.io.File(outputFile) + if (file.exists()) { + val header = file.inputStream().use { input -> + val buffer = ByteArray(16) + input.read(buffer) + buffer + } + val fileType = FileType.fromByteArray(header) + if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) { + val newPath = outputFile.removeSuffix(".dat") + ".mp4" + val newFile = java.io.File(newPath) + if (file.renameTo(newFile)) { + finalOutputFile = newPath + context.log.verbose("corrected video extension: $outputFile -> $newPath") + } + } + } + } + + context.log.verbose("onSuccess: outputFile=$finalOutputFile") context.inAppOverlay.showStatusToast( icon = Icons.Outlined.DownloadDone, durationMs = 1300, @@ -685,74 +707,120 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } context.runOnUiThread { - val selectedChapters = mutableListOf() - val dialogTranslation = translations.getCategory("dash_dialog") + val tr = context.translation.getCategory("download_processor.dash_dialog") val chapters = snapChapterList.mapIndexed { index, snapChapter -> val nextChapter = snapChapterList.getOrNull(index + 1) val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs) SnapChapterInfo(snapChapter.startTimeMs, duration) } - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { - setTitle(dialogTranslation["title"]) - setMultiChoiceItems( - chapters.map { dialogTranslation.format("segment_text", "from" to prettyPrintTime(it.offset), "to" to prettyPrintTime(it.offset + (it.duration ?: 0))) }.toTypedArray(), - List(chapters.size) { index -> - if (currentChapterIndex == index) { - selectedChapters.add(index) - true - } else false - }.toBooleanArray() - ) { _, which, isChecked -> - if (isChecked) { - selectedChapters.add(which) - } else if (selectedChapters.contains(which)) { - selectedChapters.remove(which) - } - } - setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } - setNeutralButton(dialogTranslation["download_all"]) { _, _ -> - provideDownloadManagerClient( - mediaIdentifier = paramMap["STORY_ID"].toString(), - downloadSource = MediaDownloadSource.PUBLIC_STORY, - mediaAuthor = storyName - ).downloadDashMedia(playlistUrl, 0, null) - } - setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> - val groups = mutableListOf>() + val cancelStr = context.translation["button.cancel"] + val downloadStr = context.translation["button.download"] - var lastChapterIndex = -1 - // group consecutive chapters - chapters.forEachIndexed { index, snapChapter -> - lastChapterIndex = if (selectedChapters.contains(index)) { - if (lastChapterIndex == -1) { - groups.add(mutableListOf()) + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + PurrfectOverlayTheme { + val selected = remember { mutableStateListOf().apply { add(currentChapterIndex) } } + 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(chapters) { index, item -> + val label = tr.format("snap_text", "from" to prettyPrintTime(item.offset), "to" to prettyPrintTime(item.offset + (item.duration ?: 0))) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + 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 + ) + } + } } - groups.last().add(snapChapter) - index - } else { - -1 - } - } - groups.forEach { group -> - val firstChapter = group.first() - val lastChapter = group.last() - val duration = if (firstChapter == lastChapter) { - firstChapter.duration - } else { - lastChapter.duration?.let { lastChapter.offset - firstChapter.offset + it } - } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected.size == chapters.size, + onCheckedChange = { checked -> + if (checked) { + selected.clear() + selected.addAll(0 until chapters.size) + } else { + selected.clear() + } + }, + colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) + ) + Text( + tr["download_all"] ?: "Select All", + style = MaterialTheme.typography.bodyMedium, + color = PurrfectOverlayPalette.textPrimary + ) + } - provideDownloadManagerClient( - mediaIdentifier = "${paramMap["STORY_ID"]}-${firstChapter.offset}-${lastChapter.offset}", - downloadSource = MediaDownloadSource.PUBLIC_STORY, - mediaAuthor = storyName, - forceAllowDuplicate = forceAllowDuplicate, - ).downloadDashMedia( - playlistUrl, - firstChapter.offset.plus(100), - duration - ) + 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 = { + val groups = mutableListOf>() + var lastIdx = -1 + chapters.forEachIndexed { index, info -> + if (selected.contains(index)) { + if (lastIdx == -1 || index != lastIdx + 1) groups.add(mutableListOf()) + groups.last().add(info) + lastIdx = index + } + } + groups.forEach { group -> + val first = group.first() + val last = group.last() + val duration = if (first == last) first.duration else last.duration?.let { last.offset - first.offset + it } + provideDownloadManagerClient("${paramMap["STORY_ID"]}-${first.offset}", storyName, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate) + .downloadDashMedia(playlistUrl, first.offset.plus(100), duration) + } + alertDialog.dismiss() + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) + ) { + Text(downloadStr) + } + } + } } } }.show() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index fd4591cb..d19f9f52 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -454,8 +454,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A append("├─ Opened: $processed snaps\n") append("├─ Queue: $remaining snaps\n") append("├─ Total Opened: $total snaps\n") - val speedText = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s" - append("└─ Speed: $currentSpeedText ($speedText)") + val speedNotion = if (remaining > 0) currentSpeedText else "Idle" + val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s" + append("└─ Speed: $speedNotion ($speedValue)\n\n") + if (config.showQueuePreview.get()) { append("\n\nQUEUE PREVIEW\n")