mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Refactor and improve video demo app.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -27,21 +22,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".transcode.TranscodeTestActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
<activity
|
||||
android:name=".playback.PlaybackTestActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -5,153 +5,133 @@
|
||||
|
||||
package org.thoughtcrime.video.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.media.MediaScannerConnection
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import org.signal.core.ui.navigation.TransitionSpecs
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.video.app.playback.PlaybackTestActivity
|
||||
import org.thoughtcrime.video.app.transcode.TranscodeTestActivity
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
import org.thoughtcrime.video.app.transcode.TranscodeTestViewModel
|
||||
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
|
||||
import org.thoughtcrime.video.app.transcode.composables.TranscodingScreen
|
||||
import org.thoughtcrime.video.app.transcode.composables.VideoSelectionScreen
|
||||
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
||||
|
||||
/**
|
||||
* Main activity for this sample app.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private val TAG = Log.tag(MainActivity::class.java)
|
||||
private var appLaunch = true
|
||||
}
|
||||
|
||||
private val sharedPref: SharedPreferences by lazy {
|
||||
getSharedPreferences(
|
||||
getString(R.string.preference_file_key),
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
}
|
||||
enum class Screen : NavKey {
|
||||
VideoSelection,
|
||||
Configuration,
|
||||
Transcoding
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
Log.initialize(AndroidLogger)
|
||||
|
||||
val startPlaybackScreen = { saveChoice: Boolean -> proceed(Screen.TEST_PLAYBACK, saveChoice) }
|
||||
val startTranscodeScreen = { saveChoice: Boolean -> proceed(Screen.TEST_TRANSCODE, saveChoice) }
|
||||
setContent {
|
||||
Body(startPlaybackScreen, startTranscodeScreen)
|
||||
}
|
||||
refreshMediaProviderForExternalStorage(this, arrayOf("video/*"))
|
||||
if (appLaunch) {
|
||||
appLaunch = false
|
||||
getLaunchChoice()?.let {
|
||||
proceed(it, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Body(startPlaybackScreen: (Boolean) -> Unit, startTranscodeScreen: (Boolean) -> Unit) {
|
||||
var rememberChoice by remember { mutableStateOf(getLaunchChoice() != null) }
|
||||
SignalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
SignalTheme {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LabeledButton("Test Playback") {
|
||||
startPlaybackScreen(rememberChoice)
|
||||
}
|
||||
LabeledButton("Test Transcode") {
|
||||
startTranscodeScreen(rememberChoice)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = rememberChoice,
|
||||
onCheckedChange = { isChecked ->
|
||||
rememberChoice = isChecked
|
||||
if (!isChecked) {
|
||||
clearLaunchChoice()
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(text = "Remember & Skip This Screen", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
TranscodeApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLaunchChoice(): Screen? {
|
||||
val screenName = sharedPref.getString(getString(R.string.preference_activity_shortcut_key), null) ?: return null
|
||||
return Screen.valueOf(screenName)
|
||||
}
|
||||
|
||||
private fun clearLaunchChoice() {
|
||||
with(sharedPref.edit()) {
|
||||
remove(getString(R.string.preference_activity_shortcut_key))
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLaunchChoice(choice: Screen) {
|
||||
with(sharedPref.edit()) {
|
||||
putString(getString(R.string.preference_activity_shortcut_key), choice.name)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMediaProviderForExternalStorage(context: Context, mimeTypes: Array<String>) {
|
||||
val rootPath = Environment.getExternalStorageDirectory().absolutePath
|
||||
MediaScannerConnection.scanFile(
|
||||
context,
|
||||
arrayOf<String>(rootPath),
|
||||
mimeTypes
|
||||
) { _, _ ->
|
||||
Log.i(TAG, "Re-scan of external storage for media completed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun proceed(screen: Screen, saveChoice: Boolean) {
|
||||
if (saveChoice) {
|
||||
saveLaunchChoice(screen)
|
||||
}
|
||||
when (screen) {
|
||||
Screen.TEST_PLAYBACK -> startActivity(Intent(this, PlaybackTestActivity::class.java))
|
||||
Screen.TEST_TRANSCODE -> startActivity(Intent(this, TranscodeTestActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Screen {
|
||||
TEST_PLAYBACK,
|
||||
TEST_TRANSCODE
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PreviewBody() {
|
||||
Body({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranscodeApp() {
|
||||
val backStack = rememberNavBackStack(Screen.VideoSelection)
|
||||
val viewModel: TranscodeTestViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
val pickMedia = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.PickVisualMedia()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
viewModel.selectedVideo = uri
|
||||
backStack.add(Screen.Configuration)
|
||||
}
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitonSpec,
|
||||
entryProvider = entryProvider {
|
||||
addEntryProvider(
|
||||
key = Screen.VideoSelection,
|
||||
contentKey = Screen.VideoSelection,
|
||||
metadata = emptyMap()
|
||||
) { _: Screen ->
|
||||
VideoSelectionScreen(
|
||||
onSelectVideo = {
|
||||
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
addEntryProvider(
|
||||
key = Screen.Configuration,
|
||||
contentKey = Screen.Configuration,
|
||||
metadata = emptyMap()
|
||||
) { _: Screen ->
|
||||
ConfigureEncodingParameters(
|
||||
onTranscodeClicked = {
|
||||
viewModel.startTranscode(context)
|
||||
backStack.remove(Screen.Configuration)
|
||||
backStack.add(Screen.Transcoding)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
addEntryProvider(
|
||||
key = Screen.Transcoding,
|
||||
contentKey = Screen.Transcoding,
|
||||
metadata = emptyMap()
|
||||
) { _: Screen ->
|
||||
val transcodingState by viewModel.transcodingState.collectAsStateWithLifecycle()
|
||||
TranscodingScreen(
|
||||
state = transcodingState,
|
||||
onCancel = { viewModel.cancelTranscode() },
|
||||
onReset = {
|
||||
viewModel.reset()
|
||||
backStack.remove(Screen.Transcoding)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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") {}
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* 2SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.video.app.R
|
||||
import org.thoughtcrime.video.app.transcode.composables.ConfigureEncodingParameters
|
||||
import org.thoughtcrime.video.app.transcode.composables.SelectInput
|
||||
import org.thoughtcrime.video.app.transcode.composables.SelectOutput
|
||||
import org.thoughtcrime.video.app.transcode.composables.TranscodingJobProgress
|
||||
import org.thoughtcrime.video.app.transcode.composables.WorkState
|
||||
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
||||
|
||||
/**
|
||||
* Visual entry point for testing transcoding in the video sample app.
|
||||
*/
|
||||
class TranscodeTestActivity : AppCompatActivity() {
|
||||
private val TAG = "TranscodeTestActivity"
|
||||
private val viewModel: TranscodeTestViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.initialize(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = applicationContext.getString(R.string.channel_name)
|
||||
val descriptionText = applicationContext.getString(R.string.channel_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val mChannel = NotificationChannel(getString(R.string.notification_channel_id), name, importance)
|
||||
mChannel.description = descriptionText
|
||||
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsStateWithLifecycle(emptyList())
|
||||
if (transcodingJobs.value.isNotEmpty()) {
|
||||
TranscodingJobProgress(transcodingJobs = transcodingJobs.value.map { WorkState.fromInfo(it) }, resetButtonOnClick = { viewModel.reset() })
|
||||
} else if (viewModel.selectedVideos.isEmpty()) {
|
||||
SelectInput { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
||||
} else if (viewModel.outputDirectory == null) {
|
||||
SelectOutput { outputDirRequest.launch(null) }
|
||||
} else {
|
||||
ConfigureEncodingParameters(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
getComposeView()?.keepScreenOn = true
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val notificationPermissionStatus = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
Log.v(TAG, "Notification permission status: $notificationPermissionStatus")
|
||||
if (notificationPermissionStatus != PackageManager.PERMISSION_GRANTED) {
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.POST_NOTIFICATIONS)) {
|
||||
showPermissionRationaleDialog { _, _ -> requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }
|
||||
} else {
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPermissionRationaleDialog(okListener: DialogInterface.OnClickListener) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle("The system will request the notification permission.")
|
||||
.setMessage("This permission is required to show the transcoding progress in the notification tray.")
|
||||
.setPositiveButton("Ok", okListener)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* This launches the system media picker and stores the resulting URI.
|
||||
*/
|
||||
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
Log.d(TAG, "Selected URI: $uris")
|
||||
viewModel.selectedVideos = uris
|
||||
viewModel.resetOutputDirectory()
|
||||
} else {
|
||||
Log.d(TAG, "No media selected")
|
||||
}
|
||||
}
|
||||
|
||||
private val outputDirRequest = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
uri?.let {
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
viewModel.setOutputDirectoryAndCleanFailedTranscodes(this, it)
|
||||
}
|
||||
}
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted: Boolean ->
|
||||
Log.d(TAG, "Notification permission allowed: $isGranted")
|
||||
}
|
||||
|
||||
private fun getComposeView(): ComposeView? {
|
||||
return window.decorView
|
||||
.findViewById<ViewGroup>(android.R.id.content)
|
||||
.getChildAt(0) as? ComposeView
|
||||
}
|
||||
}
|
||||
@@ -5,110 +5,235 @@
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.work.Data
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.video.StreamingTranscoder
|
||||
import org.thoughtcrime.securesms.video.TranscodingPreset
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
import org.thoughtcrime.securesms.video.postprocessing.Mp4FaststartPostProcessor
|
||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Repository to perform various transcoding functions.
|
||||
* Repository that performs video transcoding using coroutines.
|
||||
*/
|
||||
class TranscodeTestRepository(context: Context) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val usedNotificationIds = emptySet<Int>()
|
||||
class TranscodeTestRepository {
|
||||
|
||||
private fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset? = null, customTranscodingOptions: CustomTranscodingOptions? = null): Map<UUID, Uri> {
|
||||
if (customTranscodingOptions == null && transcodingPreset == null) {
|
||||
throw IllegalArgumentException("Must define either custom options or transcoding preset!")
|
||||
} else if (customTranscodingOptions != null && transcodingPreset != null) {
|
||||
throw IllegalArgumentException("Cannot define both custom options and transcoding preset!")
|
||||
data class TranscodeResult(val outputUri: Uri, val originalFile: File, val originalSize: Long, val outputSize: Long)
|
||||
|
||||
/**
|
||||
* Transcode a video using a [TranscodingPreset] and save the result to the Downloads folder.
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
suspend fun transcodeWithPreset(
|
||||
context: Context,
|
||||
inputUri: Uri,
|
||||
preset: TranscodingPreset,
|
||||
enableFastStart: Boolean,
|
||||
enableAudioRemux: Boolean,
|
||||
onProgress: (Int) -> Unit
|
||||
): TranscodeResult {
|
||||
return doTranscode(context, inputUri, enableFastStart, onProgress) { inputFile ->
|
||||
val dataSource = FileMediaDataSource(inputFile)
|
||||
StreamingTranscoder(dataSource, null, preset, DEFAULT_FILE_SIZE_LIMIT, enableAudioRemux)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedVideos.isEmpty()) {
|
||||
return emptyMap()
|
||||
/**
|
||||
* Transcode a video using custom parameters and save the result to the Downloads folder.
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
suspend fun transcodeWithCustomOptions(
|
||||
context: Context,
|
||||
inputUri: Uri,
|
||||
options: CustomTranscodingOptions,
|
||||
onProgress: (Int) -> Unit
|
||||
): TranscodeResult {
|
||||
return doTranscode(context, inputUri, options.enableFastStart, onProgress) { inputFile ->
|
||||
val dataSource = FileMediaDataSource(inputFile)
|
||||
StreamingTranscoder.createManuallyForTesting(
|
||||
dataSource,
|
||||
null,
|
||||
options.videoCodec,
|
||||
options.videoBitrate,
|
||||
options.audioBitrate,
|
||||
options.videoResolution.shortEdge,
|
||||
options.enableAudioRemux
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val urisAndRequests = selectedVideos.map {
|
||||
var notificationId = Random.nextInt().absoluteValue
|
||||
while (usedNotificationIds.contains(notificationId)) {
|
||||
notificationId = Random.nextInt().absoluteValue
|
||||
@RequiresApi(26)
|
||||
private suspend fun doTranscode(
|
||||
context: Context,
|
||||
inputUri: Uri,
|
||||
enableFastStart: Boolean,
|
||||
onProgress: (Int) -> Unit,
|
||||
createTranscoder: (File) -> StreamingTranscoder
|
||||
): TranscodeResult {
|
||||
val inputFilename = inputUri.lastPathSegment?.substringAfterLast('/') ?: "input"
|
||||
val baseName = inputFilename.substringBeforeLast('.')
|
||||
val filenameBase = "transcoded-${Instant.now()}-$baseName"
|
||||
val tempTranscodeFilename = "$filenameBase.tmp"
|
||||
val outputFilename = "$filenameBase.mp4"
|
||||
|
||||
val inputFile = File(context.filesDir, "original-${System.currentTimeMillis()}.mp4")
|
||||
val tempFile = File(context.filesDir, tempTranscodeFilename)
|
||||
|
||||
val coroutineContext = currentCoroutineContext()
|
||||
|
||||
var success = false
|
||||
try {
|
||||
// Copy input to internal storage for random access
|
||||
Log.i(TAG, "Copying input to internal storage...")
|
||||
context.contentResolver.openInputStream(inputUri).use { inputStream ->
|
||||
requireNotNull(inputStream) { "Could not open input URI" }
|
||||
inputFile.outputStream().use { out ->
|
||||
inputStream.copyTo(out)
|
||||
}
|
||||
}
|
||||
val inputData = Data.Builder()
|
||||
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
|
||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
|
||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||
Log.i(TAG, "Input copy complete. Size: ${inputFile.length()}")
|
||||
|
||||
if (transcodingPreset != null) {
|
||||
inputData.putString(TranscodeWorker.KEY_TRANSCODING_PRESET_NAME, transcodingPreset.name)
|
||||
} else if (customTranscodingOptions != null) {
|
||||
inputData.putString(TranscodeWorker.KEY_VIDEO_CODEC, customTranscodingOptions.videoCodec)
|
||||
inputData.putInt(TranscodeWorker.KEY_LONG_EDGE, customTranscodingOptions.videoResolution.longEdge)
|
||||
inputData.putInt(TranscodeWorker.KEY_SHORT_EDGE, customTranscodingOptions.videoResolution.shortEdge)
|
||||
inputData.putInt(TranscodeWorker.KEY_VIDEO_BIT_RATE, customTranscodingOptions.videoBitrate)
|
||||
inputData.putInt(TranscodeWorker.KEY_AUDIO_BIT_RATE, customTranscodingOptions.audioBitrate)
|
||||
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_FASTSTART, customTranscodingOptions.enableFastStart)
|
||||
inputData.putBoolean(TranscodeWorker.KEY_ENABLE_AUDIO_REMUX, customTranscodingOptions.enableAudioRemux)
|
||||
coroutineContext.ensureActive()
|
||||
|
||||
// Transcode
|
||||
val transcoder = createTranscoder(inputFile)
|
||||
Log.i(TAG, "Starting transcode...")
|
||||
val mdatSize: Long
|
||||
tempFile.outputStream().use { outputStream ->
|
||||
mdatSize = transcoder.transcode(
|
||||
{ percent -> onProgress(percent) },
|
||||
outputStream
|
||||
) { !coroutineContext.isActive }
|
||||
}
|
||||
val originalSize = inputFile.length()
|
||||
val outputSize = tempFile.length()
|
||||
Log.i(TAG, "Transcode complete. Output size: $outputSize, mdat size: $mdatSize")
|
||||
|
||||
coroutineContext.ensureActive()
|
||||
|
||||
// Save to Downloads
|
||||
val outputUri = if (enableFastStart) {
|
||||
saveToDownloadsWithFastStart(context, tempFile, outputFilename, mdatSize.toInt())
|
||||
} else {
|
||||
saveToDownloads(context, tempFile, outputFilename)
|
||||
}
|
||||
|
||||
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
|
||||
.setInputData(inputData.build())
|
||||
.addTag(TRANSCODING_WORK_TAG)
|
||||
.build()
|
||||
it to transcodeRequest
|
||||
}
|
||||
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
|
||||
val requests = urisAndRequests.map { it.second }
|
||||
if (forceSequentialProcessing) {
|
||||
var continuation = workManager.beginWith(requests.first())
|
||||
for (request in requests.drop(1)) {
|
||||
continuation = continuation.then(request)
|
||||
Log.i(TAG, "Saved to Downloads: $outputUri")
|
||||
success = true
|
||||
return TranscodeResult(outputUri, inputFile, originalSize, outputSize)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
if (!success) {
|
||||
inputFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDownloads(context: Context, sourceFile: File, filename: String): Uri {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return saveToDownloadsMediaStore(context, filename) { outputStream ->
|
||||
sourceFile.inputStream().use { it.copyTo(outputStream) }
|
||||
}
|
||||
continuation.enqueue()
|
||||
} else {
|
||||
workManager.enqueue(requests)
|
||||
}
|
||||
return idsToUris
|
||||
}
|
||||
|
||||
fun transcodeWithCustomOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, customTranscodingOptions: CustomTranscodingOptions?): Map<UUID, Uri> {
|
||||
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, customTranscodingOptions = customTranscodingOptions)
|
||||
}
|
||||
|
||||
fun transcodeWithPresetOptions(selectedVideos: List<Uri>, outputDirectory: Uri, forceSequentialProcessing: Boolean, transcodingPreset: TranscodingPreset): Map<UUID, Uri> {
|
||||
return transcode(selectedVideos, outputDirectory, forceSequentialProcessing, transcodingPreset)
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsFlow(jobIds: List<UUID>): Flow<MutableList<WorkInfo>> {
|
||||
if (jobIds.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
|
||||
}
|
||||
|
||||
fun cancelAllTranscodes() {
|
||||
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
|
||||
workManager.pruneWork()
|
||||
}
|
||||
|
||||
fun cleanPrivateStorage(context: Context) {
|
||||
context.filesDir.listFiles()?.forEach {
|
||||
it.delete()
|
||||
return saveToDownloadsLegacy(sourceFile, filename)
|
||||
}
|
||||
}
|
||||
|
||||
data class CustomTranscodingOptions(val videoCodec: String, val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean)
|
||||
private fun saveToDownloadsWithFastStart(context: Context, sourceFile: File, filename: String, mdatSize: Int): Uri {
|
||||
val inputStreamFactory = Mp4FaststartPostProcessor.InputStreamFactory { FileInputStream(sourceFile) }
|
||||
val processor = Mp4FaststartPostProcessor(inputStreamFactory)
|
||||
val sourceLength = sourceFile.length()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return saveToDownloadsMediaStore(context, filename) { outputStream ->
|
||||
processor.processWithMdatLength(sourceLength, mdatSize).use { it.copyTo(outputStream) }
|
||||
}
|
||||
} else {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val outputFile = File(downloadsDir, filename)
|
||||
outputFile.outputStream().use { outputStream ->
|
||||
processor.processWithMdatLength(sourceLength, mdatSize).use { it.copyTo(outputStream) }
|
||||
}
|
||||
return Uri.fromFile(outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveToDownloadsMediaStore(context: Context, filename: String, writeContent: (java.io.OutputStream) -> Unit): Uri {
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Downloads.MIME_TYPE, "video/mp4")
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
val resolver = context.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
?: throw IOException("Failed to create MediaStore entry")
|
||||
|
||||
resolver.openOutputStream(uri)?.use { outputStream ->
|
||||
writeContent(outputStream)
|
||||
} ?: throw IOException("Failed to open output stream for MediaStore entry")
|
||||
|
||||
contentValues.clear()
|
||||
contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
resolver.update(uri, contentValues, null, null)
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun saveToDownloadsLegacy(sourceFile: File, filename: String): Uri {
|
||||
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val outputFile = File(downloadsDir, filename)
|
||||
sourceFile.inputStream().use { input ->
|
||||
outputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
return Uri.fromFile(outputFile)
|
||||
}
|
||||
|
||||
data class CustomTranscodingOptions(
|
||||
val videoCodec: String,
|
||||
val videoResolution: VideoResolution,
|
||||
val videoBitrate: Int,
|
||||
val audioBitrate: Int,
|
||||
val enableFastStart: Boolean,
|
||||
val enableAudioRemux: Boolean
|
||||
)
|
||||
|
||||
private class FileMediaDataSource(private val file: File) : InputStreamMediaDataSource() {
|
||||
private val size = file.length()
|
||||
|
||||
override fun close() {
|
||||
// No persistent stream to close
|
||||
}
|
||||
|
||||
override fun getSize(): Long = size
|
||||
|
||||
override fun createInputStream(position: Long): InputStream {
|
||||
val stream = FileInputStream(file)
|
||||
stream.skip(position)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranscodingTestRepository"
|
||||
const val TRANSCODING_WORK_TAG = "transcoding"
|
||||
private val TAG = Log.tag(TranscodeTestRepository::class)
|
||||
private const val DEFAULT_FILE_SIZE_LIMIT: Long = 100 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,36 +7,37 @@ package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.work.WorkInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.video.TranscodingPreset
|
||||
import org.thoughtcrime.securesms.video.TranscodingQuality
|
||||
import org.thoughtcrime.securesms.video.videoconverter.MediaConverter
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* ViewModel for the transcoding screen of the video sample app. See [TranscodeTestActivity].
|
||||
* ViewModel for the video transcode demo app.
|
||||
*/
|
||||
class TranscodeTestViewModel : ViewModel() {
|
||||
private lateinit var repository: TranscodeTestRepository
|
||||
private var backPressedRunnable = {}
|
||||
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
||||
private val repository = TranscodeTestRepository()
|
||||
private var transcodeJob: Job? = null
|
||||
|
||||
var selectedVideo: Uri? by mutableStateOf(null)
|
||||
|
||||
var transcodingPreset by mutableStateOf(TranscodingPreset.LEVEL_2)
|
||||
private set
|
||||
|
||||
var outputDirectory: Uri? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
|
||||
var videoMegaBitrate by mutableFloatStateOf(calculateVideoMegaBitrateFromPreset(transcodingPreset))
|
||||
var videoResolution by mutableStateOf(convertPresetToVideoResolution(transcodingPreset))
|
||||
var audioKiloBitrate by mutableIntStateOf(calculateAudioKiloBitrateFromPreset(transcodingPreset))
|
||||
@@ -44,38 +45,9 @@ class TranscodeTestViewModel : ViewModel() {
|
||||
var useAutoTranscodingSettings by mutableStateOf(true)
|
||||
var enableFastStart by mutableStateOf(true)
|
||||
var enableAudioRemux by mutableStateOf(true)
|
||||
var forceSequentialQueueProcessing by mutableStateOf(false)
|
||||
|
||||
fun initialize(context: Context) {
|
||||
repository = TranscodeTestRepository(context)
|
||||
backPressedRunnable = { Toast.makeText(context, "Cancelling all transcoding jobs!", Toast.LENGTH_LONG).show() }
|
||||
}
|
||||
|
||||
fun transcode() {
|
||||
val output = outputDirectory ?: throw IllegalStateException("No output directory selected!")
|
||||
transcodingJobs = if (useAutoTranscodingSettings) {
|
||||
repository.transcodeWithPresetOptions(
|
||||
selectedVideos,
|
||||
output,
|
||||
forceSequentialQueueProcessing,
|
||||
transcodingPreset
|
||||
)
|
||||
} else {
|
||||
repository.transcodeWithCustomOptions(
|
||||
selectedVideos,
|
||||
output,
|
||||
forceSequentialQueueProcessing,
|
||||
TranscodeTestRepository.CustomTranscodingOptions(
|
||||
if (useHevc) MediaConverter.VIDEO_CODEC_H265 else MediaConverter.VIDEO_CODEC_H264,
|
||||
videoResolution,
|
||||
(videoMegaBitrate * MEGABIT).roundToInt(),
|
||||
audioKiloBitrate * KILOBIT,
|
||||
enableAudioRemux,
|
||||
enableFastStart
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
private val _transcodingState = MutableStateFlow<TranscodingState>(TranscodingState.Idle)
|
||||
val transcodingState: StateFlow<TranscodingState> = _transcodingState.asStateFlow()
|
||||
|
||||
fun updateTranscodingPreset(preset: TranscodingPreset) {
|
||||
transcodingPreset = preset
|
||||
@@ -84,49 +56,92 @@ class TranscodeTestViewModel : ViewModel() {
|
||||
audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset)
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
||||
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
|
||||
fun startTranscode(context: Context) {
|
||||
val video = selectedVideo ?: return
|
||||
_transcodingState.value = TranscodingState.InProgress(0)
|
||||
|
||||
val settings = TranscodeSettings(
|
||||
isPreset = useAutoTranscodingSettings,
|
||||
presetName = if (useAutoTranscodingSettings) transcodingPreset.name else null,
|
||||
videoResolution = videoResolution,
|
||||
videoMegaBitrate = videoMegaBitrate,
|
||||
audioKiloBitrate = audioKiloBitrate,
|
||||
useHevc = useHevc,
|
||||
enableFastStart = enableFastStart,
|
||||
enableAudioRemux = enableAudioRemux
|
||||
)
|
||||
|
||||
transcodeJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val result = if (useAutoTranscodingSettings) {
|
||||
repository.transcodeWithPreset(
|
||||
context = context,
|
||||
inputUri = video,
|
||||
preset = transcodingPreset,
|
||||
enableFastStart = enableFastStart,
|
||||
enableAudioRemux = enableAudioRemux,
|
||||
onProgress = { percent -> _transcodingState.value = TranscodingState.InProgress(percent) }
|
||||
)
|
||||
} else {
|
||||
repository.transcodeWithCustomOptions(
|
||||
context = context,
|
||||
inputUri = video,
|
||||
options = TranscodeTestRepository.CustomTranscodingOptions(
|
||||
videoCodec = if (useHevc) MediaConverter.VIDEO_CODEC_H265 else MediaConverter.VIDEO_CODEC_H264,
|
||||
videoResolution = videoResolution,
|
||||
videoBitrate = (videoMegaBitrate * MEGABIT).roundToInt(),
|
||||
audioBitrate = audioKiloBitrate * KILOBIT,
|
||||
enableFastStart = enableFastStart,
|
||||
enableAudioRemux = enableAudioRemux
|
||||
),
|
||||
onProgress = { percent -> _transcodingState.value = TranscodingState.InProgress(percent) }
|
||||
)
|
||||
}
|
||||
_transcodingState.value = TranscodingState.Completed(
|
||||
outputUri = result.outputUri,
|
||||
originalFile = result.originalFile,
|
||||
originalSize = result.originalSize,
|
||||
outputSize = result.outputSize,
|
||||
settings = settings
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
_transcodingState.value = TranscodingState.Cancelled
|
||||
} catch (e: Exception) {
|
||||
_transcodingState.value = TranscodingState.Failed(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
|
||||
outputDirectory = folderUri
|
||||
repository.cleanPrivateStorage(context)
|
||||
fun cancelTranscode() {
|
||||
transcodeJob?.cancel()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cancelAllTranscodes()
|
||||
resetOutputDirectory()
|
||||
selectedVideos = emptyList()
|
||||
}
|
||||
|
||||
private fun cancelAllTranscodes() {
|
||||
repository.cancelAllTranscodes()
|
||||
transcodingJobs = emptyMap()
|
||||
}
|
||||
|
||||
fun resetOutputDirectory() {
|
||||
outputDirectory = null
|
||||
cancelTranscode()
|
||||
val currentState = _transcodingState.value
|
||||
if (currentState is TranscodingState.Completed) {
|
||||
currentState.originalFile.delete()
|
||||
}
|
||||
selectedVideo = null
|
||||
_transcodingState.value = TranscodingState.Idle
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEGABIT = 1000000
|
||||
private const val KILOBIT = 1000
|
||||
private const val MEGABIT = 1_000_000
|
||||
private const val KILOBIT = 1_000
|
||||
|
||||
@JvmStatic
|
||||
private fun calculateVideoMegaBitrateFromPreset(preset: TranscodingPreset): Float {
|
||||
val quality = TranscodingQuality.createFromPreset(preset, -1)
|
||||
return quality.targetVideoBitRate.toFloat() / MEGABIT
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun calculateAudioKiloBitrateFromPreset(preset: TranscodingPreset): Int {
|
||||
val quality = TranscodingQuality.createFromPreset(preset, -1)
|
||||
return quality.targetAudioBitRate / KILOBIT
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun convertPresetToVideoResolution(preset: TranscodingPreset) = when (preset) {
|
||||
TranscodingPreset.LEVEL_3 -> VideoResolution.HD
|
||||
TranscodingPreset.LEVEL_3, TranscodingPreset.LEVEL_3_H265 -> VideoResolution.HD
|
||||
else -> VideoResolution.SD
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.work.WorkInfo
|
||||
import org.thoughtcrime.video.app.transcode.TranscodeWorker
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that shows the current encodes in progress.
|
||||
*/
|
||||
@Composable
|
||||
fun TranscodingJobProgress(transcodingJobs: List<WorkState>, resetButtonOnClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
transcodingJobs.forEach { workInfo ->
|
||||
val currentProgress = workInfo.progress
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
val progressIndicatorModifier = Modifier.weight(3f)
|
||||
Text(
|
||||
text = "Job ${workInfo.id.takeLast(4)}",
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
if (workInfo.state.isFinished) {
|
||||
Text(text = workInfo.state.toString(), textAlign = TextAlign.Center, modifier = progressIndicatorModifier)
|
||||
} else if (currentProgress >= 0) {
|
||||
LinearProgressIndicator(progress = currentProgress / 100f, modifier = progressIndicatorModifier)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = progressIndicatorModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledButton("Reset/Cancel", onClick = resetButtonOnClick)
|
||||
}
|
||||
}
|
||||
|
||||
data class WorkState(val id: String, val state: WorkInfo.State, val progress: Int) {
|
||||
companion object {
|
||||
fun fromInfo(info: WorkInfo): WorkState {
|
||||
return WorkState(info.id.toString(), info.state, info.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ProgressScreenPreview() {
|
||||
TranscodingJobProgress(
|
||||
listOf(
|
||||
WorkState("abcde", WorkInfo.State.RUNNING, 47),
|
||||
WorkState("fghij", WorkInfo.State.ENQUEUED, -1),
|
||||
WorkState("klmnop", WorkInfo.State.FAILED, -1)
|
||||
),
|
||||
resetButtonOnClick = {}
|
||||
)
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.92"
|
||||
android:scaleY="0.92"
|
||||
android:translateX="0.96"
|
||||
android:translateY="0.96">
|
||||
<path
|
||||
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
|
||||
android:fillColor="#FF000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 400 B |
Binary file not shown.
|
Before Width: | Height: | Size: 263 B |
Binary file not shown.
|
Before Width: | Height: | Size: 508 B |
Binary file not shown.
|
Before Width: | Height: | Size: 769 B |
@@ -4,12 +4,5 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="app_name">Video Framework Tester</string>
|
||||
<string name="notification_channel_id">transcode-progress</string>
|
||||
<string name="notification_title">Encoding video…</string>
|
||||
<string name="cancel_transcode">Cancel</string>
|
||||
<string name="channel_name">Transcoding progress updates.</string>
|
||||
<string name="channel_description">Persistent notifications that allow the transcode job to complete when the app is in the background.</string>
|
||||
<string name="preference_file_key">settings</string>
|
||||
<string name="preference_activity_shortcut_key">activity_shortcut</string>
|
||||
</resources>
|
||||
<string name="app_name">Video Transcode Demo</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight" />
|
||||
</resources>
|
||||
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
8
demo/video/src/main/res/xml/file_paths.xml
Normal file
8
demo/video/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2024 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<paths>
|
||||
<files-path name="internal" path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user