mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-01 22:25:46 +01:00
StreamingTranscoder sample app.
This commit is contained in:
committed by
Greyson Parrelli
parent
750fd4efe1
commit
c7609f9a2a
@@ -3,8 +3,9 @@
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<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"/>
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -23,6 +24,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
|
||||
android:name=".transcode.TranscodeTestActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
@@ -30,6 +32,10 @@
|
||||
android:name=".playback.PlaybackTestActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Signal" />
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -76,11 +76,11 @@ class PlaybackTestActivity : AppCompatActivity() {
|
||||
*/
|
||||
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri != null) {
|
||||
Log.d("PhotoPicker", "Selected URI: $uri")
|
||||
Log.d("PlaybackPicker", "Selected URI: $uri")
|
||||
viewModel.selectedVideo = uri
|
||||
viewModel.updateMediaSource(this)
|
||||
} else {
|
||||
Log.d("PhotoPicker", "No media selected")
|
||||
Log.d("PlaybackPicker", "No media selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
class TestTranscoder
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.net.Uri
|
||||
import java.util.UUID
|
||||
|
||||
data class TranscodeJobSnapshot(val media: Uri, val jobId: UUID)
|
||||
@@ -1,10 +1,117 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* 2SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
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.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.thoughtcrime.video.app.ui.composables.LabeledButton
|
||||
import org.thoughtcrime.video.app.ui.theme.SignalTheme
|
||||
|
||||
class TranscodeTestActivity : AppCompatActivity()
|
||||
class TranscodeTestActivity : AppCompatActivity() {
|
||||
private val viewModel: TranscodeTestViewModel by viewModels()
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.initialize(this)
|
||||
setContent {
|
||||
SignalTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val videoUris = viewModel.selectedVideos
|
||||
val outputDir = viewModel.outputDirectory
|
||||
val transcodingJobs = viewModel.getTranscodingJobsAsState().collectAsState(emptyList())
|
||||
if (transcodingJobs.value.isNotEmpty()) {
|
||||
transcodingJobs.value.forEach { workInfo ->
|
||||
val currentProgress = workInfo.progress.getInt(TranscodeWorker.KEY_PROGRESS, -1)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = "...${workInfo.id.toString().takeLast(4)}", modifier = Modifier.padding(end = 16.dp).weight(1f))
|
||||
if (workInfo.state.isFinished) {
|
||||
LinearProgressIndicator(progress = 1f, trackColor = MaterialTheme.colorScheme.secondary, modifier = Modifier.weight(3f))
|
||||
} else if (currentProgress >= 0) {
|
||||
LinearProgressIndicator(progress = currentProgress / 100f, modifier = Modifier.weight(3f))
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.weight(3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledButton("Reset/Cancel") { viewModel.reset() }
|
||||
} else if (videoUris.isEmpty()) {
|
||||
LabeledButton("Select Videos") { pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) }
|
||||
} else if (outputDir == null) {
|
||||
LabeledButton("Select Output Directory") { outputDirRequest.launch(null) }
|
||||
} else {
|
||||
Text(text = "Selected videos:", modifier = Modifier.align(Alignment.Start).padding(16.dp))
|
||||
videoUris.forEach {
|
||||
Text(text = it.toString(), fontSize = 8.sp, fontFamily = FontFamily.Monospace, modifier = Modifier.align(Alignment.Start).padding(horizontal = 16.dp))
|
||||
}
|
||||
LabeledButton(buttonLabel = "Transcode") {
|
||||
viewModel.transcode()
|
||||
viewModel.selectedVideos = emptyList()
|
||||
viewModel.resetOutputDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
getComposeView()?.keepScreenOn = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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("VideoPicker", "Selected URI: $uris")
|
||||
viewModel.selectedVideos = uris
|
||||
viewModel.resetOutputDirectory()
|
||||
} else {
|
||||
Log.d("VideoPicker", "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 fun getComposeView(): ComposeView? {
|
||||
return window.decorView
|
||||
.findViewById<ViewGroup>(android.R.id.content)
|
||||
.getChildAt(0) as? ComposeView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.provider.DocumentsContract
|
||||
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.signal.core.util.readToList
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
|
||||
class TranscodeTestRepository(context: Context) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val usedNotificationIds = emptySet<Int>()
|
||||
|
||||
fun transcode(selectedVideos: List<Uri>, outputDirectory: Uri): Map<UUID, Uri> {
|
||||
if (selectedVideos.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val urisAndRequests = selectedVideos.map {
|
||||
var notificationId = Random.nextInt().absoluteValue
|
||||
while (usedNotificationIds.contains(notificationId)) {
|
||||
notificationId = Random.nextInt().absoluteValue
|
||||
}
|
||||
val transcodeRequest = OneTimeWorkRequestBuilder<TranscodeWorker>()
|
||||
.setInputData(createInputDataForWorkRequest(it, outputDirectory, notificationId))
|
||||
.addTag(TRANSCODING_WORK_TAG)
|
||||
.build()
|
||||
it to transcodeRequest
|
||||
}
|
||||
|
||||
val idsToUris = urisAndRequests.associateBy({ it.second.id }, { it.first })
|
||||
val requests = urisAndRequests.map { it.second }
|
||||
var continuation = workManager.beginWith(requests.first())
|
||||
for (request in requests.drop(1)) {
|
||||
continuation = continuation.then(request)
|
||||
}
|
||||
continuation.enqueue()
|
||||
return idsToUris
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsFlow(jobIds: List<UUID>): Flow<MutableList<WorkInfo>> {
|
||||
if (jobIds.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
return workManager.getWorkInfosFlow(WorkQuery.fromIds(jobIds))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the input data bundle which includes the blur level to
|
||||
* update the amount of blur to be applied and the Uri to operate on
|
||||
* @return Data which contains the Image Uri as a String and blur level as an Integer
|
||||
*/
|
||||
private fun createInputDataForWorkRequest(selectedVideo: Uri, outputUri: Uri, notificationId: Int): Data {
|
||||
return Data.Builder()
|
||||
.putString(TranscodeWorker.KEY_INPUT_URI, selectedVideo.toString())
|
||||
.putString(TranscodeWorker.KEY_OUTPUT_URI, outputUri.toString())
|
||||
.putInt(TranscodeWorker.KEY_NOTIFICATION_ID, notificationId)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun cancelAllTranscodes() {
|
||||
workManager.cancelAllWorkByTag(TRANSCODING_WORK_TAG)
|
||||
workManager.pruneWork()
|
||||
}
|
||||
|
||||
fun cleanFailedTranscodes(context: Context, folderUri: Uri) {
|
||||
val docs = queryChildDocuments(context, folderUri)
|
||||
docs.filter { it.documentId.endsWith(".mp4") && it.size == 0L }.forEach {
|
||||
val fileUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, it.documentId)
|
||||
DocumentsContract.deleteDocument(context.contentResolver, fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryChildDocuments(context: Context, folderUri: Uri): List<FileMetadata> {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
folderUri,
|
||||
DocumentsContract.getTreeDocumentId(folderUri)
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
childrenUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_SIZE),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use { cursor ->
|
||||
if (cursor == null) {
|
||||
return emptyList()
|
||||
}
|
||||
return cursor.readToList {
|
||||
FileMetadata(
|
||||
documentId = it.getString(0),
|
||||
label = it.getString(1),
|
||||
size = it.getLong(2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FileMetadata(val documentId: String, val label: String, val size: Long)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranscodingTestRepository"
|
||||
const val TRANSCODING_WORK_TAG = "transcoding"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.work.WorkInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.UUID
|
||||
|
||||
class TranscodeTestViewModel : ViewModel() {
|
||||
private lateinit var repository: TranscodeTestRepository
|
||||
private var backPressedRunnable = {}
|
||||
private var transcodingJobs: Map<UUID, Uri> = emptyMap()
|
||||
var outputDirectory: Uri? by mutableStateOf(null)
|
||||
private set
|
||||
var selectedVideos: List<Uri> by mutableStateOf(emptyList())
|
||||
|
||||
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 = repository.transcode(selectedVideos, output)
|
||||
}
|
||||
|
||||
fun getTranscodingJobsAsState(): Flow<MutableList<WorkInfo>> {
|
||||
return repository.getTranscodingJobsAsFlow(transcodingJobs.keys.toList())
|
||||
}
|
||||
|
||||
fun setOutputDirectoryAndCleanFailedTranscodes(context: Context, folderUri: Uri) {
|
||||
outputDirectory = folderUri
|
||||
repository.cleanFailedTranscodes(context, folderUri)
|
||||
}
|
||||
|
||||
fun getUriFromJobId(jobId: UUID): Uri? {
|
||||
return transcodingJobs[jobId]
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cancelAllTranscodes()
|
||||
resetOutputDirectory()
|
||||
selectedVideos = emptyList()
|
||||
}
|
||||
|
||||
fun cancelAllTranscodes() {
|
||||
repository.cancelAllTranscodes()
|
||||
transcodingJobs = emptyMap()
|
||||
}
|
||||
|
||||
fun resetOutputDirectory() {
|
||||
outputDirectory = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.video.app.transcode
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Context.NOTIFICATION_SERVICE
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
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.getLength
|
||||
import org.thoughtcrime.securesms.video.StreamingTranscoder
|
||||
import org.thoughtcrime.securesms.video.videoconverter.VideoConstants
|
||||
import org.thoughtcrime.securesms.video.videoconverter.mediadatasource.InputStreamMediaDataSource
|
||||
import org.thoughtcrime.video.app.R
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
|
||||
class TranscodeWorker(private val ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
|
||||
@UnstableApi
|
||||
override suspend fun doWork(): Result {
|
||||
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
|
||||
if (notificationId < 0) {
|
||||
Log.w(TAG, "Notification ID was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val inputUri = inputData.getString(KEY_INPUT_URI)
|
||||
if (inputUri == null) {
|
||||
Log.w(TAG, "Input URI was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val outputDirUri = inputData.getString(KEY_OUTPUT_URI)
|
||||
if (outputDirUri == null) {
|
||||
Log.w(TAG, "Output URI was null!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val input = DocumentFile.fromSingleUri(ctx, Uri.parse(inputUri))?.name
|
||||
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Could not read input file name!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val outputFileUri = createFile(Uri.parse(outputDirUri), "transcoded-${Instant.now()}-$input$OUTPUT_FILE_EXTENSION")
|
||||
|
||||
if (outputFileUri == null) {
|
||||
Log.w(TAG, "Could not create output file!")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val datasource = WorkerMediaDataSource(ctx, Uri.parse(inputUri))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
Log.w(TAG, "Transcoder is only supported on API 26+!")
|
||||
return Result.failure()
|
||||
}
|
||||
val transcoder = StreamingTranscoder(datasource, null, 50 * 1024 * 1024) // TODO: set options
|
||||
setForeground(createForegroundInfo(-1, notificationId))
|
||||
ctx.contentResolver.openFileDescriptor(outputFileUri, "w").use { it: ParcelFileDescriptor? ->
|
||||
if (it == null) {
|
||||
Log.w(TAG, "Could not open output file for writing!")
|
||||
return Result.failure()
|
||||
}
|
||||
transcoder.transcode(
|
||||
{ percent: Int ->
|
||||
setProgressAsync(Data.Builder().putInt(KEY_PROGRESS, percent).build())
|
||||
setForegroundAsync(createForegroundInfo(percent, notificationId))
|
||||
},
|
||||
FileOutputStream(it.fileDescriptor),
|
||||
{ isStopped }
|
||||
)
|
||||
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())
|
||||
|
||||
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_LOW
|
||||
val mChannel = NotificationChannel(id, name, importance)
|
||||
mChannel.description = descriptionText
|
||||
val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, id)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setProgress(100, progress, progress >= 0)
|
||||
.setSmallIcon(R.drawable.ic_work_notification)
|
||||
.setOngoing(true)
|
||||
.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): Uri? {
|
||||
return DocumentFile.fromTreeUri(ctx, treeUri)?.createFile(VideoConstants.VIDEO_MIME_TYPE, filename)?.uri
|
||||
}
|
||||
|
||||
private class WorkerMediaDataSource(context: Context, private val uri: Uri) : InputStreamMediaDataSource() {
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
private val size = contentResolver.getLength(uri) ?: throw IllegalStateException()
|
||||
|
||||
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 = contentResolver.openInputStream(uri) ?: throw IllegalStateException()
|
||||
openedInputStream.skip(position)
|
||||
inputStream = openedInputStream
|
||||
return openedInputStream
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranscodeWorker"
|
||||
private const val OUTPUT_FILE_EXTENSION = ".mp4"
|
||||
const val KEY_INPUT_URI = "input_uri"
|
||||
const val KEY_OUTPUT_URI = "output_uri"
|
||||
const val KEY_PROGRESS = "progress"
|
||||
const val KEY_NOTIFICATION_ID = "notification_id"
|
||||
}
|
||||
}
|
||||
@@ -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
BIN
video/app/src/main/res/drawable-hdpi/ic_work_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 400 B |
BIN
video/app/src/main/res/drawable-mdpi/ic_work_notification.png
Normal file
BIN
video/app/src/main/res/drawable-mdpi/ic_work_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 B |
BIN
video/app/src/main/res/drawable-xhdpi/ic_work_notification.png
Normal file
BIN
video/app/src/main/res/drawable-xhdpi/ic_work_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 508 B |
BIN
video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png
Normal file
BIN
video/app/src/main/res/drawable-xxhdpi/ic_work_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 769 B |
@@ -5,4 +5,9 @@
|
||||
|
||||
<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>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user