diff --git a/demo/video/build.gradle.kts b/demo/video/build.gradle.kts
index be919a3d78..678bf98c26 100644
--- a/demo/video/build.gradle.kts
+++ b/demo/video/build.gradle.kts
@@ -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)
diff --git a/demo/video/src/main/AndroidManifest.xml b/demo/video/src/main/AndroidManifest.xml
index d718010185..c601e2c608 100644
--- a/demo/video/src/main/AndroidManifest.xml
+++ b/demo/video/src/main/AndroidManifest.xml
@@ -3,12 +3,7 @@
~ SPDX-License-Identifier: AGPL-3.0-only
-->
-
-
-
-
-
+
-
-
-
+
+
+
-
\ No newline at end of file
+
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/MainActivity.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/MainActivity.kt
index 0df2d27cb9..366d865369 100644
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/MainActivity.kt
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/MainActivity.kt
@@ -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) {
- val rootPath = Environment.getExternalStorageDirectory().absolutePath
- MediaScannerConnection.scanFile(
- context,
- arrayOf(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)
+ }
+ )
+ }
+ }
+ )
}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt
deleted file mode 100644
index 00ebae2ede..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestActivity.kt
+++ /dev/null
@@ -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") {}
- }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestViewModel.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestViewModel.kt
deleted file mode 100644
index 408ac35df0..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/PlaybackTestViewModel.kt
+++ /dev/null
@@ -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("")
- }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/SlowDataSource.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/playback/SlowDataSource.kt
deleted file mode 100644
index 887f6f8e4b..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/playback/SlowDataSource.kt
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt
deleted file mode 100644
index d802274d0d..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestActivity.kt
+++ /dev/null
@@ -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 ->
- 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(android.R.id.content)
- .getChildAt(0) as? ComposeView
- }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt
index 9d220b03bc..7909a20970 100644
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestRepository.kt
@@ -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()
+class TranscodeTestRepository {
- private fun transcode(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset? = null, customTranscodingOptions: CustomTranscodingOptions? = null): Map {
- 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()
- .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, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map {
- return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, customTranscodingOptions = customTranscodingOptions)
- }
-
- fun transcodeWithPresetOptions(selectedVideos: List, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset): Map {
- return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, transcodingPreset)
- }
-
- fun getTranscodingJobsAsFlow(jobIds: List): Flow> {
- 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
}
}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt
index 4a225b69ba..869b4f604b 100644
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeTestViewModel.kt
@@ -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 = 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 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.Idle)
+ val transcodingState: StateFlow = _transcodingState.asStateFlow()
fun updateTranscodingPreset(preset: TranscodingPreset) {
transcodingPreset = preset
@@ -84,49 +56,92 @@ class TranscodeTestViewModel : ViewModel() {
audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset)
}
- fun getTranscodingJobsAsState(): Flow> {
- 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
}
}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt
deleted file mode 100644
index e5bf336ad7..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodeWorker.kt
+++ /dev/null
@@ -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"
- }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodingState.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodingState.kt
new file mode 100644
index 0000000000..8c5fbc0adb
--- /dev/null
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/TranscodingState.kt
@@ -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
+)
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt
index c3fdc07257..3963df2d7e 100644
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/ConfigurationSelection.kt
@@ -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)
}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/InputSelection.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/InputSelection.kt
deleted file mode 100644
index 31a9a6d65b..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/InputSelection.kt
+++ /dev/null
@@ -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 { }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/OutputSelection.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/OutputSelection.kt
deleted file mode 100644
index b4c1aa863e..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/OutputSelection.kt
+++ /dev/null
@@ -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 { }
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt
deleted file mode 100644
index 7e4a2c8dfc..0000000000
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/Progress.kt
+++ /dev/null
@@ -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, 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 = {}
- )
-}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/TranscodingScreen.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/TranscodingScreen.kt
new file mode 100644
index 0000000000..c008072a97
--- /dev/null
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/TranscodingScreen.kt
@@ -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 = {}
+ )
+}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/VideoSelectionScreen.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/VideoSelectionScreen.kt
new file mode 100644
index 0000000000..0dd14de5c8
--- /dev/null
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/transcode/composables/VideoSelectionScreen.kt
@@ -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 = {})
+}
diff --git a/demo/video/src/main/java/org/thoughtcrime/video/app/ui/theme/Theme.kt b/demo/video/src/main/java/org/thoughtcrime/video/app/ui/theme/Theme.kt
index 10c826c424..979cfc69c8 100644
--- a/demo/video/src/main/java/org/thoughtcrime/video/app/ui/theme/Theme.kt
+++ b/demo/video/src/main/java/org/thoughtcrime/video/app/ui/theme/Theme.kt
@@ -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
}
}
diff --git a/demo/video/src/main/res/drawable-anydpi-v24/ic_work_notification.xml b/demo/video/src/main/res/drawable-anydpi-v24/ic_work_notification.xml
deleted file mode 100644
index 6f92d40c03..0000000000
--- a/demo/video/src/main/res/drawable-anydpi-v24/ic_work_notification.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/demo/video/src/main/res/drawable-hdpi/ic_work_notification.png b/demo/video/src/main/res/drawable-hdpi/ic_work_notification.png
deleted file mode 100644
index 8e46a11c9d..0000000000
Binary files a/demo/video/src/main/res/drawable-hdpi/ic_work_notification.png and /dev/null differ
diff --git a/demo/video/src/main/res/drawable-mdpi/ic_work_notification.png b/demo/video/src/main/res/drawable-mdpi/ic_work_notification.png
deleted file mode 100644
index 32067e0c27..0000000000
Binary files a/demo/video/src/main/res/drawable-mdpi/ic_work_notification.png and /dev/null differ
diff --git a/demo/video/src/main/res/drawable-xhdpi/ic_work_notification.png b/demo/video/src/main/res/drawable-xhdpi/ic_work_notification.png
deleted file mode 100644
index bc21cccd24..0000000000
Binary files a/demo/video/src/main/res/drawable-xhdpi/ic_work_notification.png and /dev/null differ
diff --git a/demo/video/src/main/res/drawable-xxhdpi/ic_work_notification.png b/demo/video/src/main/res/drawable-xxhdpi/ic_work_notification.png
deleted file mode 100644
index a0753ae4c2..0000000000
Binary files a/demo/video/src/main/res/drawable-xxhdpi/ic_work_notification.png and /dev/null differ
diff --git a/demo/video/src/main/res/values/strings.xml b/demo/video/src/main/res/values/strings.xml
index 789bee530b..9c37348ce5 100644
--- a/demo/video/src/main/res/values/strings.xml
+++ b/demo/video/src/main/res/values/strings.xml
@@ -4,12 +4,5 @@
-->
- Video Framework Tester
- transcode-progress
- Encoding video…
- Cancel
- Transcoding progress updates.
- Persistent notifications that allow the transcode job to complete when the app is in the background.
- settings
- activity_shortcut
-
\ No newline at end of file
+ Video Transcode Demo
+
diff --git a/demo/video/src/main/res/values/themes.xml b/demo/video/src/main/res/values/themes.xml
index b51e9d97da..0029056cdb 100644
--- a/demo/video/src/main/res/values/themes.xml
+++ b/demo/video/src/main/res/values/themes.xml
@@ -6,5 +6,5 @@
-
-
\ No newline at end of file
+
+
diff --git a/demo/video/src/main/res/xml/file_paths.xml b/demo/video/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000000..4241d7a044
--- /dev/null
+++ b/demo/video/src/main/res/xml/file_paths.xml
@@ -0,0 +1,8 @@
+
+
+
+
+