mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-28 04:34:21 +01:00
Expose StreamingTranscoder configuration options in sample app.
This commit is contained in:
committed by
Greyson Parrelli
parent
c7609f9a2a
commit
caa5e233df
@@ -5,7 +5,10 @@
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -24,14 +27,16 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
|
||||
android:name=".transcode.TranscodeTestActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
<activity
|
||||
android:name=".playback.PlaybackTestActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
/**
|
||||
* A dumping ground for constants that should be referenced across the sample app.
|
||||
*/
|
||||
internal const val MIN_VIDEO_MEGABITRATE = 2f
|
||||
internal const val DEFAULT_VIDEO_MEGABITRATE = 2f
|
||||
internal const val MAX_VIDEO_MEGABITRATE = 10f
|
||||
|
||||
enum class VideoResolution(val longEdge: Int, val shortEdge: Int) {
|
||||
SD(854, 480),
|
||||
HD(1280, 720),
|
||||
FHD(1920, 1080),
|
||||
WQHD(2560, 1440),
|
||||
UHD(3840, 2160);
|
||||
|
||||
fun getContentDescription(): String {
|
||||
return "Resolution with a long edge of $longEdge and a short edge of $shortEdge."
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
class TestTranscoder
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.UUID
|
||||
|
||||
data class TranscodeJobSnapshot(val media: Uri, val jobId: UUID)
|
||||
@@ -5,8 +5,11 @@
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
@@ -15,73 +18,64 @@ import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
import org.thoughtcrime.video.app.R
|
||||
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
|
||||
import org.thoughtcrime.video.app.transcode.composables.SelectInput
|
||||
import org.thoughtcrime.video.app.transcode.composables.SelectOutput
|
||||
import org.thoughtcrime.video.app.transcode.composables.TranscodingJobProgress
|
||||
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
||||
|
||||
/**
|
||||
* Visual entry point for testing transcoding in the video sample app.
|
||||
*/
|
||||
class TranscodeTestActivity : AppCompatActivity() {
|
||||
private val viewModel: TranscodeTestViewModel by viewModels()
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.initialize(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = applicationContext.getString(R.string.channel_name)
|
||||
val descriptionText = applicationContext.getString(R.string.channel_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val mChannel = NotificationChannel(getString(R.string.notification_channel_id), name, importance)
|
||||
mChannel.description = descriptionText
|
||||
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val videoUris = viewModel.selectedVideos
|
||||
val outputDir = viewModel.outputDirectory
|
||||
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
|
||||
if (transcodingJobs.value.isNotEmpty()) {
|
||||
transcodingJobs.value.forEach { workInfo ->
|
||||
val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = "...${workInfo.id.toString().takeLast(4)}", modifier = Modifier.padding(end = 16.dp).weight(1f))
|
||||
if (workInfo.state.isFinished) {
|
||||
LinearProgressIndicator(progress = 1f, trackColor = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(3f))
|
||||
} else if (currentProgress >= 0) {
|
||||
LinearProgressIndicator(progress = currentProgress / 100f, modifier = Modifier.weight(3f))
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.weight(3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledButton("Reset/Cancel") { viewModel.reset() }
|
||||
} else if (videoUris.isEmpty()) {
|
||||
LabeledButton("Select Videos") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
||||
} else if (outputDir == null) {
|
||||
LabeledButton("Select Output Directory") { outputDirRequest.launch(null) }
|
||||
} else {
|
||||
Text(text = "Selected videos:", modifier = Modifier.align(Alignment.Start).padding(16.dp))
|
||||
videoUris.forEach {
|
||||
Text(text = it.toString(), fontSize = 8.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.align(Alignment.Start).padding(horizontal = 16.dp))
|
||||
}
|
||||
LabeledButton(buttonLabel = "Transcode") {
|
||||
val videoUris = viewModel.selectedVideos
|
||||
val outputDir = viewModel.outputDirectory
|
||||
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
|
||||
if (transcodingJobs.value.isNotEmpty()) {
|
||||
TranscodingJobProgress(transcodingJobs = transcodingJobs.value, resetButtonOnClick = { viewModel.reset() })
|
||||
} else if (videoUris.isEmpty()) {
|
||||
SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
||||
} else if (outputDir == null) {
|
||||
SelectOutput { outputDirRequest.launch(null) }
|
||||
} else {
|
||||
ConfigureEncodingParameters(
|
||||
videoUris = videoUris,
|
||||
onAutoSettingsCheckChanged = { viewModel.useAutoTranscodingSettings = it },
|
||||
onRadioButtonSelected = { viewModel.videoResolution = it },
|
||||
onSliderValueChanged = { viewModel.videoMegaBitrate = it },
|
||||
onFastStartSettingCheckChanged = { viewModel.enableFastStart = it },
|
||||
onSequentialSettingCheckChanged = { viewModel.forceSequentialQueueProcessing = it },
|
||||
buttonClickListener = {
|
||||
viewModel.transcode()
|
||||
viewModel.selectedVideos = emptyList()
|
||||
viewModel.resetOutputDirectory()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Repository to perform various transcoding functions.
|
||||
*/
|
||||
class TranscodeTestRepository(context: Context) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val usedNotificationIds = emptySet<Int>()
|
||||
|
||||
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri): Map<UUID, Uri> {
|
||||
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map<UUID, Uri> {
|
||||
if (selectedVideos.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
@@ -34,20 +37,35 @@ class TranscodeTestRepository(context: Context) {
|
||||
while (usedNotificationIds.contains(notificationId)) {
|
||||
notificationId = Random.nextInt().absoluteValue
|
||||
}
|
||||
val inputData = Data.Builder()
|
||||
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
|
||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
|
||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||
|
||||
if (customTranscodingOptions != null) {
|
||||
inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge)
|
||||
inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge)
|
||||
inputData.putInt(TranscodeWorker.KEY_BIT_RATE, customTranscodingOptions.bitrate)
|
||||
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart)
|
||||
}
|
||||
|
||||
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
|
||||
.setInputData(createInputDataForWorkRequest(it, outputDirectory, notificationId))
|
||||
.setInputData(inputData.build())
|
||||
.addTag(TRANSCODING_WORK_TAG)
|
||||
.build()
|
||||
it to transcodeRequest
|
||||
}
|
||||
|
||||
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
|
||||
val requests = urisAndRequests.map { it.second }
|
||||
var continuation = workManager.beginWith(requests.first())
|
||||
for (request in requests.drop(1)) {
|
||||
continuation = continuation.then(request)
|
||||
if (forceSequentialProcessing) {
|
||||
var continuation = workManager.beginWith(requests.first())
|
||||
for (request in requests.drop(1)) {
|
||||
continuation = continuation.then(request)
|
||||
}
|
||||
continuation.enqueue()
|
||||
} else {
|
||||
workManager.enqueue(requests)
|
||||
}
|
||||
continuation.enqueue()
|
||||
return idsToUris
|
||||
}
|
||||
|
||||
@@ -58,19 +76,6 @@ class TranscodeTestRepository(context: Context) {
|
||||
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the input data bundle which includes the blur level to
|
||||
* update the amount of blur to be applied and the Uri to operate on
|
||||
* @return Data which contains the Image Uri as a String and blur level as an Integer
|
||||
*/
|
||||
private fun createInputDataForWorkRequest(selectedVideo: Uri, outputUri: Uri, notificationId: Int): Data {
|
||||
return Data.Builder()
|
||||
.putString(TranscodeWorker.KEY_INPUT_URI, selectedVideo.toString())
|
||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputUri.toString())
|
||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun cancelAllTranscodes() {
|
||||
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
|
||||
workManager.pruneWork()
|
||||
@@ -112,6 +117,8 @@ class TranscodeTestRepository(context: Context) {
|
||||
|
||||
private data class FileMetadata(val documentId: String, val label: String, val size: Long)
|
||||
|
||||
data class CustomTranscodingOptions(val videoResolution: VideoResolution, val bitrate: Int, val enableFastStart: Boolean)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranscodingTestRepository"
|
||||
const val TRANSCODING_WORK_TAG = "transcoding"
|
||||
|
||||
@@ -15,14 +15,24 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.work.WorkInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* ViewModel for the transcoding screen of the video sample app. See [TranscodeTestActivity].
|
||||
*/
|
||||
class TranscodeTestViewModel : ViewModel() {
|
||||
private lateinit var repository: TranscodeTestRepository
|
||||
private var backPressedRunnable = {}
|
||||
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
||||
|
||||
var outputDirectory: Uri? by mutableStateOf(null)
|
||||
private set
|
||||
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
|
||||
var videoMegaBitrate = DEFAULT_VIDEO_MEGABITRATE
|
||||
var videoResolution = VideoResolution.HD
|
||||
var useAutoTranscodingSettings = true
|
||||
var enableFastStart = true
|
||||
var forceSequentialQueueProcessing = false
|
||||
|
||||
fun initialize(context: Context) {
|
||||
repository = TranscodeTestRepository(context)
|
||||
@@ -31,7 +41,11 @@ class TranscodeTestViewModel : ViewModel() {
|
||||
|
||||
fun transcode() {
|
||||
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
|
||||
transcodingJobs = repository.transcode(selectedVideos, output)
|
||||
if (useAutoTranscodingSettings) {
|
||||
transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, null)
|
||||
} else {
|
||||
transcodingJobs = repository.transcode(selectedVideos, output, forceSequentialQueueProcessing, TranscodeTestRepository.CustomTranscodingOptions(videoResolution, (videoMegaBitrate * MEGABIT).roundToInt(), enableFastStart))
|
||||
}
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
||||
@@ -61,4 +75,8 @@ class TranscodeTestViewModel : ViewModel() {
|
||||
fun resetOutputDirectory() {
|
||||
outputDirectory = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEGABIT = 1000000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Context.NOTIFICATION_SERVICE
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.TaskStackBuilder
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -24,70 +23,118 @@ import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.video.StreamingTranscoder
|
||||
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
|
||||
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants
|
||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
|
||||
import org.thoughtcrime.video.app.R
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
|
||||
class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
/**
|
||||
* A WorkManager worker to transcode videos in the background. This utilizes [StreamingTranscoder].
|
||||
*/
|
||||
class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
@UnstableApi
|
||||
override suspend fun doWork(): Result {
|
||||
val logPrefix = "[Job ${id.toString().takeLast(4)}]"
|
||||
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
|
||||
if (notificationId < 0) {
|
||||
Log.w(TAG, "Notification ID was null!")
|
||||
Log.w(TAG, "$logPrefix Notification ID was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val inputUri = inputData.getString(KEY_INPUT_URI)
|
||||
if (inputUri == null) {
|
||||
Log.w(TAG, "Input URI was null!")
|
||||
Log.w(TAG, "$logPrefix Input URI was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val outputDirUri = inputData.getString(KEY_OUTPUT_URI)
|
||||
if (outputDirUri == null) {
|
||||
Log.w(TAG, "Output URI was null!")
|
||||
Log.w(TAG, "$logPrefix Output URI was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val input = DocumentFile.fromSingleUri(ctx, Uri.parse(inputUri))?.name
|
||||
val postProcessForFastStart = inputData.getBoolean(KEY_ENABLE_FASTSTART, true)
|
||||
val resolution = inputData.getInt(KEY_SHORT_EDGE, -1)
|
||||
val desiredBitrate = inputData.getInt(KEY_BIT_RATE, -1)
|
||||
|
||||
val input = DocumentFile.fromSingleUri(applicationContext, Uri.parse(inputUri))?.name
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Could not read input file name!")
|
||||
Log.w(TAG, "$logPrefix Could not read input file name!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val outputFileUri = createFile(Uri.parse(outputDirUri), "transcoded-${Instant.now()}-$input$OUTPUT_FILE_EXTENSION")
|
||||
val filenameBase = "transcoded-${Instant.now()}-$input"
|
||||
val tempFilename = "$filenameBase$TEMP_FILE_EXTENSION"
|
||||
val finalFilename = "$filenameBase$OUTPUT_FILE_EXTENSION"
|
||||
|
||||
if (outputFileUri == null) {
|
||||
Log.w(TAG, "Could not create output file!")
|
||||
val tempFile = createFile(Uri.parse(outputDirUri), tempFilename)
|
||||
if (tempFile == null) {
|
||||
Log.w(TAG, "$logPrefix Could not create temp file!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val datasource = WorkerMediaDataSource(ctx, Uri.parse(inputUri))
|
||||
val datasource = WorkerMediaDataSource(applicationContext, Uri.parse(inputUri))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
Log.w(TAG, "Transcoder is only supported on API 26+!")
|
||||
Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!")
|
||||
return Result.failure()
|
||||
}
|
||||
val transcoder = StreamingTranscoder(datasource, null, 50 * 1024 * 1024) // TODO: set options
|
||||
|
||||
val transcoder = if (resolution > 0 && desiredBitrate > 0) {
|
||||
StreamingTranscoder(datasource, null, desiredBitrate, resolution)
|
||||
} else {
|
||||
StreamingTranscoder(datasource, null, DEFAULT_FILE_SIZE_LIMIT)
|
||||
}
|
||||
|
||||
setForeground(createForegroundInfo(-1, notificationId))
|
||||
ctx.contentResolver.openFileDescriptor(outputFileUri, "w").use { it: ParcelFileDescriptor? ->
|
||||
if (it == null) {
|
||||
Log.w(TAG, "Could not open output file for writing!")
|
||||
|
||||
applicationContext.contentResolver.openFileDescriptor(tempFile.uri, "w").use { tempFd ->
|
||||
if (tempFd == null) {
|
||||
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
|
||||
return Result.failure()
|
||||
}
|
||||
transcoder.transcode(
|
||||
{ percent: Int ->
|
||||
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
|
||||
setForegroundAsync(createForegroundInfo(percent, notificationId))
|
||||
},
|
||||
FileOutputStream(it.fileDescriptor),
|
||||
{ isStopped }
|
||||
)
|
||||
return Result.success()
|
||||
transcoder.transcode({ percent: Int ->
|
||||
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
|
||||
setForegroundAsync(createForegroundInfo(percent, notificationId))
|
||||
}, FileOutputStream(tempFd.fileDescriptor), { isStopped })
|
||||
}
|
||||
Log.v(TAG, "$logPrefix Initial transcode completed successfully!")
|
||||
if (!postProcessForFastStart) {
|
||||
tempFile.renameTo(finalFilename)
|
||||
Log.v(TAG, "$logPrefix Rename successful.")
|
||||
} else {
|
||||
applicationContext.contentResolver.openFileDescriptor(tempFile.uri, "r").use { tempFd ->
|
||||
if (tempFd == null) {
|
||||
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val finalFile = createFile(Uri.parse(outputDirUri), finalFilename)
|
||||
if (finalFile == null) {
|
||||
Log.w(TAG, "$logPrefix Could not create final file for faststart processing!")
|
||||
return Result.failure()
|
||||
}
|
||||
applicationContext.contentResolver.openFileDescriptor(finalFile.uri, "w").use { finalFd ->
|
||||
if (finalFd == null) {
|
||||
Log.w(TAG, "$logPrefix Could not open output file for I/O!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
Mp4FaststartPostProcessor({ FileInputStream(tempFd.fileDescriptor) }, tempFd.statSize).processAndWriteTo(FileOutputStream(finalFd.fileDescriptor))
|
||||
|
||||
if (!tempFile.delete()) {
|
||||
Log.w(TAG, "$logPrefix Failed to delete temp file after processing!")
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.v(TAG, "$logPrefix Faststart postprocess successful.")
|
||||
}
|
||||
Log.v(TAG, "$logPrefix Overall transcode job successful.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
|
||||
@@ -96,23 +143,19 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
||||
val cancel = applicationContext.getString(R.string.cancel_transcode)
|
||||
val intent = WorkManager.getInstance(applicationContext)
|
||||
.createCancelPendingIntent(getId())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = applicationContext.getString(R.string.channel_name)
|
||||
val descriptionText = applicationContext.getString(R.string.channel_description)
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val mChannel = NotificationChannel(id, name, importance)
|
||||
mChannel.description = descriptionText
|
||||
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
val transcodeActivityIntent = Intent(applicationContext, TranscodeTestActivity::class.java)
|
||||
val pendingIntent: PendingIntent? = TaskStackBuilder.create(applicationContext).run {
|
||||
addNextIntentWithParentStack(transcodeActivityIntent)
|
||||
getPendingIntent(0,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, id)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setProgress(100, progress, progress >= 0)
|
||||
.setProgress(100, progress, progress <= 0)
|
||||
.setSmallIcon(R.drawable.ic_work_notification)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_delete, cancel, intent)
|
||||
.build()
|
||||
|
||||
@@ -123,8 +166,8 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFile(treeUri: Uri, filename: String): Uri? {
|
||||
return DocumentFile.fromTreeUri(ctx, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)?.uri
|
||||
private fun createFile(treeUri: Uri, filename: String): DocumentFile? {
|
||||
return DocumentFile.fromTreeUri(applicationContext, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)
|
||||
}
|
||||
|
||||
private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() {
|
||||
@@ -154,9 +197,15 @@ class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : Coro
|
||||
companion object {
|
||||
private const val TAG = "TranscodeWorker"
|
||||
private const val OUTPUT_FILE_EXTENSION = ".mp4"
|
||||
private const val TEMP_FILE_EXTENSION = ".tmp"
|
||||
private const val DEFAULT_FILE_SIZE_LIMIT: Long = 50 * 1024 * 1024
|
||||
const val KEY_INPUT_URI = "input_uri"
|
||||
const val KEY_OUTPUT_URI = "output_uri"
|
||||
const val KEY_PROGRESS = "progress"
|
||||
const val KEY_LONG_EDGE = "resolution_long_edge"
|
||||
const val KEY_SHORT_EDGE = "resolution_short_edge"
|
||||
const val KEY_BIT_RATE = "video_bit_rate"
|
||||
const val KEY_ENABLE_FASTSTART = "video_enable_faststart"
|
||||
const val KEY_NOTIFICATION_ID = "notification_id"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import android.net.Uri
|
||||
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.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.thoughtcrime.video.app.transcode.DEFAULT_VIDEO_MEGABITRATE
|
||||
import org.thoughtcrime.video.app.transcode.MAX_VIDEO_MEGABITRATE
|
||||
import org.thoughtcrime.video.app.transcode.MIN_VIDEO_MEGABITRATE
|
||||
import org.thoughtcrime.video.app.transcode.VideoResolution
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A view that shows the queue of video URIs to encode, and allows you to change the encoding options.
|
||||
*/
|
||||
@Composable
|
||||
fun ConfigureEncodingParameters(
|
||||
videoUris: List<Uri>,
|
||||
onAutoSettingsCheckChanged: (Boolean) -> Unit,
|
||||
onRadioButtonSelected: (VideoResolution) -> Unit,
|
||||
onSliderValueChanged: (Float) -> Unit,
|
||||
onFastStartSettingCheckChanged: (Boolean) -> Unit,
|
||||
onSequentialSettingCheckChanged: (Boolean) -> Unit,
|
||||
buttonClickListener: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialSettingsAutoSelected: Boolean = true
|
||||
) {
|
||||
var sliderPosition by remember { mutableFloatStateOf(DEFAULT_VIDEO_MEGABITRATE) }
|
||||
var selectedResolution by remember { mutableStateOf(VideoResolution.HD) }
|
||||
val autoSettingsChecked = remember { mutableStateOf(initialSettingsAutoSelected) }
|
||||
val fastStartChecked = remember { mutableStateOf(true) }
|
||||
val sequentialProcessingChecked = remember { mutableStateOf(false) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Selected videos:",
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(16.dp)
|
||||
)
|
||||
videoUris.forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
fontSize = 8.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = autoSettingsChecked.value,
|
||||
onCheckedChange = { isChecked ->
|
||||
autoSettingsChecked.value = isChecked
|
||||
onAutoSettingsCheckChanged(isChecked)
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = "Calculate Output Settings Automatically",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
if (!autoSettingsChecked.value) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
) {
|
||||
VideoResolution.values().forEach {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.selectable(
|
||||
selected = selectedResolution == it,
|
||||
onClick = {
|
||||
selectedResolution = it
|
||||
onRadioButtonSelected(it)
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedResolution == it,
|
||||
onClick = null,
|
||||
modifier = Modifier.semantics { contentDescription = it.getContentDescription() }
|
||||
)
|
||||
Text(
|
||||
text = "${it.shortEdge}p",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Slider(
|
||||
value = sliderPosition,
|
||||
onValueChange = {
|
||||
sliderPosition = it
|
||||
onSliderValueChanged(it)
|
||||
},
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.secondary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.secondary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
),
|
||||
steps = 5,
|
||||
valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE,
|
||||
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
)
|
||||
Text(text = "${sliderPosition.roundToInt()} Mbit/s")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = fastStartChecked.value,
|
||||
onCheckedChange = { isChecked ->
|
||||
fastStartChecked.value = isChecked
|
||||
onFastStartSettingCheckChanged(isChecked)
|
||||
}
|
||||
)
|
||||
Text(text = "Enable postprocessing for faststart", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = sequentialProcessingChecked.value,
|
||||
onCheckedChange = { isChecked ->
|
||||
sequentialProcessingChecked.value = isChecked
|
||||
onSequentialSettingCheckChanged(isChecked)
|
||||
}
|
||||
)
|
||||
Text(text = "Force Sequential Queue Processing", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
LabeledButton(buttonLabel = "Transcode", onClick = buttonClickListener, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ConfigurationScreenPreviewChecked() {
|
||||
ConfigureEncodingParameters(
|
||||
videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")),
|
||||
onAutoSettingsCheckChanged = {},
|
||||
onRadioButtonSelected = {},
|
||||
onSliderValueChanged = {},
|
||||
onFastStartSettingCheckChanged = {},
|
||||
onSequentialSettingCheckChanged = {},
|
||||
buttonClickListener = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ConfigurationScreenPreviewUnchecked() {
|
||||
ConfigureEncodingParameters(
|
||||
videoUris = listOf(Uri.parse("content://1"), Uri.parse("content://2")),
|
||||
onAutoSettingsCheckChanged = {},
|
||||
onRadioButtonSelected = {},
|
||||
onSliderValueChanged = {},
|
||||
onFastStartSettingCheckChanged = {},
|
||||
onSequentialSettingCheckChanged = {},
|
||||
buttonClickListener = {},
|
||||
initialSettingsAutoSelected = false
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that prompts you to select input videos for transcoding.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectInput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LabeledButton("Select Videos", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun InputSelectionPreview() {
|
||||
SelectInput { }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that prompts you to select an output directory that transcoded videos will be saved to.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectOutput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LabeledButton("Select Output Directory", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun OutputSelectionPreview() {
|
||||
SelectOutput { }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.work.WorkInfo
|
||||
import org.thoughtcrime.video.app.transcode.TranscodeWorker
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that shows the current encodes in progress.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscodingJobProgress(transcodingJobs: List<WorkInfo>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
transcodingJobs.forEach { workInfo ->
|
||||
val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
val progressIndicatorModifier = Modifier.weight(3f)
|
||||
Text(
|
||||
text = "Job ${workInfo.id.toString().takeLast(4)}",
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
if (workInfo.state.isFinished) {
|
||||
Text(text = workInfo.state.toString(), textAlign = TextAlign.Center, modifier = progressIndicatorModifier)
|
||||
} else if (currentProgress >= 0) {
|
||||
LinearProgressIndicator(progress = currentProgress / 100f, modifier = progressIndicatorModifier)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = progressIndicatorModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledButton("Reset/Cancel", onClick = resetButtonOnClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProgressScreenPreview() {
|
||||
TranscodingJobProgress(emptyList(), resetButtonOnClick = {})
|
||||
}
|
||||
Reference in New Issue
Block a user