Expose StreamingTranscoder configuration options in sample app.

This commit is contained in:
Nicholas Tinsley
2024-01-12 19:11:54 -05:00
committed by Greyson Parrelli
parent c7609f9a2a
commit caa5e233df
22 changed files with 738 additions and 257 deletions

View File

@@ -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"

View File

@@ -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."
}
}

View File

@@ -1,8 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
class TestTranscoder

View File

@@ -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)

View File

@@ -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()
}
}
)
}
}
}

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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
)
}

View File

@@ -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 { }
}

View File

@@ -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 { }
}

View File

@@ -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 = {})
}