Made sub exchange default and fixed #2930. Also fixed unrelated export issues.

This commit is contained in:
Koen J
2026-04-22 15:02:22 +02:00
parent 05afa12274
commit 0f7fb9059b
3 changed files with 112 additions and 41 deletions
@@ -313,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
var showSubscriptionGroups: Boolean = true; var showSubscriptionGroups: Boolean = true;
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6) @FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
var useSubscriptionExchange: Boolean = false; var useSubscriptionExchange: Boolean = true;
@AdvancedField @AdvancedField
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
@@ -13,8 +13,10 @@ import com.futo.platformplayer.helpers.FileHelper.Companion.sanitizeFileName
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -51,8 +53,12 @@ class VideoExport {
val outputFile: DocumentFile?; val outputFile: DocumentFile?;
val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set"); val downloadRoot = documentRoot ?: StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val safeBaseName = videoLocal.name.sanitizeFileName(true).ifBlank {
"video_${UUID.randomUUID()}"
}
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = "$safeBaseName.mp4"
val f = downloadRoot.createFile("video/mp4", outputFileName) val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
@@ -60,7 +66,9 @@ class VideoExport {
val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4"); val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try { try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) }; combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream -> val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) }; copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
} finally { } finally {
@@ -68,25 +76,29 @@ class VideoExport {
} }
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = "$safeBaseName.${VideoDownload.videoContainerToExtension(v.container)}"
val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName) val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream -> val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) }; copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = "$safeBaseName.${VideoDownload.audioContainerToExtension(a.container)}"
val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName) val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");
context.contentResolver.openOutputStream(f.uri)?.use { outputStream -> val outputStream = context.contentResolver.openOutputStream(f.uri)
?: throw IOException("Failed to open output stream for ${f.uri}")
outputStream.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) }; copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
} }
@@ -98,29 +110,48 @@ class VideoExport {
return@coroutineScope outputFile; return@coroutineScope outputFile;
} }
private fun ffmpegArg(value: String): String {
return "\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"") + "\""
}
private fun resumeSuccessSafely(continuation: CancellableContinuation<Unit>) {
if (!continuation.isActive) return
try {
continuation.resumeWith(Result.success(Unit))
} catch (_: IllegalStateException) {
}
}
private fun resumeFailureSafely(continuation: CancellableContinuation<Unit>, throwable: Throwable) {
if (!continuation.isActive) return
try {
continuation.resumeWithException(throwable)
} catch (_: IllegalStateException) {
}
}
private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) { private suspend fun combine(inputPathAudio: String?, inputPathVideo: String?, inputPathSubtitles: String?, outputPath: String, duration: Double, onProgress: ((Double) -> Unit)? = null) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
//ffmpeg -i a.mp4 -i b.m4a -scodec mov_text -i c.vtt -map 0:v -map 1:a -map 2 -c:v copy -c:a copy -c:s mov_text output.mp4
val cmdBuilder = StringBuilder("-y") val cmdBuilder = StringBuilder("-y")
var counter = 0 var counter = 0
if (inputPathVideo != null) { if (inputPathVideo != null) {
cmdBuilder.append(" -i $inputPathVideo") cmdBuilder.append(" -i ${ffmpegArg(inputPathVideo)}")
} }
if (inputPathAudio != null) { if (inputPathAudio != null) {
cmdBuilder.append(" -i $inputPathAudio") cmdBuilder.append(" -i ${ffmpegArg(inputPathAudio)}")
} }
if (inputPathSubtitles != null) { if (inputPathSubtitles != null) {
val subtitleExtension = File(inputPathSubtitles).extension val subtitleExtension = File(inputPathSubtitles).extension.lowercase()
when (subtitleExtension) {
val codec = when (subtitleExtension.lowercase()) { "srt", "vtt" -> {}
"srt" -> "mov_text"
"vtt" -> "webvtt"
else -> throw Exception("Unsupported subtitle format: $subtitleExtension") else -> throw Exception("Unsupported subtitle format: $subtitleExtension")
} }
cmdBuilder.append(" -scodec $codec -i $inputPathSubtitles") cmdBuilder.append(" -i ${ffmpegArg(inputPathSubtitles)}")
} }
if (inputPathVideo != null) { if (inputPathVideo != null) {
@@ -132,6 +163,7 @@ class VideoExport {
if (inputPathSubtitles != null) { if (inputPathSubtitles != null) {
cmdBuilder.append(" -map ${counter++}") cmdBuilder.append(" -map ${counter++}")
cmdBuilder.append(" -c:s mov_text")
} }
if (inputPathVideo != null) { if (inputPathVideo != null) {
@@ -140,33 +172,44 @@ class VideoExport {
if (inputPathAudio != null) { if (inputPathAudio != null) {
cmdBuilder.append(" -c:a copy") cmdBuilder.append(" -c:a copy")
} }
if (inputPathAudio != null) {
cmdBuilder.append(" -c:s mov_text")
}
cmdBuilder.append(" $outputPath") cmdBuilder.append(" ${ffmpegArg(outputPath)}")
val cmd = cmdBuilder.toString() val cmd = cmdBuilder.toString()
Logger.i(TAG, "Used command: $cmd"); Logger.i(TAG, "Used command: $cmd");
val statisticsCallback = StatisticsCallback { statistics -> val statisticsCallback = StatisticsCallback { statistics ->
val time = statistics.time.toDouble() / 1000.0 val time = statistics.time.toDouble() / 1000.0
val progressPercentage = (time / duration) val progressPercentage = if (duration > 0.0) {
onProgress?.invoke(progressPercentage) (time / duration).coerceIn(0.0, 1.0)
} else {
0.0
}
onProgress?.let { callback ->
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
callback(progressPercentage)
}
}
} }
val executorService = Executors.newSingleThreadExecutor() val executorService = Executors.newSingleThreadExecutor()
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { try {
continuation.resumeWith(Result.success(Unit)) if (ReturnCode.isSuccess(session.returnCode)) {
} else { resumeSuccessSafely(continuation)
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
"Command cancelled"
} else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
}
resumeFailureSafely(continuation, RuntimeException(errorMessage))
} }
continuation.resumeWithException(RuntimeException(errorMessage)) } finally {
executorService.shutdown()
} }
}, },
LogCallback { Logger.v(TAG, it.message) }, LogCallback { Logger.v(TAG, it.message) },
@@ -176,6 +219,7 @@ class VideoExport {
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
session.cancel() session.cancel()
executorService.shutdown()
} }
} }
} }
@@ -196,14 +240,24 @@ class VideoExport {
val totalBytes = srcFile.length() val totalBytes = srcFile.length()
var bytesCopied: Long = 0 var bytesCopied: Long = 0
if (totalBytes == 0L) {
onProgress?.let {
withContext(Dispatchers.Main) {
it(1.0)
}
}
return@withContext
}
var bytesRead: Int var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) { while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead) outputStream.write(buffer, 0, bytesRead)
bytesCopied += bytesRead.toLong() bytesCopied += bytesRead.toLong()
onProgress?.let { onProgress?.let {
val progress = (bytesCopied / totalBytes.toDouble()).coerceIn(0.0, 1.0)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
it(bytesCopied / totalBytes.toDouble()) it(progress)
} }
} }
} }
@@ -1,18 +1,35 @@
package com.futo.platformplayer.helpers package com.futo.platformplayer.helpers
import java.text.Normalizer
class FileHelper { class FileHelper {
companion object { companion object {
fun String.sanitizeFileName(allowSpace: Boolean = false): String { fun String.sanitizeFileName(allowSpace: Boolean = false): String {
return this.filter { val normalized = Normalizer.normalize(this, Normalizer.Form.NFC)
(it in '0' .. '9') ||
(it in 'a'..'z') || val cleaned = buildString(normalized.length) {
(it in 'A'..'Z') || for (ch in normalized) {
(it == '-' || it == '.' || it == '_' || (it == ' ' && allowSpace)) || when {
(it in '丁'..'龤') || //Chinese/Kanji ch == '\u0000' -> {}
(it in '\u3040'..'\u309f') || //Hiragana Character.isISOControl(ch) -> {}
(it in '\u30A0'..'\u30ff') || //Katakana ch == '/' || ch == '\\' || ch == ':' || ch == '*' ||
(it in '\u0600'..'\u06FF') //Arabic ch == '?' || ch == '"' || ch == '<' || ch == '>' || ch == '|' -> append('_')
}; //Chinese ch == ' ' && !allowSpace -> append('_')
else -> append(ch)
}
}
}
val collapsed = if (allowSpace) {
cleaned.replace(Regex("""\s+"""), " ")
} else {
cleaned.replace(Regex("""\s+"""), "_")
}
return collapsed
.trim()
.trimEnd('.')
} }
} }
} }