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