Source added
1
video/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
76
video/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("signal-sample-app")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
val signalBuildToolsVersion: String by rootProject.extra
|
||||
val signalCompileSdkVersion: String by rootProject.extra
|
||||
val signalTargetSdkVersion: Int by rootProject.extra
|
||||
val signalMinSdkVersion: Int by rootProject.extra
|
||||
val signalJavaVersion: JavaVersion by rootProject.extra
|
||||
val signalKotlinJvmTarget: String by rootProject.extra
|
||||
|
||||
android {
|
||||
namespace = "org.thoughtcrime.video.app"
|
||||
compileSdkVersion = signalCompileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "org.thoughtcrime.video.app"
|
||||
minSdk = 23
|
||||
targetSdk = signalTargetSdkVersion
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = signalJavaVersion
|
||||
targetCompatibility = signalJavaVersion
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(project(":video"))
|
||||
implementation(project(":core-util"))
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
|
||||
implementation(libs.androidx.compose.ui.tooling.core)
|
||||
implementation(libs.androidx.compose.ui.test.manifest)
|
||||
androidTestImplementation(testLibs.junit.junit)
|
||||
androidTestImplementation(testLibs.androidx.test.runner)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
|
||||
}
|
||||
21
video/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("org.thoughtcrime.video.app", appContext.packageName)
|
||||
}
|
||||
}
|
||||
47
video/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ 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" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Signal">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Signal">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<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" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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.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.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Checkbox
|
||||
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 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.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
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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({}, {})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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") {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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("")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
/**
|
||||
* A dumping ground for constants that should be referenced across the sample app.
|
||||
*/
|
||||
internal const val MIN_VIDEO_MEGABITRATE = 0.5f
|
||||
internal const val MAX_VIDEO_MEGABITRATE = 4f
|
||||
internal val OPTIONS_AUDIO_KILOBITRATES = listOf(64, 96, 128, 160, 192)
|
||||
|
||||
enum class VideoResolution(val longEdge: Int, val shortEdge: Int) {
|
||||
SD(854, 480),
|
||||
HD(1280, 720),
|
||||
FHD(1920, 1080),
|
||||
WQHD(2560, 1440),
|
||||
UHD(3840, 2160);
|
||||
|
||||
fun getContentDescription(): String {
|
||||
return "Resolution with a long edge of $longEdge and a short edge of $shortEdge."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
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 org.thoughtcrime.securesms.video.TranscodingPreset
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Repository to perform various transcoding functions.
|
||||
*/
|
||||
class TranscodeTestRepository(context: Context) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val usedNotificationIds = emptySet<Int>()
|
||||
|
||||
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!")
|
||||
}
|
||||
|
||||
if (selectedVideos.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val urisAndRequests = selectedVideos.map {
|
||||
var notificationId = Random.nextInt().absoluteValue
|
||||
while (usedNotificationIds.contains(notificationId)) {
|
||||
notificationId = Random.nextInt().absoluteValue
|
||||
}
|
||||
val inputData = Data.Builder()
|
||||
.putString(TranscodeWorker.KEY_INPUT_URI, it.toString())
|
||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputDirectory.toString())
|
||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||
|
||||
if (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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
data class CustomTranscodingOptions(val videoCodec: String, val videoResolution: VideoResolution, val videoBitrate: Int, val audioBitrate: Int, val enableFastStart: Boolean, val enableAudioRemux: Boolean)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranscodingTestRepository"
|
||||
const val TRANSCODING_WORK_TAG = "transcoding"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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 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].
|
||||
*/
|
||||
class TranscodeTestViewModel : ViewModel() {
|
||||
private lateinit var repository: TranscodeTestRepository
|
||||
private var backPressedRunnable = {}
|
||||
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
||||
|
||||
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))
|
||||
var useHevc by mutableStateOf(false)
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTranscodingPreset(preset: TranscodingPreset) {
|
||||
transcodingPreset = preset
|
||||
videoResolution = convertPresetToVideoResolution(preset)
|
||||
videoMegaBitrate = calculateVideoMegaBitrateFromPreset(preset)
|
||||
audioKiloBitrate = calculateAudioKiloBitrateFromPreset(preset)
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
||||
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
|
||||
}
|
||||
|
||||
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
|
||||
outputDirectory = folderUri
|
||||
repository.cleanPrivateStorage(context)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cancelAllTranscodes()
|
||||
resetOutputDirectory()
|
||||
selectedVideos = emptyList()
|
||||
}
|
||||
|
||||
private fun cancelAllTranscodes() {
|
||||
repository.cancelAllTranscodes()
|
||||
transcodingJobs = emptyMap()
|
||||
}
|
||||
|
||||
fun resetOutputDirectory() {
|
||||
outputDirectory = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MEGABIT = 1000000
|
||||
private const val KILOBIT = 1000
|
||||
|
||||
@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
|
||||
else -> VideoResolution.SD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
/*
|
||||
* 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,325 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.thoughtcrime.securesms.video.TranscodingPreset
|
||||
import org.thoughtcrime.securesms.video.videoconverter.utils.DeviceCapabilities
|
||||
import org.thoughtcrime.video.app.transcode.MAX_VIDEO_MEGABITRATE
|
||||
import org.thoughtcrime.video.app.transcode.MIN_VIDEO_MEGABITRATE
|
||||
import org.thoughtcrime.video.app.transcode.OPTIONS_AUDIO_KILOBITRATES
|
||||
import org.thoughtcrime.video.app.transcode.TranscodeTestViewModel
|
||||
import org.thoughtcrime.video.app.transcode.VideoResolution
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A view that shows the queue of video URIs to encode, and allows you to change the encoding options.
|
||||
*/
|
||||
@Composable
|
||||
fun ConfigureEncodingParameters(
|
||||
hevcCapable: Boolean = DeviceCapabilities.canEncodeHevc(),
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TranscodeTestViewModel = viewModel()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = "Selected videos:",
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.align(Alignment.Start)
|
||||
)
|
||||
viewModel.selectedVideos.forEach {
|
||||
Text(
|
||||
text = it.toString(),
|
||||
fontSize = 8.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.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
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = viewModel.useAutoTranscodingSettings,
|
||||
onCheckedChange = { viewModel.useAutoTranscodingSettings = it }
|
||||
)
|
||||
Text(
|
||||
text = "Match Signal App Transcoding Settings",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
if (viewModel.useAutoTranscodingSettings) {
|
||||
PresetPicker(
|
||||
viewModel.transcodingPreset,
|
||||
viewModel::updateTranscodingPreset,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
} else {
|
||||
CustomSettings(
|
||||
selectedResolution = viewModel.videoResolution,
|
||||
onResolutionSelected = { viewModel.videoResolution = it },
|
||||
useHevc = viewModel.useHevc,
|
||||
onUseHevcSettingChanged = { viewModel.useHevc = it },
|
||||
fastStartChecked = viewModel.enableFastStart,
|
||||
onFastStartSettingCheckChanged = { viewModel.enableFastStart = it },
|
||||
audioRemuxChecked = viewModel.enableAudioRemux,
|
||||
onAudioRemuxCheckChanged = { viewModel.enableAudioRemux = it },
|
||||
videoSliderPosition = viewModel.videoMegaBitrate,
|
||||
updateVideoSliderPosition = { viewModel.videoMegaBitrate = it },
|
||||
audioSliderPosition = viewModel.audioKiloBitrate,
|
||||
updateAudioSliderPosition = { viewModel.audioKiloBitrate = it.roundToInt() },
|
||||
hevcCapable = hevcCapable,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
LabeledButton(
|
||||
buttonLabel = "Transcode",
|
||||
onClick = {
|
||||
viewModel.transcode()
|
||||
viewModel.selectedVideos = emptyList()
|
||||
viewModel.resetOutputDirectory()
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetPicker(
|
||||
selectedTranscodingPreset: TranscodingPreset,
|
||||
onPresetSelected: (TranscodingPreset) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
) {
|
||||
TranscodingPreset.entries.forEach {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.selectable(
|
||||
selected = selectedTranscodingPreset == it,
|
||||
onClick = {
|
||||
onPresetSelected(it)
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedTranscodingPreset == it,
|
||||
onClick = null,
|
||||
modifier = Modifier.semantics { contentDescription = it.name }
|
||||
)
|
||||
Text(
|
||||
text = it.name,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomSettings(
|
||||
selectedResolution: VideoResolution,
|
||||
onResolutionSelected: (VideoResolution) -> Unit,
|
||||
useHevc: Boolean,
|
||||
onUseHevcSettingChanged: (Boolean) -> Unit,
|
||||
fastStartChecked: Boolean,
|
||||
onFastStartSettingCheckChanged: (Boolean) -> Unit,
|
||||
audioRemuxChecked: Boolean,
|
||||
onAudioRemuxCheckChanged: (Boolean) -> Unit,
|
||||
videoSliderPosition: Float,
|
||||
updateVideoSliderPosition: (Float) -> Unit,
|
||||
audioSliderPosition: Int,
|
||||
updateAudioSliderPosition: (Float) -> Unit,
|
||||
hevcCapable: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.selectableGroup()
|
||||
) {
|
||||
VideoResolution.entries.forEach {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.selectable(
|
||||
selected = selectedResolution == it,
|
||||
onClick = { onResolutionSelected(it) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedResolution == it,
|
||||
onClick = null,
|
||||
modifier = Modifier.semantics { contentDescription = it.getContentDescription() }
|
||||
)
|
||||
Text(
|
||||
text = "${it.shortEdge}p",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
VideoBitrateSlider(videoSliderPosition, updateVideoSliderPosition)
|
||||
AudioBitrateSlider(audioSliderPosition, updateAudioSliderPosition)
|
||||
|
||||
if (hevcCapable) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = useHevc,
|
||||
onCheckedChange = { onUseHevcSettingChanged(it) }
|
||||
)
|
||||
Text(text = "Use HEVC encoder", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = audioRemuxChecked,
|
||||
onCheckedChange = { onAudioRemuxCheckChanged(it) }
|
||||
)
|
||||
Text(text = "Allow audio remuxing", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = fastStartChecked,
|
||||
onCheckedChange = { onFastStartSettingCheckChanged(it) }
|
||||
)
|
||||
Text(text = "Perform Mp4San Postprocessing", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoBitrateSlider(
|
||||
videoSliderPosition: Float,
|
||||
updateSliderPosition: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Slider(
|
||||
value = videoSliderPosition,
|
||||
onValueChange = updateSliderPosition,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.secondary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.secondary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
),
|
||||
valueRange = MIN_VIDEO_MEGABITRATE..MAX_VIDEO_MEGABITRATE,
|
||||
modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
)
|
||||
Text(text = String.format("Video: %.2f Mbit/s", videoSliderPosition))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioBitrateSlider(
|
||||
audioSliderPosition: Int,
|
||||
updateSliderPosition: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val minValue = OPTIONS_AUDIO_KILOBITRATES.first().toFloat()
|
||||
val maxValue = OPTIONS_AUDIO_KILOBITRATES.last().toFloat()
|
||||
val steps = OPTIONS_AUDIO_KILOBITRATES.size - 2
|
||||
|
||||
Slider(
|
||||
value = audioSliderPosition.toFloat(),
|
||||
onValueChange = updateSliderPosition,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.secondary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.secondary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
),
|
||||
valueRange = minValue..maxValue,
|
||||
steps = steps,
|
||||
modifier = modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
)
|
||||
Text(text = String.format("Audio: %d Kbit/s", audioSliderPosition))
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ConfigurationScreenPreviewChecked() {
|
||||
val vm: TranscodeTestViewModel = viewModel()
|
||||
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
|
||||
vm.forceSequentialQueueProcessing = true
|
||||
ConfigureEncodingParameters()
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ConfigurationScreenPreviewUnchecked() {
|
||||
val vm: TranscodeTestViewModel = viewModel()
|
||||
vm.selectedVideos = listOf(Uri.parse("content://1"), Uri.parse("content://2"))
|
||||
vm.useAutoTranscodingSettings = false
|
||||
ConfigureEncodingParameters(hevcCapable = true)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that prompts you to select input videos for transcoding.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectInput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LabeledButton("Select Videos", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun InputSelectionPreview() {
|
||||
SelectInput { }
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
|
||||
/**
|
||||
* A view that prompts you to select an output directory that transcoded videos will be saved to.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectOutput(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LabeledButton("Select Output Directory", onClick = onClick, modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun OutputSelectionPreview() {
|
||||
SelectOutput { }
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* 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,18 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.ui.composables
|
||||
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun LabeledButton(buttonLabel: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Button(onClick = onClick, modifier = modifier) {
|
||||
Text(buttonLabel)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.thoughtcrime.video.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
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
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
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
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<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>
|
||||
BIN
video/app/src/main/res/drawable-hdpi/ic_work_notification.png
Normal file
|
After Width: | Height: | Size: 400 B |
BIN
video/app/src/main/res/drawable-mdpi/ic_work_notification.png
Normal file
|
After Width: | Height: | Size: 263 B |
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
BIN
video/app/src/main/res/drawable-xhdpi/ic_work_notification.png
Normal file
|
After Width: | Height: | Size: 508 B |
BIN
video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png
Normal file
|
After Width: | Height: | Size: 769 B |
175
video/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
11
video/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
video/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
video/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
video/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
video/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
video/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
video/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
video/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
video/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
video/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
video/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
15
video/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
15
video/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<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>
|
||||
10
video/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight" />
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||