diff --git a/constants.gradle b/constants.gradle
index 50ddb74592..7805a8dfb9 100644
--- a/constants.gradle
+++ b/constants.gradle
@@ -37,6 +37,7 @@ project.ext {
androidxAnnotationExperimentalVersion = '1.2.0'
androidxAppCompatVersion = '1.3.1'
androidxCollectionVersion = '1.1.0'
+ androidxConstraintLayoutVersion = '2.0.4'
androidxCoreVersion = '1.7.0'
androidxFuturesVersion = '1.1.0'
androidxMediaVersion = '1.4.3'
diff --git a/demos/transformer/README.md b/demos/transformer/README.md
new file mode 100644
index 0000000000..fb2657001e
--- /dev/null
+++ b/demos/transformer/README.md
@@ -0,0 +1,9 @@
+# Transformer demo
+
+This app demonstrates how to use the [Transformer][] API to modify videos, for
+example by removing audio or video.
+
+See the [demos README](../README.md) for instructions on how to build and run
+this demo.
+
+[Transformer]: https://exoplayer.dev/transforming-media.html
diff --git a/demos/transformer/build.gradle b/demos/transformer/build.gradle
new file mode 100644
index 0000000000..14825588ba
--- /dev/null
+++ b/demos/transformer/build.gradle
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+apply from: '../../constants.gradle'
+apply plugin: 'com.android.application'
+
+android {
+ compileSdkVersion project.ext.compileSdkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ versionName project.ext.releaseVersion
+ versionCode project.ext.releaseVersionCode
+ minSdkVersion 21
+ targetSdkVersion project.ext.appTargetSdkVersion
+ multiDexEnabled true
+ }
+
+ buildTypes {
+ release {
+ shrinkResources true
+ minifyEnabled true
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ signingConfig signingConfigs.debug
+ }
+ }
+
+ lintOptions {
+ // This demo app isn't indexed and doesn't have translations.
+ disable 'GoogleAppIndexingWarning','MissingTranslation'
+ }
+}
+
+dependencies {
+ compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
+ implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion
+ implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion
+ implementation 'androidx.constraintlayout:constraintlayout:' + androidxConstraintLayoutVersion
+ implementation 'androidx.multidex:multidex:' + androidxMultidexVersion
+ implementation 'com.google.android.material:material:' + androidxMaterialVersion
+ implementation project(modulePrefix + 'lib-exoplayer')
+ implementation project(modulePrefix + 'lib-transformer')
+ implementation project(modulePrefix + 'lib-ui')
+}
diff --git a/demos/transformer/src/main/AndroidManifest.xml b/demos/transformer/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..b14bc3622b
--- /dev/null
+++ b/demos/transformer/src/main/AndroidManifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/transformer/src/main/java/androidx/media3/demo/transformer/ConfigurationActivity.java b/demos/transformer/src/main/java/androidx/media3/demo/transformer/ConfigurationActivity.java
new file mode 100644
index 0000000000..de0369c346
--- /dev/null
+++ b/demos/transformer/src/main/java/androidx/media3/demo/transformer/ConfigurationActivity.java
@@ -0,0 +1,302 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.demo.transformer;
+
+import static androidx.media3.common.util.Assertions.checkNotNull;
+import static androidx.media3.common.util.Assertions.checkState;
+
+import android.app.Activity;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.Spinner;
+import android.widget.TextView;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.media3.common.MimeTypes;
+import java.util.Arrays;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/**
+ * An {@link Activity} that sets the configuration to use for transforming and playing media, using
+ * {@link TransformerActivity}.
+ */
+public final class ConfigurationActivity extends AppCompatActivity {
+ public static final String SHOULD_REMOVE_AUDIO = "should_remove_audio";
+ public static final String SHOULD_REMOVE_VIDEO = "should_remove_video";
+ public static final String SHOULD_FLATTEN_FOR_SLOW_MOTION = "should_flatten_for_slow_motion";
+ public static final String AUDIO_MIME_TYPE = "audio_mime_type";
+ public static final String VIDEO_MIME_TYPE = "video_mime_type";
+ public static final String RESOLUTION_HEIGHT = "resolution_height";
+ public static final String TRANSLATE_X = "translate_x";
+ public static final String TRANSLATE_Y = "translate_y";
+ public static final String SCALE_X = "scale_x";
+ public static final String SCALE_Y = "scale_y";
+ public static final String ROTATE_DEGREES = "rotate_degrees";
+ private static final String[] INPUT_URIS = {
+ "https://html5demos.com/assets/dizzy.mp4",
+ "https://storage.googleapis.com/exoplayer-test-media-0/android-block-1080-hevc.mp4",
+ "https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4",
+ "https://html5demos.com/assets/dizzy.webm",
+ };
+ private static final String[] URI_DESCRIPTIONS = { // same order as INPUT_URIS
+ "MP4 with H264 video and AAC audio",
+ "MP4 with H265 video and AAC audio",
+ "Long MP4 with H264 video and AAC audio",
+ "WebM with VP8 video and Vorbis audio",
+ };
+ private static final String SAME_AS_INPUT_OPTION = "same as input";
+
+ private @MonotonicNonNull Button chooseFileButton;
+ private @MonotonicNonNull CheckBox removeAudioCheckbox;
+ private @MonotonicNonNull CheckBox removeVideoCheckbox;
+ private @MonotonicNonNull CheckBox flattenForSlowMotionCheckbox;
+ private @MonotonicNonNull Spinner audioMimeSpinner;
+ private @MonotonicNonNull Spinner videoMimeSpinner;
+ private @MonotonicNonNull Spinner resolutionHeightSpinner;
+ private @MonotonicNonNull Spinner translateSpinner;
+ private @MonotonicNonNull Spinner scaleSpinner;
+ private @MonotonicNonNull Spinner rotateSpinner;
+ private @MonotonicNonNull TextView chosenFileTextView;
+ private int inputUriPosition;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.configuration_activity);
+
+ findViewById(R.id.transform_button).setOnClickListener(this::startTransformation);
+
+ chooseFileButton = findViewById(R.id.choose_file_button);
+ chooseFileButton.setOnClickListener(this::chooseFile);
+
+ chosenFileTextView = findViewById(R.id.chosen_file_text_view);
+ chosenFileTextView.setText(URI_DESCRIPTIONS[inputUriPosition]);
+
+ removeAudioCheckbox = findViewById(R.id.remove_audio_checkbox);
+ removeAudioCheckbox.setOnClickListener(this::onRemoveAudio);
+
+ removeVideoCheckbox = findViewById(R.id.remove_video_checkbox);
+ removeVideoCheckbox.setOnClickListener(this::onRemoveVideo);
+
+ flattenForSlowMotionCheckbox = findViewById(R.id.flatten_for_slow_motion_checkbox);
+
+ ArrayAdapter audioMimeAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ audioMimeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ audioMimeSpinner = findViewById(R.id.audio_mime_spinner);
+ audioMimeSpinner.setAdapter(audioMimeAdapter);
+ audioMimeAdapter.addAll(
+ SAME_AS_INPUT_OPTION, MimeTypes.AUDIO_AAC, MimeTypes.AUDIO_AMR_NB, MimeTypes.AUDIO_AMR_WB);
+
+ ArrayAdapter videoMimeAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ videoMimeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ videoMimeSpinner = findViewById(R.id.video_mime_spinner);
+ videoMimeSpinner.setAdapter(videoMimeAdapter);
+ videoMimeAdapter.addAll(
+ SAME_AS_INPUT_OPTION,
+ MimeTypes.VIDEO_H263,
+ MimeTypes.VIDEO_H264,
+ MimeTypes.VIDEO_H265,
+ MimeTypes.VIDEO_MP4V);
+
+ ArrayAdapter resolutionHeightAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ resolutionHeightAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ resolutionHeightSpinner = findViewById(R.id.resolution_height_spinner);
+ resolutionHeightSpinner.setAdapter(resolutionHeightAdapter);
+ resolutionHeightAdapter.addAll(
+ SAME_AS_INPUT_OPTION, "144", "240", "360", "480", "720", "1080", "1440", "2160");
+
+ ArrayAdapter translateAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ translateAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ translateSpinner = findViewById(R.id.translate_spinner);
+ translateSpinner.setAdapter(translateAdapter);
+ translateAdapter.addAll(
+ SAME_AS_INPUT_OPTION, "-.1, -.1", "0, 0", ".5, 0", "0, .5", "1, 1", "1.9, 0", "0, 1.9");
+
+ ArrayAdapter scaleAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ scaleAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ scaleSpinner = findViewById(R.id.scale_spinner);
+ scaleSpinner.setAdapter(scaleAdapter);
+ scaleAdapter.addAll(SAME_AS_INPUT_OPTION, "-1, -1", "-1, 1", "1, 1", ".5, 1", ".5, .5", "2, 2");
+
+ ArrayAdapter rotateAdapter =
+ new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item);
+ rotateAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ rotateSpinner = findViewById(R.id.rotate_spinner);
+ rotateSpinner.setAdapter(rotateAdapter);
+ rotateAdapter.addAll(SAME_AS_INPUT_OPTION, "0", "10", "45", "90", "180");
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ @Nullable Uri intentUri = getIntent().getData();
+ if (intentUri != null) {
+ checkNotNull(chooseFileButton).setEnabled(false);
+ checkNotNull(chosenFileTextView).setText(intentUri.toString());
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ }
+
+ @RequiresNonNull({
+ "removeAudioCheckbox",
+ "removeVideoCheckbox",
+ "flattenForSlowMotionCheckbox",
+ "audioMimeSpinner",
+ "videoMimeSpinner",
+ "resolutionHeightSpinner",
+ "translateSpinner",
+ "scaleSpinner",
+ "rotateSpinner"
+ })
+ private void startTransformation(View view) {
+ Intent transformerIntent = new Intent(this, TransformerActivity.class);
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(SHOULD_REMOVE_AUDIO, removeAudioCheckbox.isChecked());
+ bundle.putBoolean(SHOULD_REMOVE_VIDEO, removeVideoCheckbox.isChecked());
+ bundle.putBoolean(SHOULD_FLATTEN_FOR_SLOW_MOTION, flattenForSlowMotionCheckbox.isChecked());
+ String selectedAudioMimeType = String.valueOf(audioMimeSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedAudioMimeType)) {
+ bundle.putString(AUDIO_MIME_TYPE, selectedAudioMimeType);
+ }
+ String selectedVideoMimeType = String.valueOf(videoMimeSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedVideoMimeType)) {
+ bundle.putString(VIDEO_MIME_TYPE, selectedVideoMimeType);
+ }
+ String selectedResolutionHeight = String.valueOf(resolutionHeightSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedResolutionHeight)) {
+ bundle.putInt(RESOLUTION_HEIGHT, Integer.valueOf(selectedResolutionHeight));
+ }
+ String selectedTranslate = String.valueOf(translateSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedTranslate)) {
+ List translateXY = Arrays.asList(selectedTranslate.split(", "));
+ checkState(translateXY.size() == 2);
+ bundle.putFloat(TRANSLATE_X, Float.valueOf(translateXY.get(0)));
+ bundle.putFloat(TRANSLATE_Y, Float.valueOf(translateXY.get(1)));
+ }
+ String selectedScale = String.valueOf(scaleSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedScale)) {
+ List scaleXY = Arrays.asList(selectedScale.split(", "));
+ checkState(scaleXY.size() == 2);
+ bundle.putFloat(SCALE_X, Float.valueOf(scaleXY.get(0)));
+ bundle.putFloat(SCALE_Y, Float.valueOf(scaleXY.get(1)));
+ }
+ String selectedRotate = String.valueOf(rotateSpinner.getSelectedItem());
+ if (!SAME_AS_INPUT_OPTION.equals(selectedRotate)) {
+ bundle.putFloat(ROTATE_DEGREES, Float.valueOf(selectedRotate));
+ }
+ transformerIntent.putExtras(bundle);
+
+ @Nullable Uri intentUri = getIntent().getData();
+ transformerIntent.setData(
+ intentUri != null ? intentUri : Uri.parse(INPUT_URIS[inputUriPosition]));
+
+ startActivity(transformerIntent);
+ }
+
+ private void chooseFile(View view) {
+ new AlertDialog.Builder(/* context= */ this)
+ .setTitle(R.string.choose_file_title)
+ .setSingleChoiceItems(URI_DESCRIPTIONS, inputUriPosition, this::selectFileInDialog)
+ .setPositiveButton(android.R.string.ok, /* listener= */ null)
+ .create()
+ .show();
+ }
+
+ @RequiresNonNull("chosenFileTextView")
+ private void selectFileInDialog(DialogInterface dialog, int which) {
+ inputUriPosition = which;
+ chosenFileTextView.setText(URI_DESCRIPTIONS[inputUriPosition]);
+ }
+
+ @RequiresNonNull({
+ "removeVideoCheckbox",
+ "audioMimeSpinner",
+ "videoMimeSpinner",
+ "resolutionHeightSpinner",
+ "translateSpinner",
+ "scaleSpinner",
+ "rotateSpinner"
+ })
+ private void onRemoveAudio(View view) {
+ if (((CheckBox) view).isChecked()) {
+ removeVideoCheckbox.setChecked(false);
+ enableTrackSpecificOptions(/* isAudioEnabled= */ false, /* isVideoEnabled= */ true);
+ } else {
+ enableTrackSpecificOptions(/* isAudioEnabled= */ true, /* isVideoEnabled= */ true);
+ }
+ }
+
+ @RequiresNonNull({
+ "removeAudioCheckbox",
+ "audioMimeSpinner",
+ "videoMimeSpinner",
+ "resolutionHeightSpinner",
+ "translateSpinner",
+ "scaleSpinner",
+ "rotateSpinner"
+ })
+ private void onRemoveVideo(View view) {
+ if (((CheckBox) view).isChecked()) {
+ removeAudioCheckbox.setChecked(false);
+ enableTrackSpecificOptions(/* isAudioEnabled= */ true, /* isVideoEnabled= */ false);
+ } else {
+ enableTrackSpecificOptions(/* isAudioEnabled= */ true, /* isVideoEnabled= */ true);
+ }
+ }
+
+ @RequiresNonNull({
+ "audioMimeSpinner",
+ "videoMimeSpinner",
+ "resolutionHeightSpinner",
+ "translateSpinner",
+ "scaleSpinner",
+ "rotateSpinner"
+ })
+ private void enableTrackSpecificOptions(boolean isAudioEnabled, boolean isVideoEnabled) {
+ audioMimeSpinner.setEnabled(isAudioEnabled);
+ videoMimeSpinner.setEnabled(isVideoEnabled);
+ resolutionHeightSpinner.setEnabled(isVideoEnabled);
+ translateSpinner.setEnabled(isVideoEnabled);
+ scaleSpinner.setEnabled(isVideoEnabled);
+ rotateSpinner.setEnabled(isVideoEnabled);
+
+ findViewById(R.id.audio_mime_text_view).setEnabled(isAudioEnabled);
+ findViewById(R.id.video_mime_text_view).setEnabled(isVideoEnabled);
+ findViewById(R.id.resolution_height_text_view).setEnabled(isVideoEnabled);
+ findViewById(R.id.translate).setEnabled(isVideoEnabled);
+ findViewById(R.id.scale).setEnabled(isVideoEnabled);
+ findViewById(R.id.rotate).setEnabled(isVideoEnabled);
+ }
+}
diff --git a/demos/transformer/src/main/java/androidx/media3/demo/transformer/TransformerActivity.java b/demos/transformer/src/main/java/androidx/media3/demo/transformer/TransformerActivity.java
new file mode 100644
index 0000000000..f82145ab84
--- /dev/null
+++ b/demos/transformer/src/main/java/androidx/media3/demo/transformer/TransformerActivity.java
@@ -0,0 +1,372 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.demo.transformer;
+
+import static android.Manifest.permission.READ_EXTERNAL_STORAGE;
+import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
+import static androidx.media3.common.util.Assertions.checkNotNull;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Matrix;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.media3.common.C;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.util.Log;
+import androidx.media3.common.util.Util;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.util.DebugTextViewHelper;
+import androidx.media3.transformer.ProgressHolder;
+import androidx.media3.transformer.TransformationException;
+import androidx.media3.transformer.TransformationRequest;
+import androidx.media3.transformer.Transformer;
+import androidx.media3.ui.AspectRatioFrameLayout;
+import androidx.media3.ui.PlayerView;
+import com.google.android.material.progressindicator.LinearProgressIndicator;
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Ticker;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+import org.checkerframework.checker.nullness.qual.RequiresNonNull;
+
+/** An {@link Activity} that transforms and plays media using {@link Transformer}. */
+public final class TransformerActivity extends AppCompatActivity {
+ private static final String TAG = "TransformerActivity";
+
+ private @MonotonicNonNull PlayerView playerView;
+ private @MonotonicNonNull TextView debugTextView;
+ private @MonotonicNonNull TextView informationTextView;
+ private @MonotonicNonNull ViewGroup progressViewGroup;
+ private @MonotonicNonNull LinearProgressIndicator progressIndicator;
+ private @MonotonicNonNull Stopwatch transformationStopwatch;
+ private @MonotonicNonNull AspectRatioFrameLayout debugFrame;
+
+ @Nullable private DebugTextViewHelper debugTextViewHelper;
+ @Nullable private ExoPlayer player;
+ @Nullable private Transformer transformer;
+ @Nullable private File externalCacheFile;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.transformer_activity);
+
+ playerView = findViewById(R.id.player_view);
+ debugTextView = findViewById(R.id.debug_text_view);
+ informationTextView = findViewById(R.id.information_text_view);
+ progressViewGroup = findViewById(R.id.progress_view_group);
+ progressIndicator = findViewById(R.id.progress_indicator);
+ debugFrame = findViewById(R.id.debug_aspect_ratio_frame_layout);
+
+ transformationStopwatch =
+ Stopwatch.createUnstarted(
+ new Ticker() {
+ public long read() {
+ return android.os.SystemClock.elapsedRealtimeNanos();
+ }
+ });
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+
+ checkNotNull(progressIndicator);
+ checkNotNull(informationTextView);
+ checkNotNull(transformationStopwatch);
+ checkNotNull(playerView);
+ checkNotNull(debugTextView);
+ checkNotNull(progressViewGroup);
+ startTransformation();
+
+ playerView.onResume();
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ checkNotNull(transformationStopwatch).reset();
+
+ checkNotNull(transformer).cancel();
+ transformer = null;
+
+ checkNotNull(playerView).onPause();
+ releasePlayer();
+
+ checkNotNull(externalCacheFile).delete();
+ externalCacheFile = null;
+ }
+
+ @RequiresNonNull({
+ "playerView",
+ "debugTextView",
+ "informationTextView",
+ "progressIndicator",
+ "transformationStopwatch",
+ "progressViewGroup",
+ })
+ private void startTransformation() {
+ requestTransformerPermissions();
+
+ Intent intent = getIntent();
+ Uri uri = checkNotNull(intent.getData());
+ try {
+ externalCacheFile = createExternalCacheFile("transformer-output.mp4");
+ String filePath = externalCacheFile.getAbsolutePath();
+ @Nullable Bundle bundle = intent.getExtras();
+ Transformer transformer = createTransformer(bundle, filePath);
+ transformationStopwatch.start();
+ transformer.startTransformation(MediaItem.fromUri(uri), filePath);
+ this.transformer = transformer;
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ informationTextView.setText(R.string.transformation_started);
+ Handler mainHandler = new Handler(getMainLooper());
+ ProgressHolder progressHolder = new ProgressHolder();
+ mainHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (transformer != null
+ && transformer.getProgress(progressHolder)
+ != Transformer.PROGRESS_STATE_NO_TRANSFORMATION) {
+ progressIndicator.setProgress(progressHolder.progress);
+ informationTextView.setText(
+ getString(
+ R.string.transformation_timer,
+ transformationStopwatch.elapsed(TimeUnit.SECONDS)));
+ mainHandler.postDelayed(/* r= */ this, /* delayMillis= */ 500);
+ }
+ }
+ });
+ }
+
+ // Create a cache file, resetting it if it already exists.
+ private File createExternalCacheFile(String fileName) throws IOException {
+ File file = new File(getExternalCacheDir(), fileName);
+ if (file.exists() && !file.delete()) {
+ throw new IllegalStateException("Could not delete the previous transformer output file");
+ }
+ if (!file.createNewFile()) {
+ throw new IllegalStateException("Could not create the transformer output file");
+ }
+ return file;
+ }
+
+ @RequiresNonNull({
+ "playerView",
+ "debugTextView",
+ "informationTextView",
+ "transformationStopwatch",
+ "progressViewGroup",
+ })
+ private Transformer createTransformer(@Nullable Bundle bundle, String filePath) {
+ Transformer.Builder transformerBuilder = new Transformer.Builder(/* context= */ this);
+ if (bundle != null) {
+ TransformationRequest.Builder requestBuilder = new TransformationRequest.Builder();
+ requestBuilder.setFlattenForSlowMotion(
+ bundle.getBoolean(ConfigurationActivity.SHOULD_FLATTEN_FOR_SLOW_MOTION));
+ @Nullable String audioMimeType = bundle.getString(ConfigurationActivity.AUDIO_MIME_TYPE);
+ if (audioMimeType != null) {
+ requestBuilder.setAudioMimeType(audioMimeType);
+ }
+ @Nullable String videoMimeType = bundle.getString(ConfigurationActivity.VIDEO_MIME_TYPE);
+ if (videoMimeType != null) {
+ requestBuilder.setVideoMimeType(videoMimeType);
+ }
+ int resolutionHeight =
+ bundle.getInt(
+ ConfigurationActivity.RESOLUTION_HEIGHT, /* defaultValue= */ C.LENGTH_UNSET);
+ if (resolutionHeight != C.LENGTH_UNSET) {
+ requestBuilder.setResolution(resolutionHeight);
+ }
+ Matrix transformationMatrix = getTransformationMatrix(bundle);
+ if (!transformationMatrix.isIdentity()) {
+ requestBuilder.setTransformationMatrix(transformationMatrix);
+ }
+ transformerBuilder
+ .setTransformationRequest(requestBuilder.build())
+ .setRemoveAudio(bundle.getBoolean(ConfigurationActivity.SHOULD_REMOVE_AUDIO))
+ .setRemoveVideo(bundle.getBoolean(ConfigurationActivity.SHOULD_REMOVE_VIDEO));
+ }
+ return transformerBuilder
+ .addListener(
+ new Transformer.Listener() {
+ @Override
+ public void onTransformationCompleted(MediaItem mediaItem) {
+ TransformerActivity.this.onTransformationCompleted(filePath);
+ }
+
+ @Override
+ public void onTransformationError(
+ MediaItem mediaItem, TransformationException exception) {
+ TransformerActivity.this.onTransformationError(exception);
+ }
+ })
+ .setDebugViewProvider(new DemoDebugViewProvider())
+ .build();
+ }
+
+ private static Matrix getTransformationMatrix(Bundle bundle) {
+ Matrix transformationMatrix = new Matrix();
+
+ float translateX = bundle.getFloat(ConfigurationActivity.TRANSLATE_X, /* defaultValue= */ 0);
+ float translateY = bundle.getFloat(ConfigurationActivity.TRANSLATE_Y, /* defaultValue= */ 0);
+ // TODO(b/213198690): Get resolution for aspect ratio and scale all translations' translateX
+ // by this aspect ratio.
+ transformationMatrix.postTranslate(translateX, translateY);
+
+ float scaleX = bundle.getFloat(ConfigurationActivity.SCALE_X, /* defaultValue= */ 1);
+ float scaleY = bundle.getFloat(ConfigurationActivity.SCALE_Y, /* defaultValue= */ 1);
+ transformationMatrix.postScale(scaleX, scaleY);
+
+ float rotateDegrees =
+ bundle.getFloat(ConfigurationActivity.ROTATE_DEGREES, /* defaultValue= */ 0);
+ transformationMatrix.postRotate(rotateDegrees);
+
+ return transformationMatrix;
+ }
+
+ @RequiresNonNull({
+ "informationTextView",
+ "progressViewGroup",
+ "transformationStopwatch",
+ })
+ private void onTransformationError(TransformationException exception) {
+ transformationStopwatch.stop();
+ informationTextView.setText(R.string.transformation_error);
+ progressViewGroup.setVisibility(View.GONE);
+ Toast.makeText(
+ TransformerActivity.this, "Transformation error: " + exception, Toast.LENGTH_LONG)
+ .show();
+ Log.e(TAG, "Transformation error", exception);
+ }
+
+ @RequiresNonNull({
+ "playerView",
+ "debugTextView",
+ "informationTextView",
+ "progressViewGroup",
+ "transformationStopwatch",
+ })
+ private void onTransformationCompleted(String filePath) {
+ transformationStopwatch.stop();
+ informationTextView.setText(
+ getString(
+ R.string.transformation_completed, transformationStopwatch.elapsed(TimeUnit.SECONDS)));
+ progressViewGroup.setVisibility(View.GONE);
+ playMediaItem(MediaItem.fromUri("file://" + filePath));
+ Log.d(TAG, "Output file path: file://" + filePath);
+ }
+
+ @RequiresNonNull({"playerView", "debugTextView"})
+ private void playMediaItem(MediaItem mediaItem) {
+ playerView.setPlayer(null);
+ releasePlayer();
+
+ ExoPlayer player = new ExoPlayer.Builder(/* context= */ this).build();
+ playerView.setPlayer(player);
+ player.setMediaItem(mediaItem);
+ player.play();
+ player.prepare();
+ this.player = player;
+ debugTextViewHelper = new DebugTextViewHelper(player, debugTextView);
+ debugTextViewHelper.start();
+ }
+
+ private void releasePlayer() {
+ if (debugTextViewHelper != null) {
+ debugTextViewHelper.stop();
+ debugTextViewHelper = null;
+ }
+ if (player != null) {
+ player.release();
+ player = null;
+ }
+ }
+
+ private void requestTransformerPermissions() {
+ if (Util.SDK_INT < 23) {
+ return;
+ }
+ if (checkSelfPermission(READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
+ || checkSelfPermission(WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(
+ new String[] {READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE}, /* requestCode= */ 0);
+ }
+ }
+
+ private final class DemoDebugViewProvider implements Transformer.DebugViewProvider {
+
+ @Nullable
+ @Override
+ public SurfaceView getDebugPreviewSurfaceView(int width, int height) {
+ // Update the UI on the main thread and wait for the output surface to be available.
+ CountDownLatch surfaceCreatedCountDownLatch = new CountDownLatch(1);
+ SurfaceView surfaceView = new SurfaceView(/* context= */ TransformerActivity.this);
+ runOnUiThread(
+ () -> {
+ AspectRatioFrameLayout debugFrame = checkNotNull(TransformerActivity.this.debugFrame);
+ debugFrame.addView(surfaceView);
+ debugFrame.setAspectRatio((float) width / height);
+ surfaceView
+ .getHolder()
+ .addCallback(
+ new SurfaceHolder.Callback() {
+ @Override
+ public void surfaceCreated(SurfaceHolder surfaceHolder) {
+ surfaceCreatedCountDownLatch.countDown();
+ }
+
+ @Override
+ public void surfaceChanged(
+ SurfaceHolder surfaceHolder, int format, int width, int height) {
+ // Do nothing.
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
+ // Do nothing.
+ }
+ });
+ });
+ try {
+ surfaceCreatedCountDownLatch.await();
+ } catch (InterruptedException e) {
+ Log.w(TAG, "Interrupted waiting for debug surface.");
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ return surfaceView;
+ }
+ }
+}
diff --git a/demos/transformer/src/main/java/androidx/media3/demo/transformer/package-info.java b/demos/transformer/src/main/java/androidx/media3/demo/transformer/package-info.java
new file mode 100644
index 0000000000..8a243704e2
--- /dev/null
+++ b/demos/transformer/src/main/java/androidx/media3/demo/transformer/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NonNullApi
+package androidx.media3.demo.transformer;
+
+import androidx.media3.common.util.NonNullApi;
diff --git a/demos/transformer/src/main/res/layout/configuration_activity.xml b/demos/transformer/src/main/res/layout/configuration_activity.xml
new file mode 100644
index 0000000000..a9a9410a35
--- /dev/null
+++ b/demos/transformer/src/main/res/layout/configuration_activity.xml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/transformer/src/main/res/layout/spinner_item.xml b/demos/transformer/src/main/res/layout/spinner_item.xml
new file mode 100644
index 0000000000..6c7dfaa6f7
--- /dev/null
+++ b/demos/transformer/src/main/res/layout/spinner_item.xml
@@ -0,0 +1,26 @@
+
+
+
diff --git a/demos/transformer/src/main/res/layout/transformer_activity.xml b/demos/transformer/src/main/res/layout/transformer_activity.xml
new file mode 100644
index 0000000000..94b4484969
--- /dev/null
+++ b/demos/transformer/src/main/res/layout/transformer_activity.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demos/transformer/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/transformer/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..adaa93220e
Binary files /dev/null and b/demos/transformer/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/demos/transformer/src/main/res/mipmap-mdpi/ic_launcher.png b/demos/transformer/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..9b6f7d5e80
Binary files /dev/null and b/demos/transformer/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/demos/transformer/src/main/res/mipmap-xhdpi/ic_launcher.png b/demos/transformer/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..2101026c9f
Binary files /dev/null and b/demos/transformer/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/demos/transformer/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demos/transformer/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..223ec8bd11
Binary files /dev/null and b/demos/transformer/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/demos/transformer/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/transformer/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..698ed68c42
Binary files /dev/null and b/demos/transformer/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/demos/transformer/src/main/res/values/strings.xml b/demos/transformer/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..39bd87ad8f
--- /dev/null
+++ b/demos/transformer/src/main/res/values/strings.xml
@@ -0,0 +1,37 @@
+
+
+
+ Transformer Demo
+ Configuration
+ Choose File
+ Remove audio
+ Remove video
+ Flatten for slow motion
+ Output audio MIME type
+ Output video MIME type
+ Output video resolution
+ Translate video
+ Scale video
+ Rotate video (degrees)
+ Transform
+ Debug preview:
+ No debug preview available
+ Transformation started
+ Transformation started %d seconds ago.
+ Transformation completed in %d seconds.
+ Transformation error
+
diff --git a/settings.gradle b/settings.gradle
index c9d1e715fa..eaaf9cd9c5 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -28,6 +28,8 @@ include modulePrefix + 'demo-session'
project(modulePrefix + 'demo-session').projectDir = new File(rootDir, 'demos/session')
include modulePrefix + 'demo-surface'
project(modulePrefix + 'demo-surface').projectDir = new File(rootDir, 'demos/surface')
+include modulePrefix + 'demo-transformer'
+project(modulePrefix + 'demo-transformer').projectDir = new File(rootDir, 'demos/transformer')
include modulePrefix + 'test-exoplayer-playback'
project(modulePrefix + 'test-exoplayer-playback').projectDir = new File(rootDir, 'libraries/test_exoplayer_playback')