Refactor and improve video demo app.

This commit is contained in:
Greyson Parrelli
2026-02-06 10:40:52 -05:00
parent d5b2f4fdd3
commit 8d749c404f
26 changed files with 736 additions and 1173 deletions

View File

@@ -52,15 +52,15 @@ android {
}
dependencies {
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.bundles.media3)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(project(":lib:video"))
implementation(project(":core:util"))
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
implementation(project(":core:ui"))
implementation(libs.androidx.compose.ui.tooling.core)
implementation(libs.androidx.compose.ui.test.manifest)
androidTestImplementation(testLibs.junit.junit)

View File

@@ -3,12 +3,7 @@
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<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_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
@@ -27,21 +22,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</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"
tools:node="merge" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>

View File

@@ -5,153 +5,133 @@
package org.thoughtcrime.video.app
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.MediaScannerConnection
import android.os.Bundle
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.activity.enableEdgeToEdge
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Checkbox
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.tooling.preview.Preview
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.thoughtcrime.video.app.playback.PlaybackTestActivity
import org.thoughtcrime.video.app.transcode.TranscodeTestActivity
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import org.thoughtcrime.video.app.transcode.TranscodeTestViewModel
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
import org.thoughtcrime.video.app.transcode.composables.TranscodingScreen
import org.thoughtcrime.video.app.transcode.composables.VideoSelectionScreen
import org.thoughtcrime.video.app.ui.theme.SignalTheme
/**
* Main activity for this sample app.
*/
class MainActivity : AppCompatActivity() {
companion object {
private val TAG = Log.tag(MainActivity::class.java)
private var appLaunch = true
}
private val sharedPref: SharedPreferences by lazy {
getSharedPreferences(
getString(R.string.preference_file_key),
Context.MODE_PRIVATE
)
}
enum class Screen : NavKey {
VideoSelection,
Configuration,
Transcoding
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Log.initialize(AndroidLogger)
val startPlaybackScreen = { saveChoice: Boolean -> proceed(Screen.TEST_PLAYBACK, saveChoice) }
val startTranscodeScreen = { saveChoice: Boolean -> proceed(Screen.TEST_TRANSCODE, saveChoice) }
setContent {
Body(startPlaybackScreen, startTranscodeScreen)
}
refreshMediaProviderForExternalStorage(this, arrayOf("video/*"))
if (appLaunch) {
appLaunch = false
getLaunchChoice()?.let {
proceed(it, false)
}
}
}
@Composable
private fun Body(startPlaybackScreen: (Boolean) -> Unit, startTranscodeScreen: (Boolean) -> Unit) {
var rememberChoice by remember { mutableStateOf(getLaunchChoice() != null) }
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
SignalTheme {
Surface(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
color = MaterialTheme.colorScheme.background
) {
LabeledButton("Test Playback") {
startPlaybackScreen(rememberChoice)
}
LabeledButton("Test Transcode") {
startTranscodeScreen(rememberChoice)
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = rememberChoice,
onCheckedChange = { isChecked ->
rememberChoice = isChecked
if (!isChecked) {
clearLaunchChoice()
}
}
)
Text(text = "Remember & Skip This Screen", style = MaterialTheme.typography.labelLarge)
}
TranscodeApp()
}
}
}
}
private fun getLaunchChoice(): Screen? {
val screenName = sharedPref.getString(getString(R.string.preference_activity_shortcut_key), null) ?: return null
return Screen.valueOf(screenName)
}
private fun clearLaunchChoice() {
with(sharedPref.edit()) {
remove(getString(R.string.preference_activity_shortcut_key))
apply()
}
}
private fun saveLaunchChoice(choice: Screen) {
with(sharedPref.edit()) {
putString(getString(R.string.preference_activity_shortcut_key), choice.name)
apply()
}
}
private fun refreshMediaProviderForExternalStorage(context: Context, mimeTypes: Array<String>) {
val rootPath = Environment.getExternalStorageDirectory().absolutePath
MediaScannerConnection.scanFile(
context,
arrayOf<String>(rootPath),
mimeTypes
) { _, _ ->
Log.i(TAG, "Re-scan of external storage for media completed.")
}
}
private fun proceed(screen: Screen, saveChoice: Boolean) {
if (saveChoice) {
saveLaunchChoice(screen)
}
when (screen) {
Screen.TEST_PLAYBACK -> startActivity(Intent(this, PlaybackTestActivity::class.java))
Screen.TEST_TRANSCODE -> startActivity(Intent(this, TranscodeTestActivity::class.java))
}
}
private enum class Screen {
TEST_PLAYBACK,
TEST_TRANSCODE
}
@Preview
@Composable
private fun PreviewBody() {
Body({}, {})
}
}
@Composable
private fun TranscodeApp() {
val backStack = rememberNavBackStack(Screen.VideoSelection)
val viewModel: TranscodeTestViewModel = viewModel()
val context = LocalContext.current
val pickMedia = androidx.activity.compose.rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
viewModel.selectedVideo = uri
backStack.add(Screen.Configuration)
}
}
NavDisplay(
backStack = backStack,
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitonSpec,
entryProvider = entryProvider {
addEntryProvider(
key = Screen.VideoSelection,
contentKey = Screen.VideoSelection,
metadata = emptyMap()
) { _: Screen ->
VideoSelectionScreen(
onSelectVideo = {
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
}
)
}
addEntryProvider(
key = Screen.Configuration,
contentKey = Screen.Configuration,
metadata = emptyMap()
) { _: Screen ->
ConfigureEncodingParameters(
onTranscodeClicked = {
viewModel.startTranscode(context)
backStack.remove(Screen.Configuration)
backStack.add(Screen.Transcoding)
},
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
viewModel = viewModel
)
}
addEntryProvider(
key = Screen.Transcoding,
contentKey = Screen.Transcoding,
metadata = emptyMap()
) { _: Screen ->
val transcodingState by viewModel.transcodingState.collectAsStateWithLifecycle()
TranscodingScreen(
state = transcodingState,
onCancel = { viewModel.cancelTranscode() },
onReset = {
viewModel.reset()
backStack.remove(Screen.Transcoding)
}
)
}
}
)
}

View File

@@ -1,107 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.ui.PlayerView
import org.thoughtcrime.video.app.ui.composables.LabeledButton
import org.thoughtcrime.video.app.ui.theme.SignalTheme
class PlaybackTestActivity : AppCompatActivity() {
private val viewModel: PlaybackTestViewModel by viewModels()
private lateinit var exoPlayer: ExoPlayer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.initialize(this)
exoPlayer = ExoPlayer.Builder(this).build()
setContent {
SignalTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val videoUri = viewModel.selectedVideo
if (videoUri == null) {
LabeledButton("Select Video") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
} else {
LabeledButton("Play Video") { viewModel.updateMediaSource(this@PlaybackTestActivity) }
LabeledButton("Play Video with slow download") { viewModel.updateMediaSourceTrickle(this@PlaybackTestActivity) }
ExoVideoView(source = viewModel.mediaSource, exoPlayer = exoPlayer)
}
}
}
}
}
}
override fun onPause() {
super.onPause()
exoPlayer.pause()
}
override fun onDestroy() {
super.onDestroy()
viewModel.releaseCache()
exoPlayer.stop()
exoPlayer.release()
}
/**
* This launches the system media picker and stores the resulting URI.
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
Log.d("PlaybackPicker", "Selected URI: $uri")
viewModel.selectedVideo = uri
viewModel.updateMediaSource(this)
} else {
Log.d("PlaybackPicker", "No media selected")
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun ExoVideoView(source: MediaSource, exoPlayer: ExoPlayer, modifier: Modifier = Modifier) {
exoPlayer.playWhenReady = false
exoPlayer.setMediaSource(source)
exoPlayer.prepare()
AndroidView(factory = { context ->
PlayerView(context).apply {
player = exoPlayer
}
}, modifier = modifier)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
SignalTheme {
LabeledButton("Preview Render") {}
}
}

View File

@@ -1,93 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SilenceMediaSource
import java.io.File
/**
* Main screen view model for the video sample app.
*/
@OptIn(UnstableApi::class)
class PlaybackTestViewModel : ViewModel() {
// Initialize an silent media source before the user selects a video. This is the closest I could find to an "empty" media source while still being nullsafe.
private val value by lazy {
val factory = SilenceMediaSource.Factory()
factory.setDurationUs(1000)
factory.createMediaSource()
}
private lateinit var cache: Cache
var selectedVideo: Uri? by mutableStateOf(null)
var mediaSource: MediaSource by mutableStateOf(value)
private set
/**
* Initialize the backing cache. This is a file in the app's cache directory that has a random suffix to ensure you get cache misses on a new app launch.
*
* @param context required to get the file path of the cache directory.
*/
fun initialize(context: Context) {
val cacheDir = File(context.cacheDir.absolutePath)
cache = SimpleCache(File(cacheDir, getRandomString(12)), NoOpCacheEvictor())
}
fun updateMediaSource(context: Context) {
selectedVideo?.let {
mediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)).createMediaSource(MediaItem.fromUri(it))
}
}
/**
* Replaces the media source with one that has a latency to each read from the media source, simulating network latency.
* It stores the result in a cache (that does not have a penalty) to better mimic real-world performance:
* once a chunk is downloaded from the network, it will not have to be re-fetched.
*
* @param context
*/
fun updateMediaSourceTrickle(context: Context) {
selectedVideo?.let {
val cacheFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(SlowDataSource.Factory(context, 10))
mediaSource = ProgressiveMediaSource.Factory(cacheFactory).createMediaSource(MediaItem.fromUri(it))
}
}
fun releaseCache() {
cache.release()
}
/**
* Get random string. Will always return at least one character.
*
* @param length length of the returned string.
* @return a string composed of random alphanumeric characters of the specified length (minimum of 1).
*/
private fun getRandomString(length: Int): String {
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return (1..length.coerceAtLeast(1))
.map { allowedChars.random() }
.joinToString("")
}
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.playback
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
/**
* This wraps a [DefaultDataSource] and adds [latency] to each read. This is intended to approximate a slow/shoddy network connection that drip-feeds in data.
*
* @property latency the amount, in milliseconds, that each read should be delayed. A good proxy for network ping.
* @constructor
*
* @param context used to initialize the underlying [DefaultDataSource.Factory]
*/
@OptIn(UnstableApi::class)
class SlowDataSource(context: Context, private val latency: Long) : DataSource {
private val internalDataSource: DataSource = DefaultDataSource.Factory(context).createDataSource()
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
Thread.sleep(latency)
return internalDataSource.read(buffer, offset, length)
}
override fun addTransferListener(transferListener: TransferListener) {
internalDataSource.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
return internalDataSource.open(dataSpec)
}
override fun getUri(): Uri? {
return internalDataSource.uri
}
override fun close() {
return internalDataSource.close()
}
class Factory(private val context: Context, private val latency: Long) : DataSource.Factory {
override fun createDataSource(): DataSource {
return SlowDataSource(context, latency)
}
}
}

View File

@@ -1,141 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* 2SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.ViewGroup
import androidx.activity.compose.setContent
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.transcode.composables.WorkState
import org.thoughtcrime.video.app.ui.theme.SignalTheme
/**
* Visual entry point for testing transcoding in the video sample app.
*/
class TranscodeTestActivity : AppCompatActivity() {
private val TAG = "TranscodeTestActivity"
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) {
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsStateWithLifecycle(emptyList())
if (transcodingJobs.value.isNotEmpty()) {
TranscodingJobProgress(transcodingJobs = transcodingJobs.value.map { WorkState.fromInfo(it) }, resetButtonOnClick = { viewModel.reset() })
} else if (viewModel.selectedVideos.isEmpty()) {
SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
} else if (viewModel.outputDirectory == null) {
SelectOutput { outputDirRequest.launch(null) }
} else {
ConfigureEncodingParameters(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
viewModel = viewModel
)
}
}
}
}
getComposeView()?.keepScreenOn = true
if (Build.VERSION.SDK_INT >= 33) {
val notificationPermissionStatus = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
Log.v(TAG, "Notification permission status: $notificationPermissionStatus")
if (notificationPermissionStatus != PackageManager.PERMISSION_GRANTED) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
showPermissionRationaleDialog { _, _ -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
} else {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
private fun showPermissionRationaleDialog(okListener: DialogInterface.OnClickListener) {
MaterialAlertDialogBuilder(this)
.setTitle("The system will request the notification permission.")
.setMessage("This permission is required to show the transcoding progress in the notification tray.")
.setPositiveButton("Ok", okListener)
.show()
}
/**
* This launches the system media picker and stores the resulting URI.
*/
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
Log.d(TAG, "Selected URI: $uris")
viewModel.selectedVideos = uris
viewModel.resetOutputDirectory()
} else {
Log.d(TAG, "No media selected")
}
}
private val outputDirRequest = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
uri?.let {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
viewModel.setOutputDirectoryAndCleanFailedTranscodes(this, it)
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission allowed: $isGranted")
}
private fun getComposeView(): ComposeView? {
return window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView
}
}

View File

@@ -5,110 +5,235 @@
package org.thoughtcrime.video.app.transcode
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.video.StreamingTranscoder
import org.thoughtcrime.securesms.video.TranscodingPreset
import java.util.UUID
import kotlin.math.absoluteValue
import kotlin.random.Random
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
import java.time.Instant
/**
* Repository to perform various transcoding functions.
* Repository that performs video transcoding using coroutines.
*/
class TranscodeTestRepository(context: Context) {
private val workManager = WorkManager.getInstance(context)
private val usedNotificationIds = emptySet<Int>()
class TranscodeTestRepository {
private fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset? = null, customTranscodingOptions: CustomTranscodingOptions? = null): Map<UUID, Uri> {
if (customTranscodingOptions == null && transcodingPreset == null) {
throw IllegalArgumentException("Must define either custom options or transcoding preset!")
} else if (customTranscodingOptions != null && transcodingPreset != null) {
throw IllegalArgumentException("Cannot define both custom options and transcoding preset!")
data class TranscodeResult(val outputUri: Uri, val originalFile: File, val originalSize: Long, val outputSize: Long)
/**
* Transcode a video using a [TranscodingPreset] and save the result to the Downloads folder.
*/
@RequiresApi(26)
suspend fun transcodeWithPreset(
context: Context,
inputUri: Uri,
preset: TranscodingPreset,
enableFastStart: Boolean,
enableAudioRemux: Boolean,
onProgress: (Int) -> Unit
): TranscodeResult {
return doTranscode(context, inputUri, enableFastStart, onProgress) { inputFile ->
val dataSource = FileMediaDataSource(inputFile)
StreamingTranscoder(dataSource, null, preset, DEFAULT_FILE_SIZE_LIMIT, enableAudioRemux)
}
}
if (selectedVideos.isEmpty()) {
return emptyMap()
/**
* Transcode a video using custom parameters and save the result to the Downloads folder.
*/
@RequiresApi(26)
suspend fun transcodeWithCustomOptions(
context: Context,
inputUri: Uri,
options: CustomTranscodingOptions,
onProgress: (Int) -> Unit
): TranscodeResult {
return doTranscode(context, inputUri, options.enableFastStart, onProgress) { inputFile ->
val dataSource = FileMediaDataSource(inputFile)
StreamingTranscoder.createManuallyForTesting(
dataSource,
null,
options.videoCodec,
options.videoBitrate,
options.audioBitrate,
options.videoResolution.shortEdge,
options.enableAudioRemux
)
}
}
val urisAndRequests = selectedVideos.map {
var notificationId = Random.nextInt().absoluteValue
while (usedNotificationIds.contains(notificationId)) {
notificationId = Random.nextInt().absoluteValue
@RequiresApi(26)
private suspend fun doTranscode(
context: Context,
inputUri: Uri,
enableFastStart: Boolean,
onProgress: (Int) -> Unit,
createTranscoder: (File) -> StreamingTranscoder
): TranscodeResult {
val inputFilename = inputUri.lastPathSegment?.substringAfterLast('/') ?: "input"
val baseName = inputFilename.substringBeforeLast('.')
val filenameBase = "transcoded-${Instant.now()}-$baseName"
val tempTranscodeFilename = "$filenameBase.tmp"
val outputFilename = "$filenameBase.mp4"
val inputFile = File(context.filesDir, "original-${System.currentTimeMillis()}.mp4")
val tempFile = File(context.filesDir, tempTranscodeFilename)
val coroutineContext = currentCoroutineContext()
var success = false
try {
// Copy input to internal storage for random access
Log.i(TAG, "Copying input to internal storage...")
context.contentResolver.openInputStream(inputUri).use { inputStream ->
requireNotNull(inputStream) { "Could not open input URI" }
inputFile.outputStream().use { out ->
inputStream.copyTo(out)
}
}
val inputData = Data.Builder()
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
Log.i(TAG, "Input copy complete. Size: ${inputFile.length()}")
if (transcodingPreset != null) {
inputData.putString(TranscodeWorker.KEY_TRANSCODING_PRESET_NAME, transcodingPreset.name)
} else if (customTranscodingOptions != null) {
inputData.putString(TranscodeWorker.KEY_VIDEO_CODEC, customTranscodingOptions.videoCodec)
inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge)
inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge)
inputData.putInt(TranscodeWorker.KEY_VIDEO_BIT_RATE, customTranscodingOptions.videoBitrate)
inputData.putInt(TranscodeWorker.KEY_AUDIO_BIT_RATE, customTranscodingOptions.audioBitrate)
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart)
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_AUDIO_REMUX, customTranscodingOptions.enableAudioRemux)
coroutineContext.ensureActive()
// Transcode
val transcoder = createTranscoder(inputFile)
Log.i(TAG, "Starting transcode...")
val mdatSize: Long
tempFile.outputStream().use { outputStream ->
mdatSize = transcoder.transcode(
{ percent -> onProgress(percent) },
outputStream
) { !coroutineContext.isActive }
}
val originalSize = inputFile.length()
val outputSize = tempFile.length()
Log.i(TAG, "Transcode complete. Output size: $outputSize, mdat size: $mdatSize")
coroutineContext.ensureActive()
// Save to Downloads
val outputUri = if (enableFastStart) {
saveToDownloadsWithFastStart(context, tempFile, outputFilename, mdatSize.toInt())
} else {
saveToDownloads(context, tempFile, outputFilename)
}
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
.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 }
if (forceSequentialProcessing) {
var continuation = workManager.beginWith(requests.first())
for (request in requests.drop(1)) {
continuation = continuation.then(request)
Log.i(TAG, "Saved to Downloads: $outputUri")
success = true
return TranscodeResult(outputUri, inputFile, originalSize, outputSize)
} finally {
tempFile.delete()
if (!success) {
inputFile.delete()
}
}
}
private fun saveToDownloads(context: Context, sourceFile: File, filename: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return saveToDownloadsMediaStore(context, filename) { outputStream ->
sourceFile.inputStream().use { it.copyTo(outputStream) }
}
continuation.enqueue()
} else {
workManager.enqueue(requests)
}
return idsToUris
}
fun transcodeWithCustomOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map<UUID, Uri> {
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, customTranscodingOptions = customTranscodingOptions)
}
fun transcodeWithPresetOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset): Map<UUID, Uri> {
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, transcodingPreset)
}
fun getTranscodingJobsAsFlow(jobIds: List<UUID>): Flow<MutableList<WorkInfo>> {
if (jobIds.isEmpty()) {
return emptyFlow()
}
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
}
fun cancelAllTranscodes() {
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
workManager.pruneWork()
}
fun cleanPrivateStorage(context: Context) {
context.filesDir.listFiles()?.forEach {
it.delete()
return saveToDownloadsLegacy(sourceFile, filename)
}
}
data class CustomTranscodingOptions(val videoCodec: String, val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean)
private fun saveToDownloadsWithFastStart(context: Context, sourceFile: File, filename: String, mdatSize: Int): Uri {
val inputStreamFactory = Mp4FaststartPostProcessor.InputStreamFactory { FileInputStream(sourceFile) }
val processor = Mp4FaststartPostProcessor(inputStreamFactory)
val sourceLength = sourceFile.length()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return saveToDownloadsMediaStore(context, filename) { outputStream ->
processor.processWithMdatLength(sourceLength, mdatSize).use { it.copyTo(outputStream) }
}
} else {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val outputFile = File(downloadsDir, filename)
outputFile.outputStream().use { outputStream ->
processor.processWithMdatLength(sourceLength, mdatSize).use { it.copyTo(outputStream) }
}
return Uri.fromFile(outputFile)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveToDownloadsMediaStore(context: Context, filename: String, writeContent: (java.io.OutputStream) -> Unit): Uri {
val contentValues = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, filename)
put(MediaStore.Downloads.MIME_TYPE, "video/mp4")
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.Downloads.IS_PENDING, 1)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
?: throw IOException("Failed to create MediaStore entry")
resolver.openOutputStream(uri)?.use { outputStream ->
writeContent(outputStream)
} ?: throw IOException("Failed to open output stream for MediaStore entry")
contentValues.clear()
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
return uri
}
@Suppress("DEPRECATION")
private fun saveToDownloadsLegacy(sourceFile: File, filename: String): Uri {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val outputFile = File(downloadsDir, filename)
sourceFile.inputStream().use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
return Uri.fromFile(outputFile)
}
data class CustomTranscodingOptions(
val videoCodec: String,
val videoResolution: VideoResolution,
val videoBitrate: Int,
val audioBitrate: Int,
val enableFastStart: Boolean,
val enableAudioRemux: Boolean
)
private class FileMediaDataSource(private val file: File) : InputStreamMediaDataSource() {
private val size = file.length()
override fun close() {
// No persistent stream to close
}
override fun getSize(): Long = size
override fun createInputStream(position: Long): InputStream {
val stream = FileInputStream(file)
stream.skip(position)
return stream
}
}
companion object {
private const val TAG = "TranscodingTestRepository"
const val TRANSCODING_WORK_TAG = "transcoding"
private val TAG = Log.tag(TranscodeTestRepository::class)
private const val DEFAULT_FILE_SIZE_LIMIT: Long = 100 * 1024 * 1024
}
}

View File

@@ -7,36 +7,37 @@ package org.thoughtcrime.video.app.transcode
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import kotlinx.coroutines.flow.Flow
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.TranscodingQuality
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter
import java.util.UUID
import kotlin.math.roundToInt
/**
* ViewModel for the transcoding screen of the video sample app. See [TranscodeTestActivity].
* ViewModel for the video transcode demo app.
*/
class TranscodeTestViewModel : ViewModel() {
private lateinit var repository: TranscodeTestRepository
private var backPressedRunnable = {}
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
private val repository = TranscodeTestRepository()
private var transcodeJob: Job? = null
var selectedVideo: Uri? by mutableStateOf(null)
var transcodingPreset by mutableStateOf(TranscodingPreset.LEVEL_2)
private set
var outputDirectory: Uri? by mutableStateOf(null)
private set
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
var videoMegaBitrate by mutableFloatStateOf(calculateVideoMegaBitrateFromPreset(transcodingPreset))
var videoResolution by mutableStateOf(convertPresetToVideoResolution(transcodingPreset))
var audioKiloBitrate by mutableIntStateOf(calculateAudioKiloBitrateFromPreset(transcodingPreset))
@@ -44,38 +45,9 @@ class TranscodeTestViewModel : ViewModel() {
var useAutoTranscodingSettings by mutableStateOf(true)
var enableFastStart by mutableStateOf(true)
var enableAudioRemux by mutableStateOf(true)
var forceSequentialQueueProcessing by mutableStateOf(false)
fun initialize(context: Context) {
repository = TranscodeTestRepository(context)
backPressedRunnable = { Toast.makeText(context, "Cancelling all transcoding jobs!", Toast.LENGTH_LONG).show() }
}
fun transcode() {
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
transcodingJobs = if (useAutoTranscodingSettings) {
repository.transcodeWithPresetOptions(
selectedVideos,
output,
forceSequentialQueueProcessing,
transcodingPreset
)
} else {
repository.transcodeWithCustomOptions(
selectedVideos,
output,
forceSequentialQueueProcessing,
TranscodeTestRepository.CustomTranscodingOptions(
if (useHevc) MediaConverter.VIDEO_CODEC_H265 else MediaConverter.VIDEO_CODEC_H264,
videoResolution,
(videoMegaBitrate * MEGABIT).roundToInt(),
audioKiloBitrate * KILOBIT,
enableAudioRemux,
enableFastStart
)
)
}
}
private val _transcodingState = MutableStateFlow<TranscodingState>(TranscodingState.Idle)
val transcodingState: StateFlow<TranscodingState> = _transcodingState.asStateFlow()
fun updateTranscodingPreset(preset: TranscodingPreset) {
transcodingPreset = preset
@@ -84,49 +56,92 @@ class TranscodeTestViewModel : ViewModel() {
audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset)
}
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
fun startTranscode(context: Context) {
val video = selectedVideo ?: return
_transcodingState.value = TranscodingState.InProgress(0)
val settings = TranscodeSettings(
isPreset = useAutoTranscodingSettings,
presetName = if (useAutoTranscodingSettings) transcodingPreset.name else null,
videoResolution = videoResolution,
videoMegaBitrate = videoMegaBitrate,
audioKiloBitrate = audioKiloBitrate,
useHevc = useHevc,
enableFastStart = enableFastStart,
enableAudioRemux = enableAudioRemux
)
transcodeJob = viewModelScope.launch(Dispatchers.IO) {
try {
val result = if (useAutoTranscodingSettings) {
repository.transcodeWithPreset(
context = context,
inputUri = video,
preset = transcodingPreset,
enableFastStart = enableFastStart,
enableAudioRemux = enableAudioRemux,
onProgress = { percent -> _transcodingState.value = TranscodingState.InProgress(percent) }
)
} else {
repository.transcodeWithCustomOptions(
context = context,
inputUri = video,
options = TranscodeTestRepository.CustomTranscodingOptions(
videoCodec = if (useHevc) MediaConverter.VIDEO_CODEC_H265 else MediaConverter.VIDEO_CODEC_H264,
videoResolution = videoResolution,
videoBitrate = (videoMegaBitrate * MEGABIT).roundToInt(),
audioBitrate = audioKiloBitrate * KILOBIT,
enableFastStart = enableFastStart,
enableAudioRemux = enableAudioRemux
),
onProgress = { percent -> _transcodingState.value = TranscodingState.InProgress(percent) }
)
}
_transcodingState.value = TranscodingState.Completed(
outputUri = result.outputUri,
originalFile = result.originalFile,
originalSize = result.originalSize,
outputSize = result.outputSize,
settings = settings
)
} catch (e: CancellationException) {
_transcodingState.value = TranscodingState.Cancelled
} catch (e: Exception) {
_transcodingState.value = TranscodingState.Failed(e.message ?: "Unknown error")
}
}
}
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
outputDirectory = folderUri
repository.cleanPrivateStorage(context)
fun cancelTranscode() {
transcodeJob?.cancel()
}
fun reset() {
cancelAllTranscodes()
resetOutputDirectory()
selectedVideos = emptyList()
}
private fun cancelAllTranscodes() {
repository.cancelAllTranscodes()
transcodingJobs = emptyMap()
}
fun resetOutputDirectory() {
outputDirectory = null
cancelTranscode()
val currentState = _transcodingState.value
if (currentState is TranscodingState.Completed) {
currentState.originalFile.delete()
}
selectedVideo = null
_transcodingState.value = TranscodingState.Idle
}
companion object {
private const val MEGABIT = 1000000
private const val KILOBIT = 1000
private const val MEGABIT = 1_000_000
private const val KILOBIT = 1_000
@JvmStatic
private fun calculateVideoMegaBitrateFromPreset(preset: TranscodingPreset): Float {
val quality = TranscodingQuality.createFromPreset(preset, -1)
return quality.targetVideoBitRate.toFloat() / MEGABIT
}
@JvmStatic
private fun calculateAudioKiloBitrateFromPreset(preset: TranscodingPreset): Int {
val quality = TranscodingQuality.createFromPreset(preset, -1)
return quality.targetAudioBitRate / KILOBIT
}
@JvmStatic
private fun convertPresetToVideoResolution(preset: TranscodingPreset) = when (preset) {
TranscodingPreset.LEVEL_3 -> VideoResolution.HD
TranscodingPreset.LEVEL_3, TranscodingPreset.LEVEL_3_H265 -> VideoResolution.HD
else -> VideoResolution.SD
}
}

View File

@@ -1,266 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.Uri
import android.os.Build
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
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.signal.core.util.readLength
import org.thoughtcrime.securesms.video.StreamingTranscoder
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter.VideoCodec
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import org.thoughtcrime.video.app.R
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
import java.time.Instant
/**
* A WorkManager worker to transcode videos in the background. This utilizes [StreamingTranscoder].
*/
class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
private var lastProgress = 0
@UnstableApi
override suspend fun doWork(): Result {
val logPrefix = "[Job ${id.toString().takeLast(4)}]"
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Log.w(TAG, "$logPrefix Transcoder is only supported on API 26+!")
return Result.failure()
}
val inputParams = InputParams(inputData)
val inputFilename = DocumentFile.fromSingleUri(applicationContext, inputParams.inputUri)?.name?.removeFileExtension()
if (inputFilename == null) {
Log.w(TAG, "$logPrefix Could not read input file name!")
return Result.failure()
}
val filenameBase = "transcoded-${Instant.now()}-$inputFilename"
val tempFilename = "$filenameBase$TEMP_FILE_EXTENSION"
val finalFilename = "$filenameBase$OUTPUT_FILE_EXTENSION"
setForeground(createForegroundInfo(-1, inputParams.notificationId))
applicationContext.openFileOutput(tempFilename, Context.MODE_PRIVATE).use { outputStream ->
if (outputStream == null) {
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
return Result.failure()
}
applicationContext.contentResolver.openInputStream(inputParams.inputUri).use { inputStream ->
applicationContext.openFileOutput(inputFilename, Context.MODE_PRIVATE).use { outputStream ->
Log.i(TAG, "Started copying input to internal storage.")
inputStream?.copyTo(outputStream)
Log.i(TAG, "Finished copying input to internal storage.")
}
}
}
val datasource = WorkerMediaDataSource(File(applicationContext.filesDir, inputFilename))
val transcoder = if (inputParams.resolution > 0 && inputParams.videoBitrate > 0) {
if (inputParams.videoCodec == null) {
Log.w(TAG, "$logPrefix Video codec was null!")
return Result.failure()
}
Log.d(TAG, "$logPrefix Initializing StreamingTranscoder with custom parameters: CODEC:${inputParams.videoCodec} B:V=${inputParams.videoBitrate}, B:A=${inputParams.audioBitrate}, res=${inputParams.resolution}, audioRemux=${inputParams.audioRemux}")
StreamingTranscoder.createManuallyForTesting(datasource, null, inputParams.videoCodec, inputParams.videoBitrate, inputParams.audioBitrate, inputParams.resolution, inputParams.audioRemux)
} else if (inputParams.transcodingPreset != null) {
StreamingTranscoder(datasource, null, inputParams.transcodingPreset, DEFAULT_FILE_SIZE_LIMIT, inputParams.audioRemux)
} else {
throw IllegalArgumentException("Improper input data! No TranscodingPreset defined, or invalid manual parameters!")
}
applicationContext.openFileOutput(tempFilename, Context.MODE_PRIVATE).use { outputStream ->
transcoder.transcode({ percent: Int ->
if (lastProgress != percent) {
lastProgress = percent
Log.v(TAG, "$logPrefix Updating progress percent to $percent%")
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
setForegroundAsync(createForegroundInfo(percent, inputParams.notificationId))
}
}, outputStream, { isStopped })
}
Log.v(TAG, "$logPrefix Initial transcode completed successfully!")
val finalFile = createFile(inputParams.outputDirUri, finalFilename) ?: run {
Log.w(TAG, "$logPrefix Could not create final file for faststart processing!")
return Result.failure()
}
if (!inputParams.postProcessForFastStart) {
applicationContext.openFileInput(tempFilename).use { tempFileStream ->
if (tempFileStream == null) {
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
return Result.failure()
}
applicationContext.contentResolver.openOutputStream(finalFile.uri, "w").use { finalFileStream ->
if (finalFileStream == null) {
Log.w(TAG, "$logPrefix Could not open output file for I/O!")
return Result.failure()
}
tempFileStream.copyTo(finalFileStream)
}
}
Log.v(TAG, "$logPrefix Rename successful.")
} else {
val tempFileLength: Long
applicationContext.openFileInput(tempFilename).use { tempFileStream ->
if (tempFileStream == null) {
Log.w(TAG, "$logPrefix Could not open temp file for I/O!")
return Result.failure()
}
tempFileLength = tempFileStream.readLength()
}
applicationContext.contentResolver.openOutputStream(finalFile.uri, "w").use { finalFileStream ->
if (finalFileStream == null) {
Log.w(TAG, "$logPrefix Could not open output file for I/O!")
return Result.failure()
}
val inputStreamFactory = { applicationContext.openFileInput(tempFilename) ?: throw IOException("Could not open temp file for reading!") }
val bytesCopied = Mp4FaststartPostProcessor(inputStreamFactory).processAndWriteTo(finalFileStream)
if (bytesCopied != tempFileLength) {
Log.w(TAG, "$logPrefix Postprocessing failed! Original transcoded filesize ($tempFileLength) did not match postprocessed filesize ($bytesCopied)")
return Result.failure()
}
Log.v(TAG, "$logPrefix Faststart postprocess successful.")
}
val tempFile = File(applicationContext.filesDir, tempFilename)
if (!tempFile.delete()) {
Log.w(TAG, "$logPrefix Failed to delete temp file after processing!")
return Result.failure()
}
}
Log.v(TAG, "$logPrefix Overall transcode job successful.")
return Result.success()
}
private fun createForegroundInfo(progress: Int, notificationId: Int): ForegroundInfo {
val id = applicationContext.getString(R.string.notification_channel_id)
val title = applicationContext.getString(R.string.notification_title)
val cancel = applicationContext.getString(R.string.cancel_transcode)
val intent = WorkManager.getInstance(applicationContext)
.createCancelPendingIntent(getId())
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)
.setSmallIcon(R.drawable.ic_work_notification)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(android.R.drawable.ic_delete, cancel, intent)
.build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(notificationId, notification)
}
}
private fun createFile(treeUri: Uri, filename: String): DocumentFile? {
return DocumentFile.fromTreeUri(applicationContext, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)
}
private fun String.removeFileExtension(): String {
val lastDot = this.lastIndexOf('.')
return if (lastDot != -1) {
this.substring(0, lastDot)
} else {
this
}
}
private class WorkerMediaDataSource(private val file: File) : InputStreamMediaDataSource() {
private val size = file.length()
private var inputStream: InputStream? = null
override fun close() {
inputStream?.close()
}
override fun getSize(): Long {
return size
}
override fun createInputStream(position: Long): InputStream {
inputStream?.close()
val openedInputStream = FileInputStream(file)
openedInputStream.skip(position)
inputStream = openedInputStream
return openedInputStream
}
}
private data class InputParams(private val inputData: Data) {
val notificationId: Int = inputData.getInt(KEY_NOTIFICATION_ID, -1)
val inputUri: Uri = Uri.parse(inputData.getString(KEY_INPUT_URI))
val outputDirUri: Uri = Uri.parse(inputData.getString(KEY_OUTPUT_URI))
val postProcessForFastStart: Boolean = inputData.getBoolean(KEY_ENABLE_FASTSTART, true)
val transcodingPreset: TranscodingPreset? = inputData.getString(KEY_TRANSCODING_PRESET_NAME)?.let { TranscodingPreset.valueOf(it) }
@VideoCodec val videoCodec: String? = inputData.getString(KEY_VIDEO_CODEC)
val resolution: Int = inputData.getInt(KEY_SHORT_EDGE, -1)
val videoBitrate: Int = inputData.getInt(KEY_VIDEO_BIT_RATE, -1)
val audioBitrate: Int = inputData.getInt(KEY_AUDIO_BIT_RATE, -1)
val audioRemux: Boolean = inputData.getBoolean(KEY_ENABLE_AUDIO_REMUX, true)
}
companion object {
private const val TAG = "TranscodeWorker"
private const val OUTPUT_FILE_EXTENSION = ".mp4"
const val TEMP_FILE_EXTENSION = ".tmp"
private const val DEFAULT_FILE_SIZE_LIMIT: Long = 100 * 1024 * 1024
const val KEY_INPUT_URI = "input_uri"
const val KEY_OUTPUT_URI = "output_uri"
const val KEY_TRANSCODING_PRESET_NAME = "transcoding_quality_preset"
const val KEY_PROGRESS = "progress"
const val KEY_VIDEO_CODEC = "video_codec"
const val KEY_LONG_EDGE = "resolution_long_edge"
const val KEY_SHORT_EDGE = "resolution_short_edge"
const val KEY_VIDEO_BIT_RATE = "video_bit_rate"
const val KEY_AUDIO_BIT_RATE = "audio_bit_rate"
const val KEY_ENABLE_AUDIO_REMUX = "audio_remux"
const val KEY_ENABLE_FASTSTART = "video_enable_faststart"
const val KEY_NOTIFICATION_ID = "notification_id"
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode
import android.net.Uri
import java.io.File
sealed class TranscodingState {
data object Idle : TranscodingState()
data class InProgress(val percent: Int) : TranscodingState()
data class Completed(
val outputUri: Uri,
val originalFile: File,
val originalSize: Long,
val outputSize: Long,
val settings: TranscodeSettings
) : TranscodingState()
data class Failed(val error: String) : TranscodingState()
data object Cancelled : TranscodingState()
}
data class TranscodeSettings(
val isPreset: Boolean,
val presetName: String?,
val videoResolution: VideoResolution,
val videoMegaBitrate: Float,
val audioKiloBitrate: Int,
val useHevc: Boolean,
val enableFastStart: Boolean,
val enableAudioRemux: Boolean
)

View File

@@ -42,10 +42,11 @@ 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.
* A view that shows the selected video URI and allows you to change the encoding options.
*/
@Composable
fun ConfigureEncodingParameters(
onTranscodeClicked: () -> Unit,
hevcCapable: Boolean = DeviceCapabilities.canEncodeHevc(),
modifier: Modifier = Modifier,
viewModel: TranscodeTestViewModel = viewModel()
@@ -56,12 +57,12 @@ fun ConfigureEncodingParameters(
modifier = modifier
) {
Text(
text = "Selected videos:",
text = "Selected video:",
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start)
)
viewModel.selectedVideos.forEach {
viewModel.selectedVideo?.let {
Text(
text = it.toString(),
fontSize = 8.sp,
@@ -71,17 +72,6 @@ fun ConfigureEncodingParameters(
.align(Alignment.Start)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Checkbox(
checked = viewModel.forceSequentialQueueProcessing,
onCheckedChange = { viewModel.forceSequentialQueueProcessing = it }
)
Text(text = "Force Sequential Queue Processing", style = MaterialTheme.typography.bodySmall)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -122,11 +112,7 @@ fun ConfigureEncodingParameters(
}
LabeledButton(
buttonLabel = "Transcode",
onClick = {
viewModel.transcode()
viewModel.selectedVideos = emptyList()
viewModel.resetOutputDirectory()
},
onClick = onTranscodeClicked,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@@ -308,18 +294,17 @@ private fun AudioBitrateSlider(
@Preview(showBackground = true)
@Composable
private fun ConfigurationScreenPreviewChecked() {
private fun ConfigurationScreenPreviewPreset() {
val vm: TranscodeTestViewModel = viewModel()
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
vm.forceSequentialQueueProcessing = true
ConfigureEncodingParameters()
vm.selectedVideo = Uri.parse("content://media/video/1")
ConfigureEncodingParameters(onTranscodeClicked = {})
}
@Preview(showBackground = true)
@Composable
private fun ConfigurationScreenPreviewUnchecked() {
private fun ConfigurationScreenPreviewCustom() {
val vm: TranscodeTestViewModel = viewModel()
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
vm.selectedVideo = Uri.parse("content://media/video/1")
vm.useAutoTranscodingSettings = false
ConfigureEncodingParameters(hevcCapable = true)
ConfigureEncodingParameters(onTranscodeClicked = {}, hevcCapable = true)
}

View File

@@ -1,33 +0,0 @@
/*
* 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

@@ -1,33 +0,0 @@
/*
* 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

@@ -1,78 +0,0 @@
/*
* 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<WorkState>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
transcodingJobs.forEach { workInfo ->
val currentProgress = workInfo.progress
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.padding(horizontal = 16.dp)
) {
val progressIndicatorModifier = Modifier.weight(3f)
Text(
text = "Job ${workInfo.id.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)
}
}
data class WorkState(val id: String, val state: WorkInfo.State, val progress: Int) {
companion object {
fun fromInfo(info: WorkInfo): WorkState {
return WorkState(info.id.toString(), info.state, info.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1))
}
}
}
@Preview
@Composable
private fun ProgressScreenPreview() {
TranscodingJobProgress(
listOf(
WorkState("abcde", WorkInfo.State.RUNNING, 47),
WorkState("fghij", WorkInfo.State.ENQUEUED, -1),
WorkState("klmnop", WorkInfo.State.FAILED, -1)
),
resetButtonOnClick = {}
)
}

View File

@@ -0,0 +1,223 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.video.app.transcode.composables
import android.content.Intent
import android.net.Uri
import android.text.format.Formatter
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import org.thoughtcrime.video.app.transcode.TranscodeSettings
import org.thoughtcrime.video.app.transcode.TranscodingState
import org.thoughtcrime.video.app.transcode.VideoResolution
import org.thoughtcrime.video.app.ui.composables.LabeledButton
@Composable
fun TranscodingScreen(
state: TranscodingState,
onCancel: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
if (state is TranscodingState.InProgress) {
DisposableEffect(Unit) {
val window = (context as? android.app.Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
when (state) {
is TranscodingState.Idle -> {
Text("Preparing...", style = MaterialTheme.typography.bodyLarge)
}
is TranscodingState.InProgress -> {
Text("Transcoding: ${state.percent}%", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { state.percent / 100f },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.height(24.dp))
LabeledButton("Cancel", onClick = onCancel)
}
is TranscodingState.Completed -> {
Text("Transcoding Complete", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
val originalFormatted = Formatter.formatFileSize(context, state.originalSize)
val outputFormatted = Formatter.formatFileSize(context, state.outputSize)
val ratio = if (state.originalSize > 0) {
"%.1f%%".format(state.outputSize.toFloat() / state.originalSize * 100)
} else {
"N/A"
}
Text("File Sizes", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
StatsText("Original: $originalFormatted")
StatsText("Output: $outputFormatted ($ratio of original)")
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(12.dp))
Text("Transcode Settings", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
if (state.settings.isPreset) {
StatsText("Mode: Preset (${state.settings.presetName})")
} else {
StatsText("Mode: Custom")
}
StatsText("Resolution: ${state.settings.videoResolution.name} (${state.settings.videoResolution.shortEdge}p)")
StatsText("Video bitrate: ${"%.2f".format(state.settings.videoMegaBitrate)} Mbps")
StatsText("Audio bitrate: ${state.settings.audioKiloBitrate} kbps")
StatsText("Codec: ${if (state.settings.useHevc) "HEVC (H.265)" else "AVC (H.264)"}")
StatsText("Fast start: ${if (state.settings.enableFastStart) "Yes" else "No"}")
StatsText("Audio remux: ${if (state.settings.enableAudioRemux) "Yes" else "No"}")
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Saved to Downloads:\n${state.outputUri}",
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(24.dp))
LabeledButton("Play Original", onClick = {
val originalUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", state.originalFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(originalUri, "video/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
})
Spacer(modifier = Modifier.height(8.dp))
LabeledButton("Play Transcoded", onClick = {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(state.outputUri, "video/mp4")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
})
Spacer(modifier = Modifier.height(8.dp))
LabeledButton("Start Over", onClick = onReset)
}
is TranscodingState.Failed -> {
Text(
"Transcoding Failed",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(text = state.error)
Spacer(modifier = Modifier.height(24.dp))
LabeledButton("Start Over", onClick = onReset)
}
is TranscodingState.Cancelled -> {
Text("Transcoding Cancelled", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(24.dp))
LabeledButton("Start Over", onClick = onReset)
}
}
}
}
@Composable
private fun StatsText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
}
@Preview(showBackground = true)
@Composable
private fun TranscodingScreenInProgressPreview() {
TranscodingScreen(
state = TranscodingState.InProgress(42),
onCancel = {},
onReset = {}
)
}
@Preview(showBackground = true)
@Composable
private fun TranscodingScreenCompletedPreview() {
TranscodingScreen(
state = TranscodingState.Completed(
outputUri = Uri.parse("content://downloads/123"),
originalFile = java.io.File("/tmp/original.mp4"),
originalSize = 52_428_800L,
outputSize = 12_582_912L,
settings = TranscodeSettings(
isPreset = true,
presetName = "LEVEL_2",
videoResolution = VideoResolution.SD,
videoMegaBitrate = 2.0f,
audioKiloBitrate = 192,
useHevc = false,
enableFastStart = true,
enableAudioRemux = true
)
),
onCancel = {},
onReset = {}
)
}
@Preview(showBackground = true)
@Composable
private fun TranscodingScreenFailedPreview() {
TranscodingScreen(
state = TranscodingState.Failed("Encoder initialization failed"),
onCancel = {},
onReset = {}
)
}

View File

@@ -0,0 +1,44 @@
/*
* 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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.thoughtcrime.video.app.ui.composables.LabeledButton
@Composable
fun VideoSelectionScreen(
onSelectVideo: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxSize()
) {
Text(
text = "Video Transcode Demo",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 24.dp)
)
LabeledButton("Select Video", onClick = onSelectVideo)
}
}
@Preview(showBackground = true)
@Composable
private fun VideoSelectionScreenPreview() {
VideoSelectionScreen(onSelectVideo = {})
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@@ -30,22 +29,11 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun SignalTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
@@ -62,8 +50,7 @@ fun SignalTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

View File

@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="0.96"
android:translateY="0.96">
<path
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
android:fillColor="#FF000000"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 B

View File

@@ -4,12 +4,5 @@
-->
<resources>
<string name="app_name">Video Framework Tester</string>
<string name="notification_channel_id">transcode-progress</string>
<string name="notification_title">Encoding video…</string>
<string name="cancel_transcode">Cancel</string>
<string name="channel_name">Transcoding progress updates.</string>
<string name="channel_description">Persistent notifications that allow the transcode job to complete when the app is in the background.</string>
<string name="preference_file_key">settings</string>
<string name="preference_activity_shortcut_key">activity_shortcut</string>
</resources>
<string name="app_name">Video Transcode Demo</string>
</resources>

View File

@@ -6,5 +6,5 @@
<resources>
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight" />
</resources>
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2024 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<paths>
<files-path name="internal" path="." />
</paths>