diff --git a/build.gradle b/build.gradle index 3beb94a663c..5cc3ec01a43 100644 --- a/build.gradle +++ b/build.gradle @@ -17,10 +17,10 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.8.1' + classpath 'com.android.tools.build:gradle:8.8.2' classpath 'com.google.android.gms:strict-version-matcher-plugin:1.2.4' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20' - classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.20' + classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21' } } allprojects { diff --git a/demos/composition/build.gradle b/demos/composition/build.gradle index 6e0f8b1a097..5fe9707a051 100644 --- a/demos/composition/build.gradle +++ b/demos/composition/build.gradle @@ -15,6 +15,8 @@ */ apply from: '../../constants.gradle' apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { namespace 'androidx.media3.demo.composition' @@ -22,8 +24,12 @@ android { compileSdk project.ext.compileSdkVersion compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = '11' } defaultConfig { @@ -37,7 +43,7 @@ android { release { shrinkResources true minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt' +// proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt' signingConfig signingConfigs.debug } } @@ -46,10 +52,14 @@ android { // This demo app isn't indexed and doesn't have translations. disable 'GoogleAppIndexingWarning','MissingTranslation' } + + buildFeatures { + compose true + } } dependencies { - implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:' + androidxMaterialVersion implementation project(modulePrefix + 'lib-effect') implementation project(modulePrefix + 'lib-exoplayer') @@ -57,6 +67,22 @@ dependencies { implementation project(modulePrefix + 'lib-muxer') implementation project(modulePrefix + 'lib-transformer') implementation project(modulePrefix + 'lib-ui') + implementation project(modulePrefix + 'lib-ui-compose') implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7' + implementation 'androidx.activity:activity-compose:1.9.0' + implementation platform('androidx.compose:compose-bom:2024.12.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material3.adaptive:adaptive' + implementation 'androidx.compose.material3.adaptive:adaptive-layout' + implementation 'androidx.compose.material3.adaptive:adaptive-navigation' + androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' } diff --git a/demos/composition/src/main/AndroidManifest.xml b/demos/composition/src/main/AndroidManifest.xml index a521883c127..e42b485c04a 100644 --- a/demos/composition/src/main/AndroidManifest.xml +++ b/demos/composition/src/main/AndroidManifest.xml @@ -1,51 +1,43 @@ - - - - - - - - - - + - - + + + - + + - + + + + + + + + \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/AssetItemAdapter.java b/demos/composition/src/main/java/androidx/media3/demo/composition/AssetItemAdapter.java deleted file mode 100644 index 620fdf69032..00000000000 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/AssetItemAdapter.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2024 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.composition; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import androidx.recyclerview.widget.RecyclerView; -import java.util.List; - -/** A {@link RecyclerView.Adapter} that displays assets in a sequence in a {@link RecyclerView}. */ -public final class AssetItemAdapter extends RecyclerView.Adapter { - private static final String TAG = "AssetItemAdapter"; - - private final List data; - - /** - * Creates a new instance - * - * @param data A list of items to populate RecyclerView with. - */ - public AssetItemAdapter(List data) { - this.data = data; - } - - @Override - public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.preset_item, parent, false); - return new ViewHolder(v); - } - - @Override - public void onBindViewHolder(ViewHolder holder, int position) { - holder.getTextView().setText(data.get(position)); - } - - @Override - public int getItemCount() { - return data.size(); - } - - /** A {@link RecyclerView.ViewHolder} used to build {@link AssetItemAdapter}. */ - public static final class ViewHolder extends RecyclerView.ViewHolder { - private final TextView textView; - - private ViewHolder(View view) { - super(view); - textView = view.findViewById(R.id.preset_name_text); - } - - private TextView getTextView() { - return textView; - } - } -} diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java deleted file mode 100644 index ed10763cd27..00000000000 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.java +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright 2024 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.composition; - -import static android.content.pm.ActivityInfo.COLOR_MODE_HDR; -import static android.os.Build.VERSION.SDK_INT; -import static androidx.media3.transformer.Composition.HDR_MODE_EXPERIMENTAL_FORCE_INTERPRET_HDR_AS_SDR; -import static androidx.media3.transformer.Composition.HDR_MODE_KEEP_HDR; -import static androidx.media3.transformer.Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_MEDIACODEC; -import static androidx.media3.transformer.Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL; - -import android.app.Activity; -import android.content.DialogInterface; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ArrayAdapter; -import android.widget.CheckBox; -import android.widget.Spinner; -import android.widget.Toast; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.AppCompatButton; -import androidx.appcompat.widget.AppCompatCheckBox; -import androidx.appcompat.widget.AppCompatTextView; -import androidx.media3.common.Effect; -import androidx.media3.common.MediaItem; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.PlaybackException; -import androidx.media3.common.Player; -import androidx.media3.common.audio.SonicAudioProcessor; -import androidx.media3.common.util.Clock; -import androidx.media3.common.util.Log; -import androidx.media3.common.util.Util; -import androidx.media3.effect.DebugTraceUtil; -import androidx.media3.effect.LanczosResample; -import androidx.media3.effect.Presentation; -import androidx.media3.effect.RgbFilter; -import androidx.media3.transformer.Composition; -import androidx.media3.transformer.CompositionPlayer; -import androidx.media3.transformer.EditedMediaItem; -import androidx.media3.transformer.EditedMediaItemSequence; -import androidx.media3.transformer.Effects; -import androidx.media3.transformer.ExportException; -import androidx.media3.transformer.ExportResult; -import androidx.media3.transformer.InAppFragmentedMp4Muxer; -import androidx.media3.transformer.InAppMp4Muxer; -import androidx.media3.transformer.JsonUtil; -import androidx.media3.transformer.Transformer; -import androidx.media3.ui.PlayerView; -import androidx.recyclerview.widget.DividerItemDecoration; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import com.google.common.base.Stopwatch; -import com.google.common.base.Ticker; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import org.json.JSONException; -import org.json.JSONObject; - -/** - * An {@link Activity} that previews compositions, using {@link - * androidx.media3.transformer.CompositionPlayer}. - */ -public final class CompositionPreviewActivity extends AppCompatActivity { - private static final String TAG = "CompPreviewActivity"; - private static final String AUDIO_URI = - "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3"; - private static final String SAME_AS_INPUT_OPTION = "same as input"; - private static final ImmutableMap HDR_MODE_DESCRIPTIONS = - new ImmutableMap.Builder() - .put("Keep HDR", HDR_MODE_KEEP_HDR) - .put("MediaCodec tone-map HDR to SDR", HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_MEDIACODEC) - .put("OpenGL tone-map HDR to SDR", HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL) - .put("Force Interpret HDR as SDR", HDR_MODE_EXPERIMENTAL_FORCE_INTERPRET_HDR_AS_SDR) - .build(); - private static final ImmutableList RESOLUTION_HEIGHTS = - ImmutableList.of( - SAME_AS_INPUT_OPTION, "144", "240", "360", "480", "720", "1080", "1440", "2160"); - - private ArrayList sequenceAssetTitles; - private boolean[] selectedMediaItems; - private String[] presetDescriptions; - private AssetItemAdapter assetItemAdapter; - @Nullable private CompositionPlayer compositionPlayer; - @Nullable private Transformer transformer; - @Nullable private File outputFile; - private PlayerView playerView; - private AppCompatButton exportButton; - private AppCompatTextView exportInformationTextView; - private Stopwatch exportStopwatch; - private boolean includeBackgroundAudioTrack; - private boolean appliesVideoEffects; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (SDK_INT >= 26) { - getWindow().setColorMode(COLOR_MODE_HDR); - } - setContentView(R.layout.composition_preview_activity); - playerView = findViewById(R.id.composition_player_view); - - findViewById(R.id.preview_button).setOnClickListener(view -> previewComposition()); - findViewById(R.id.edit_sequence_button).setOnClickListener(view -> selectPreset()); - RecyclerView presetList = findViewById(R.id.composition_preset_list); - presetList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL)); - LinearLayoutManager layoutManager = - new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, /* reverseLayout= */ false); - presetList.setLayoutManager(layoutManager); - - exportInformationTextView = findViewById(R.id.export_information_text); - exportButton = findViewById(R.id.composition_export_button); - exportButton.setOnClickListener(view -> showExportSettings()); - - AppCompatCheckBox backgroundAudioCheckBox = findViewById(R.id.background_audio_checkbox); - backgroundAudioCheckBox.setOnCheckedChangeListener( - (compoundButton, checked) -> includeBackgroundAudioTrack = checked); - - ArrayAdapter resolutionHeightAdapter = - new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item); - resolutionHeightAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - Spinner resolutionHeightSpinner = findViewById(R.id.resolution_height_spinner); - resolutionHeightSpinner.setAdapter(resolutionHeightAdapter); - resolutionHeightAdapter.addAll(RESOLUTION_HEIGHTS); - - ArrayAdapter hdrModeAdapter = new ArrayAdapter<>(this, R.layout.spinner_item); - hdrModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - Spinner hdrModeSpinner = findViewById(R.id.hdr_mode_spinner); - hdrModeSpinner.setAdapter(hdrModeAdapter); - hdrModeAdapter.addAll(HDR_MODE_DESCRIPTIONS.keySet()); - - AppCompatCheckBox applyVideoEffectsCheckBox = findViewById(R.id.apply_video_effects_checkbox); - applyVideoEffectsCheckBox.setOnCheckedChangeListener( - ((compoundButton, checked) -> appliesVideoEffects = checked)); - - presetDescriptions = getResources().getStringArray(R.array.preset_descriptions); - // Select two media items by default. - selectedMediaItems = new boolean[presetDescriptions.length]; - selectedMediaItems[0] = true; - selectedMediaItems[2] = true; - sequenceAssetTitles = new ArrayList<>(); - for (int i = 0; i < selectedMediaItems.length; i++) { - if (selectedMediaItems[i]) { - sequenceAssetTitles.add(presetDescriptions[i]); - } - } - assetItemAdapter = new AssetItemAdapter(sequenceAssetTitles); - presetList.setAdapter(assetItemAdapter); - - exportStopwatch = - Stopwatch.createUnstarted( - new Ticker() { - @Override - public long read() { - return android.os.SystemClock.elapsedRealtimeNanos(); - } - }); - } - - @Override - protected void onStart() { - super.onStart(); - playerView.onResume(); - } - - @Override - protected void onStop() { - super.onStop(); - playerView.onPause(); - releasePlayer(); - cancelExport(); - exportStopwatch.reset(); - } - - @SuppressWarnings("MissingSuperCall") - @Override - public void onBackPressed() { - if (compositionPlayer != null) { - compositionPlayer.pause(); - } - if (exportStopwatch.isRunning()) { - cancelExport(); - exportStopwatch.reset(); - } - } - - private Composition prepareComposition() { - String[] presetUris = getResources().getStringArray(/* id= */ R.array.preset_uris); - int[] presetDurationsUs = getResources().getIntArray(/* id= */ R.array.preset_durations); - List mediaItems = new ArrayList<>(); - ImmutableList.Builder videoEffectsBuilder = new ImmutableList.Builder<>(); - if (appliesVideoEffects) { - videoEffectsBuilder.add(MatrixTransformationFactory.createDizzyCropEffect()); - videoEffectsBuilder.add(RgbFilter.createGrayscaleFilter()); - } - Spinner resolutionHeightSpinner = findViewById(R.id.resolution_height_spinner); - String selectedResolutionHeight = String.valueOf(resolutionHeightSpinner.getSelectedItem()); - if (!SAME_AS_INPUT_OPTION.equals(selectedResolutionHeight)) { - int resolutionHeight = Integer.parseInt(selectedResolutionHeight); - videoEffectsBuilder.add( - LanczosResample.scaleToFitWithFlexibleOrientation(10000, resolutionHeight)); - videoEffectsBuilder.add(Presentation.createForShortSide(resolutionHeight)); - } - ImmutableList videoEffects = videoEffectsBuilder.build(); - for (int i = 0; i < selectedMediaItems.length; i++) { - if (selectedMediaItems[i]) { - SonicAudioProcessor pitchChanger = new SonicAudioProcessor(); - pitchChanger.setPitch(mediaItems.size() % 2 == 0 ? 2f : 0.2f); - MediaItem mediaItem = - new MediaItem.Builder() - .setUri(presetUris[i]) - .setImageDurationMs(Util.usToMs(presetDurationsUs[i])) // Ignored for audio/video - .build(); - EditedMediaItem.Builder itemBuilder = - new EditedMediaItem.Builder(mediaItem) - .setEffects( - new Effects( - /* audioProcessors= */ ImmutableList.of(pitchChanger), - /* videoEffects= */ videoEffects)) - .setDurationUs(presetDurationsUs[i]); - mediaItems.add(itemBuilder.build()); - } - } - EditedMediaItemSequence videoSequence = new EditedMediaItemSequence.Builder(mediaItems).build(); - List compositionSequences = new ArrayList<>(); - compositionSequences.add(videoSequence); - if (includeBackgroundAudioTrack) { - compositionSequences.add(getAudioBackgroundSequence()); - } - SonicAudioProcessor sampleRateChanger = new SonicAudioProcessor(); - sampleRateChanger.setOutputSampleRateHz(8_000); - Spinner hdrModeSpinner = findViewById(R.id.hdr_mode_spinner); - int selectedHdrMode = - HDR_MODE_DESCRIPTIONS.get(String.valueOf(hdrModeSpinner.getSelectedItem())); - return new Composition.Builder(compositionSequences) - .setEffects( - new Effects( - /* audioProcessors= */ ImmutableList.of(sampleRateChanger), - /* videoEffects= */ ImmutableList.of())) - .setHdrMode(selectedHdrMode) - .build(); - } - - private static EditedMediaItemSequence getAudioBackgroundSequence() { - MediaItem audioMediaItem = new MediaItem.Builder().setUri(AUDIO_URI).build(); - EditedMediaItem audioItem = - new EditedMediaItem.Builder(audioMediaItem).setDurationUs(59_000_000).build(); - return new EditedMediaItemSequence.Builder(audioItem).setIsLooping(true).build(); - } - - private void previewComposition() { - releasePlayer(); - Composition composition = prepareComposition(); - playerView.setPlayer(null); - - CompositionPlayer player = new CompositionPlayer.Builder(getApplicationContext()).build(); - this.compositionPlayer = player; - playerView.setPlayer(compositionPlayer); - playerView.setControllerAutoShow(false); - player.addListener( - new Player.Listener() { - @Override - public void onPlayerError(PlaybackException error) { - Toast.makeText(getApplicationContext(), "Preview error: " + error, Toast.LENGTH_LONG) - .show(); - Log.e(TAG, "Preview error", error); - } - }); - player.setComposition(composition); - player.prepare(); - player.play(); - } - - private void selectPreset() { - new AlertDialog.Builder(/* context= */ this) - .setTitle(R.string.select_preset_title) - .setMultiChoiceItems(presetDescriptions, selectedMediaItems, this::selectPresetInDialog) - .setPositiveButton(R.string.ok, /* listener= */ null) - .setCancelable(false) - .create() - .show(); - } - - private void selectPresetInDialog(DialogInterface dialog, int which, boolean isChecked) { - selectedMediaItems[which] = isChecked; - // The items will be added to a the sequence in the order they were selected. - if (isChecked) { - sequenceAssetTitles.add(presetDescriptions[which]); - assetItemAdapter.notifyItemInserted(sequenceAssetTitles.size() - 1); - } else { - int index = sequenceAssetTitles.indexOf(presetDescriptions[which]); - sequenceAssetTitles.remove(presetDescriptions[which]); - assetItemAdapter.notifyItemRemoved(index); - } - } - - private void showExportSettings() { - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this); - LayoutInflater inflater = this.getLayoutInflater(); - View exportSettingsDialogView = inflater.inflate(R.layout.export_settings, null); - - alertDialogBuilder - .setView(exportSettingsDialogView) - .setTitle(R.string.export_settings) - .setPositiveButton( - R.string.export, (dialog, id) -> exportComposition(exportSettingsDialogView)) - .setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss()); - - ArrayAdapter audioMimeAdapter = - new ArrayAdapter<>(/* context= */ this, R.layout.spinner_item); - audioMimeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - Spinner audioMimeSpinner = exportSettingsDialogView.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); - Spinner videoMimeSpinner = exportSettingsDialogView.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, - MimeTypes.VIDEO_AV1); - - CheckBox enableDebugTracingCheckBox = - exportSettingsDialogView.findViewById(R.id.enable_debug_tracing_checkbox); - enableDebugTracingCheckBox.setOnCheckedChangeListener( - (buttonView, isChecked) -> DebugTraceUtil.enableTracing = isChecked); - - CheckBox useMedia3Mp4MuxerCheckBox = - exportSettingsDialogView.findViewById(R.id.use_media3_mp4_muxer_checkbox); - CheckBox useMedia3FragmentedMp4MuxerCheckBox = - exportSettingsDialogView.findViewById(R.id.use_media3_fragmented_mp4_muxer_checkbox); - useMedia3Mp4MuxerCheckBox.setOnCheckedChangeListener( - (buttonView, isChecked) -> { - if (isChecked) { - useMedia3FragmentedMp4MuxerCheckBox.setChecked(false); - } - }); - useMedia3FragmentedMp4MuxerCheckBox.setOnCheckedChangeListener( - (buttonView, isChecked) -> { - if (isChecked) { - useMedia3Mp4MuxerCheckBox.setChecked(false); - } - }); - - AlertDialog dialog = alertDialogBuilder.create(); - dialog.show(); - } - - private void exportComposition(View exportSettingsDialogView) { - // Cancel and clean up files from any ongoing export. - cancelExport(); - - Composition composition = prepareComposition(); - - try { - outputFile = - createExternalCacheFile( - "composition-preview-" + Clock.DEFAULT.elapsedRealtime() + ".mp4"); - } catch (IOException e) { - Toast.makeText( - getApplicationContext(), - "Aborting export! Unable to create output file: " + e, - Toast.LENGTH_LONG) - .show(); - Log.e(TAG, "Aborting export! Unable to create output file: ", e); - return; - } - String filePath = outputFile.getAbsolutePath(); - - Transformer.Builder transformerBuilder = new Transformer.Builder(/* context= */ this); - - Spinner audioMimeTypeSpinner = exportSettingsDialogView.findViewById(R.id.audio_mime_spinner); - String selectedAudioMimeType = String.valueOf(audioMimeTypeSpinner.getSelectedItem()); - if (!SAME_AS_INPUT_OPTION.equals(selectedAudioMimeType)) { - transformerBuilder.setAudioMimeType(selectedAudioMimeType); - } - - Spinner videoMimeTypeSpinner = exportSettingsDialogView.findViewById(R.id.video_mime_spinner); - String selectedVideoMimeType = String.valueOf(videoMimeTypeSpinner.getSelectedItem()); - if (!SAME_AS_INPUT_OPTION.equals(selectedVideoMimeType)) { - transformerBuilder.setVideoMimeType(selectedVideoMimeType); - } - - CheckBox useMedia3Mp4MuxerCheckBox = - exportSettingsDialogView.findViewById(R.id.use_media3_mp4_muxer_checkbox); - CheckBox useMedia3FragmentedMp4MuxerCheckBox = - exportSettingsDialogView.findViewById(R.id.use_media3_fragmented_mp4_muxer_checkbox); - if (useMedia3Mp4MuxerCheckBox.isChecked()) { - transformerBuilder.setMuxerFactory(new InAppMp4Muxer.Factory()); - } - if (useMedia3FragmentedMp4MuxerCheckBox.isChecked()) { - transformerBuilder.setMuxerFactory(new InAppFragmentedMp4Muxer.Factory()); - } - - transformer = - transformerBuilder - .addListener( - new Transformer.Listener() { - @Override - public void onCompleted(Composition composition, ExportResult exportResult) { - exportStopwatch.stop(); - long elapsedTimeMs = exportStopwatch.elapsed(TimeUnit.MILLISECONDS); - String details = - getString(R.string.export_completed, elapsedTimeMs / 1000.f, filePath); - Log.d(TAG, DebugTraceUtil.generateTraceSummary()); - Log.i(TAG, details); - exportInformationTextView.setText(details); - - try { - JSONObject resultJson = - JsonUtil.exportResultAsJsonObject(exportResult) - .put("elapsedTimeMs", elapsedTimeMs) - .put("device", JsonUtil.getDeviceDetailsAsJsonObject()); - for (String line : Util.split(resultJson.toString(2), "\n")) { - Log.i(TAG, line); - } - } catch (JSONException e) { - Log.w(TAG, "Unable to convert exportResult to JSON", e); - } - } - - @Override - public void onError( - Composition composition, - ExportResult exportResult, - ExportException exportException) { - exportStopwatch.stop(); - Toast.makeText( - getApplicationContext(), - "Export error: " + exportException, - Toast.LENGTH_LONG) - .show(); - Log.e(TAG, "Export error", exportException); - Log.d(TAG, DebugTraceUtil.generateTraceSummary()); - exportInformationTextView.setText(R.string.export_error); - } - }) - .build(); - - exportInformationTextView.setText(R.string.export_started); - exportStopwatch.reset(); - exportStopwatch.start(); - transformer.start(composition, filePath); - Log.i(TAG, "Export started"); - } - - private void releasePlayer() { - if (compositionPlayer != null) { - compositionPlayer.release(); - compositionPlayer = null; - } - } - - /** Cancels any ongoing export operation, and deletes output file contents. */ - private void cancelExport() { - if (transformer != null) { - transformer.cancel(); - transformer = null; - } - if (outputFile != null) { - outputFile.delete(); - outputFile = null; - } - exportInformationTextView.setText(""); - } - - /** - * Creates a {@link File} of the {@code fileName} in the application cache directory. - * - *

If a file of that name already exists, it is overwritten. - */ - // TODO: b/320636291 - Refactor duplicate createExternalCacheFile functions. - private File createExternalCacheFile(String fileName) throws IOException { - File file = new File(getExternalCacheDir(), fileName); - if (file.exists() && !file.delete()) { - throw new IOException("Could not delete file: " + file.getAbsolutePath()); - } - if (!file.createNewFile()) { - throw new IOException("Could not create file: " + file.getAbsolutePath()); - } - return file; - } -} diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.kt new file mode 100644 index 00000000000..0e3c8c1d586 --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewActivity.kt @@ -0,0 +1,524 @@ +/* + * Copyright 2024 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.composition + +import android.content.pm.ActivityInfo +import android.os.Build.VERSION.SDK_INT +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.twotone.Delete +import androidx.compose.material.icons.twotone.Star +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.media3.common.MimeTypes +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.COMPOSITION_LAYOUT +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.HDR_MODE_DESCRIPTIONS +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.LAYOUT_EXTRA +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.MUXER_OPTIONS +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.RESOLUTION_HEIGHTS +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.SAME_AS_INPUT_OPTION +import androidx.media3.demo.composition.ui.DropDownSpinner +import androidx.media3.demo.composition.ui.theme.CompositionDemoTheme +import androidx.media3.transformer.Composition +import androidx.media3.ui.PlayerView +import java.util.Locale +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * An [Activity] that previews compositions, using [ ]. + */ +class CompositionPreviewActivity : AppCompatActivity() { + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + if (SDK_INT >= 26) { + window.setColorMode(ActivityInfo.COLOR_MODE_HDR) + } + + val compositionLayout = intent.getStringExtra(LAYOUT_EXTRA) ?: COMPOSITION_LAYOUT[0] + Log.d(TAG, "Received layout of $compositionLayout") + val viewModel: CompositionPreviewViewModel by viewModels { + CompositionPreviewViewModelFactory( + application, compositionLayout + ) + } + + // TODO(nevmital): Update to follow https://developer.android.com/topic/architecture/ui-layer/events#consuming-trigger-updates + viewModel.toastMessage.observe(this) { newMessage -> + newMessage?.let { + viewModel.toastMessage.value = null + Toast.makeText(this, it, Toast.LENGTH_LONG).show() + } + } + + setContent { + CompositionDemoTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val navigator = rememberSupportingPaneScaffoldNavigator() + + BackHandler(navigator.canNavigateBack()) { + navigator.navigateBack() + } + + SupportingPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + mainPane = { + CompositionPreviewPane( + shouldShowSupportingPaneButton = navigator.scaffoldValue.secondary == PaneAdaptedValue.Hidden, + onNavigateToSupportingPane = { + navigator.navigateTo(ThreePaneScaffoldRole.Secondary) + }, + viewModel + ) + }, + supportingPane = { + ExportOptionsPane( + viewModel, + shouldShowBackButton = navigator.scaffoldValue.primary == PaneAdaptedValue.Hidden, + onBack = { navigator.navigateBack() }) + }, + modifier = Modifier.padding(innerPadding).padding(16.dp, 8.dp) + ) + } + } + } + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Composable + fun ThreePaneScaffoldScope.CompositionPreviewPane( + shouldShowSupportingPaneButton: Boolean, + onNavigateToSupportingPane: () -> Unit, + viewModel: CompositionPreviewViewModel, + modifier: Modifier = Modifier, + ) { + AnimatedPane { + // Main pane content + val scrollState = rememberScrollState() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxSize().verticalScroll(scrollState) + ) { + Text( + text = "${viewModel.compositionLayout} ${stringResource(R.string.preview_composition)}", + fontWeight = FontWeight.Bold + ) + val playerViewModifier = + if (scrollState.canScrollForward || scrollState.canScrollBackward) { + Modifier.heightIn(min = 250.dp) + } else { + Modifier + } + AndroidView( + factory = { context -> PlayerView(context) }, + update = { playerView -> + playerView.player = viewModel.compositionPlayer + playerView.useController = false + }, + modifier = playerViewModifier + ) +// PlayerSurface(viewModel.compositionPlayer, SURFACE_TYPE_SURFACE_VIEW) + HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(0.dp, 4.dp)) + VideoSequenceList(viewModel) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.add_background_audio), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + Switch( + viewModel.includeBackgroundAudioTrack, + { checked -> viewModel.includeBackgroundAudioTrack = checked } + ) + } + OutputSettings(viewModel) + HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(0.dp, 4.dp)) + Row(modifier = Modifier.fillMaxWidth().padding(8.dp, 0.dp)) { + Button(onClick = { viewModel.previewComposition() }) { + Text(text = stringResource(R.string.preview)) + } + Spacer(Modifier.weight(1f)) + if (shouldShowSupportingPaneButton) { + Button(onClick = onNavigateToSupportingPane) { + Text(text = stringResource(R.string.export_settings)) + } + } + } + } + } + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Composable + fun ThreePaneScaffoldScope.ExportOptionsPane( + viewModel: CompositionPreviewViewModel, + shouldShowBackButton: Boolean, + onBack: () -> Unit, + modifier: Modifier = Modifier, + ) { + var isAudioTypeExpanded by remember { mutableStateOf(false) } + var isVideoTypeExpanded by remember { mutableStateOf(false) } + + AnimatedPane { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = "Export settings", fontWeight = FontWeight.Bold) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.output_audio_mime_type), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + DropDownSpinner( + isAudioTypeExpanded, + viewModel.outputAudioMimeType, + listOf( + SAME_AS_INPUT_OPTION, + MimeTypes.AUDIO_AAC, + MimeTypes.AUDIO_AMR_NB, + MimeTypes.AUDIO_AMR_WB + ), + { expanded -> isAudioTypeExpanded = expanded }, + { selection -> viewModel.outputAudioMimeType = selection }) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.output_video_mime_type), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + DropDownSpinner( + isVideoTypeExpanded, + viewModel.outputVideoMimeType, + listOf( + SAME_AS_INPUT_OPTION, + MimeTypes.VIDEO_H263, + MimeTypes.VIDEO_H264, + MimeTypes.VIDEO_H265, + MimeTypes.VIDEO_MP4V, + MimeTypes.VIDEO_AV1 + ), + { expanded -> isVideoTypeExpanded = expanded }, + { selection -> viewModel.outputVideoMimeType = selection }) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.enable_debug_tracing), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + val debugTracingEnabled by viewModel.enableDebugTracing.collectAsState() + Switch( + debugTracingEnabled, { checked -> viewModel.enableDebugTracing(checked) } + ) + } + Column(Modifier.selectableGroup()) { + MUXER_OPTIONS.forEach { text -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .selectable( + selected = text == viewModel.muxerOption, + onClick = { viewModel.muxerOption = text }, + role = Role.RadioButton + ) + .fillMaxWidth() + ) { + Text(text = text, modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp)) + RadioButton( + selected = (text == viewModel.muxerOption), onClick = null + ) + } + } + } + HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(0.dp, 4.dp)) + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp, 0.dp) + ) { + if (shouldShowBackButton) { + OutlinedButton({ onBack() }) { + Text(text = stringResource(R.string.cancel)) + } + } + Spacer(Modifier.weight(1f)) + Button({ viewModel.exportComposition() }) { + Text(text = stringResource(R.string.export)) + } + } + viewModel.exportResultInformation?.let { + HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(0.dp, 4.dp)) + Text(text = it) + } + } + } + } + + @Composable + fun VideoSequenceList(viewModel: CompositionPreviewViewModel) { + var showDialog by remember { mutableStateOf(false) } + + if (showDialog) { + VideoSequenceDialog( + { showDialog = false }, + viewModel.mediaItemOptions, + { index -> viewModel.addItem(index) } + ) + } + + Box( + modifier = Modifier + .border(2.dp, MaterialTheme.colorScheme.secondary, RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(16.dp)) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(8.dp) + ) { + Text( + text = "Video sequence items", + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Text( + text = "Click the star to apply effects", + fontSize = 12.sp, + fontStyle = FontStyle.Italic, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.secondary) + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp) // Needs a defined max height since it's in a scrollable column + ) { + itemsIndexed(viewModel.selectedMediaItems) { index, item -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "${index + 1}. ${item.title}", + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp).weight(1f) + ) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleButton( + checked = item.applyEffects.value, + onCheckedChange = { checked -> + viewModel.updateEffects(index, checked) + }) { + Icon( + imageVector = if (item.applyEffects.value) Icons.Filled.Star else Icons.TwoTone.Star, + contentDescription = "Apply effects to item ${index + 1}" + ) + } + IconButton({ viewModel.removeItem(index) }) { + Icon( + Icons.TwoTone.Delete, + contentDescription = "Remove item ${index + 1}" + ) + } + } + } + } + } + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.secondary) + ElevatedButton( + onClick = { showDialog = true }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(R.string.edit)) + } + } + } + } + + @Composable + fun VideoSequenceDialog( + onDismissRequest: () -> Unit, + itemOptions: List, + addSelectedVideo: (Int) -> Unit + ) { + Dialog(onDismissRequest) { + Card( + modifier = Modifier.fillMaxSize().padding(4.dp), shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Select videos", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(0.dp, 8.dp) + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f).padding(8.dp, 0.dp) + ) { + itemsIndexed(itemOptions) { index, item -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxWidth() + ) { + FilledIconButton(onClick = { addSelectedVideo(index) }) { + Icon(Icons.Filled.Add, contentDescription = "Add item") + } + val duration = item.durationMs.toDuration(DurationUnit.MICROSECONDS) + val durationString = String.format( + Locale.US, + "%02d:%02d", + duration.inWholeMinutes, + duration.inWholeSeconds % 60 + ) + Text(text = "${item.title} ($durationString)") + } + } + } + Button({ onDismissRequest() }, modifier = Modifier.padding(0.dp, 4.dp)) { + Text(text = stringResource(R.string.ok)) + } + } + } + } + } + + @Composable + fun OutputSettings(viewModel: CompositionPreviewViewModel) { + var resolutionExpanded by remember { mutableStateOf(false) } + var hdrExpanded by remember { mutableStateOf(false) } + var selectedHdrMode by remember { mutableStateOf(HDR_MODE_DESCRIPTIONS.keys.first()) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.output_video_resolution), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + DropDownSpinner( + resolutionExpanded, + viewModel.outputResolution, + RESOLUTION_HEIGHTS, + { newExpanded -> resolutionExpanded = newExpanded }, + { newSelection -> viewModel.outputResolution = newSelection }) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.hdr_mode), + modifier = Modifier.padding(0.dp, 0.dp, 8.dp, 0.dp) + ) + DropDownSpinner( + hdrExpanded, + selectedHdrMode, + HDR_MODE_DESCRIPTIONS.keys.toList(), + { newExpanded -> hdrExpanded = newExpanded }, + { newSelection -> + selectedHdrMode = newSelection + viewModel.outputHdrMode = + HDR_MODE_DESCRIPTIONS[newSelection] ?: Composition.HDR_MODE_KEEP_HDR + } + ) + } + } + } + + companion object { + private const val TAG = "CompPreviewActivity" + } +} diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt new file mode 100644 index 00000000000..7719fcc61cc --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/CompositionPreviewViewModel.kt @@ -0,0 +1,511 @@ +package androidx.media3.demo.composition + +import android.app.Application +import android.os.SystemClock +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.media3.common.Effect +import androidx.media3.common.MediaItem +import androidx.media3.common.OverlaySettings +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoCompositorSettings +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.Clock +import androidx.media3.common.util.Log +import androidx.media3.common.util.Size +import androidx.media3.common.util.Util +import androidx.media3.demo.composition.MatrixTransformationFactory.createDizzyCropEffect +import androidx.media3.effect.DebugTraceUtil +import androidx.media3.effect.LanczosResample +import androidx.media3.effect.MultipleInputVideoGraph +import androidx.media3.effect.Presentation +import androidx.media3.effect.RgbFilter +import androidx.media3.effect.StaticOverlaySettings +import androidx.media3.transformer.Composition +import androidx.media3.transformer.CompositionPlayer +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.EditedMediaItemSequence +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.InAppFragmentedMp4Muxer +import androidx.media3.transformer.InAppMp4Muxer +import androidx.media3.transformer.JsonUtil +import androidx.media3.transformer.Transformer +import com.google.common.base.Stopwatch +import com.google.common.base.Ticker +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.json.JSONException +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.math.cos +import kotlin.math.sin + +class CompositionPreviewViewModel(application: Application, val compositionLayout: String) : + AndroidViewModel(application) { + data class Item( + val title: String, + val uri: String, + val durationMs: Long, + var applyEffects: MutableState + ) + + var compositionPlayer by mutableStateOf(createCompositionPlayer()) + var transformer: Transformer? = null + var outputFile: File? = null + var exportStopwatch: Stopwatch = Stopwatch.createUnstarted(object : Ticker() { + override fun read(): Long { + return SystemClock.elapsedRealtimeNanos() + } + }) + + var mediaItemOptions = mutableStateListOf() + var selectedMediaItems = mutableStateListOf() + + var includeBackgroundAudioTrack by mutableStateOf(false) + var outputResolution by mutableStateOf(RESOLUTION_HEIGHTS[0]) + var outputHdrMode by mutableIntStateOf(Composition.HDR_MODE_KEEP_HDR) + var outputAudioMimeType by mutableStateOf(SAME_AS_INPUT_OPTION) + var outputVideoMimeType by mutableStateOf(SAME_AS_INPUT_OPTION) + var muxerOption by mutableStateOf(MUXER_OPTIONS[0]) + var exportResultInformation by mutableStateOf(null) + + private val _enableDebugTracing = MutableStateFlow(false) + val enableDebugTracing: StateFlow = _enableDebugTracing.asStateFlow() + + var toastMessage = MutableLiveData(null) + + private val individualVideoEffects = + listOf(createDizzyCropEffect(), RgbFilter.createGrayscaleFilter()) + + val EXPORT_ERROR_MESSAGE = application.resources.getString(R.string.export_error) + val EXPORT_STARTED_MESSAGE = application.resources.getString(R.string.export_started) + + init { + // Load media items + val titles = application.resources.getStringArray(/* id= */R.array.preset_descriptions) + val uris = application.resources.getStringArray(/* id= */R.array.preset_uris) + val durations = application.resources.getIntArray(/* id= */R.array.preset_durations) + for (i in titles.indices) { + mediaItemOptions.add( + Item(titles[i], uris[i], durations[i].toLong(), mutableStateOf(false)) + ) + } + // Load initial media item selections + addItem(0) + addItem(2) + } + + fun enableDebugTracing(enable: Boolean) { + _enableDebugTracing.update { _ -> enable } + DebugTraceUtil.enableTracing = enable + } + + fun addItem(index: Int) { + selectedMediaItems.add(mediaItemOptions[index].copy()) + } + + fun removeItem(index: Int) { + selectedMediaItems.removeAt(index) + } + + fun updateEffects(index: Int, checked: Boolean) { + selectedMediaItems[index].applyEffects.value = checked + } + + fun prepareComposition(): Composition { + val mediaItems = mutableListOf() + + val universalVideoEffects = mutableListOf() + if (SAME_AS_INPUT_OPTION != outputResolution) { + val resolutionHeight = outputResolution.toInt() + universalVideoEffects.add( + LanczosResample.scaleToFitWithFlexibleOrientation( + 10000, + resolutionHeight + ) + ) + universalVideoEffects.add(Presentation.createForShortSide(resolutionHeight)) + } + for (item in selectedMediaItems) { + val mediaItem = MediaItem.Builder().setUri(item.uri) + .setImageDurationMs(Util.usToMs(item.durationMs)) // Ignored for audio/video + .build() + val allVideoEffects = + universalVideoEffects + if (item.applyEffects.value) individualVideoEffects else emptyList() + val itemBuilder = EditedMediaItem.Builder(mediaItem) + .setEffects( + Effects( + /* audioProcessors= */emptyList(), + /* videoEffects= */allVideoEffects + ) + ) + .setDurationUs(item.durationMs) + mediaItems.add(itemBuilder.build()) + } + val numSequences = when (compositionLayout) { + COMPOSITION_LAYOUT[1] -> 4 // 2x2 Grid + COMPOSITION_LAYOUT[2] -> 2 // PiP Overlay + else -> 1 // Sequence + } + val videoSequenceBuilders = + MutableList(numSequences) { _ -> EditedMediaItemSequence.Builder() } + val videoSequences = mutableListOf() + for (sequence in 0 until numSequences) { + var hasItem = false + for (item in sequence until mediaItems.size step numSequences) { + hasItem = true + Log.d(TAG, "Adding item $item to sequence $sequence") + videoSequenceBuilders[sequence].addItem(mediaItems[item]) + } + if (hasItem) { + videoSequences.add(videoSequenceBuilders[sequence].build()) + Log.d( + TAG, + "Sequence #$sequence has ${videoSequences.last().editedMediaItems.size} item(s)" + ) + } + } + if (includeBackgroundAudioTrack) { + videoSequences.add(getAudioBackgroundSequence()) + } + // TODO(nevmital): Do we want a checkbox for this AudioProcessor? + val sampleRateChanger = SonicAudioProcessor() + sampleRateChanger.setOutputSampleRateHz(8000) + return Composition.Builder(videoSequences) + .setEffects( + Effects( /* audioProcessors= */ + listOf(sampleRateChanger), /* videoEffects= */ + emptyList() + ) + ) + .setVideoCompositorSettings(getVideoCompositorSettings()) + .setHdrMode(outputHdrMode) + .build() + } + + fun getVideoCompositorSettings(): VideoCompositorSettings { + return when (compositionLayout) { + COMPOSITION_LAYOUT[1] -> { + // 2x2 Grid + object : VideoCompositorSettings { + override fun getOutputSize(inputSizes: List): Size { + //TODO(nevmital): Should this always be returning the size of the first sequence? + return inputSizes[0] + } + + override fun getOverlaySettings( + inputId: Int, + presentationTimeUs: Long + ): OverlaySettings { + return when (inputId) { + 0 -> { + StaticOverlaySettings.Builder() + .setScale(0.5f, 0.5f) + .setOverlayFrameAnchor(0f, 0f) // Middle of overlay + .setBackgroundFrameAnchor( + -0.5f, + 0.5f + ) // Top-left section of background + .build() + } + + 1 -> { + StaticOverlaySettings.Builder() + .setScale(0.5f, 0.5f) + .setOverlayFrameAnchor(0f, 0f) // Middle of overlay + .setBackgroundFrameAnchor( + 0.5f, + 0.5f + ) // Top-right section of background + .build() + } + + 2 -> { + StaticOverlaySettings.Builder() + .setScale(0.5f, 0.5f) + .setOverlayFrameAnchor(0f, 0f) // Middle of overlay + .setBackgroundFrameAnchor( + -0.5f, + -0.5f + ) // Bottom-left section of background + .build() + } + + 3 -> { + StaticOverlaySettings.Builder() + .setScale(0.5f, 0.5f) + .setOverlayFrameAnchor(0f, 0f) // Middle of overlay + .setBackgroundFrameAnchor( + 0.5f, + -0.5f + ) // Bottom-right section of background + .build() + } + + else -> { + StaticOverlaySettings.Builder().build() + } + } + } + + } + } + + COMPOSITION_LAYOUT[2] -> { + // PiP Overlay + val cycleTimeUs = 3_000_000f // Time for overlay to complete a cycle in Us + + object : VideoCompositorSettings { + override fun getOutputSize(inputSizes: List): Size { + return inputSizes[0] + } + + override fun getOverlaySettings( + inputId: Int, + presentationTimeUs: Long + ): OverlaySettings { + return if (inputId == 0) { + val cycleRadians = 2 * Math.PI * (presentationTimeUs / cycleTimeUs) + StaticOverlaySettings.Builder() + .setScale(0.35f, 0.35f) + .setOverlayFrameAnchor(0f, 1f) // Top-middle of overlay + .setBackgroundFrameAnchor(sin(cycleRadians).toFloat() * 0.5f, -0.2f) + .setRotationDegrees(cos(cycleRadians).toFloat() * -10f) + .build() + } else { + StaticOverlaySettings.Builder().build() + } + } + } + } + + else -> { + VideoCompositorSettings.DEFAULT + } + } + } + + fun previewComposition() { + releasePlayer() + val composition = prepareComposition() + + compositionPlayer.setComposition(composition) + compositionPlayer.prepare() + compositionPlayer.play() + } + + fun exportComposition() { + // Cancel and clean up files from any ongoing export. + cancelExport() + + val composition = prepareComposition() + + try { + outputFile = createExternalCacheFile( + "composition-preview-" + Clock.DEFAULT.elapsedRealtime() + ".mp4" + ) + } catch (e: IOException) { + toastMessage.value = "Aborting export! Unable to create output file: $e" + Log.e(TAG, "Aborting export! Unable to create output file: ", e) + return + } + val filePath = outputFile!!.absolutePath + + val transformerBuilder = Transformer.Builder( /* context= */getApplication()) + + if (SAME_AS_INPUT_OPTION != outputAudioMimeType) { + transformerBuilder.setAudioMimeType(outputAudioMimeType) + } + + if (SAME_AS_INPUT_OPTION != outputVideoMimeType) { + transformerBuilder.setVideoMimeType(outputVideoMimeType) + } + + when (muxerOption) { + MUXER_OPTIONS[0] -> {} + MUXER_OPTIONS[1] -> { + transformerBuilder.setMuxerFactory(InAppMp4Muxer.Factory()) + } + + MUXER_OPTIONS[2] -> { + transformerBuilder.setMuxerFactory(InAppFragmentedMp4Muxer.Factory()) + } + } + + transformer = transformerBuilder.addListener(object : Transformer.Listener { + override fun onCompleted( + composition: Composition, exportResult: ExportResult + ) { + exportStopwatch.stop() + val elapsedTimeMs = exportStopwatch.elapsed(TimeUnit.MILLISECONDS) + val details = getApplication().resources.getString( + R.string.export_completed, elapsedTimeMs / 1000f, filePath + ) + Log.d(TAG, DebugTraceUtil.generateTraceSummary()) + Log.i(TAG, details) + exportResultInformation = details + + try { + val resultJson = JsonUtil.exportResultAsJsonObject(exportResult) + .put("elapsedTimeMs", elapsedTimeMs) + .put("device", JsonUtil.getDeviceDetailsAsJsonObject()) + for (line in Util.split(resultJson.toString(2), "\n")) { + Log.i(TAG, line) + } + } catch (e: JSONException) { + Log.w(TAG, "Unable to convert exportResult to JSON", e) + } + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException + ) { + exportStopwatch.stop() + toastMessage.value = "Export error: $exportException" + Log.e(TAG, "Export error", exportException) + Log.d(TAG, DebugTraceUtil.generateTraceSummary()) + exportResultInformation = EXPORT_ERROR_MESSAGE + } + }).build() + + exportResultInformation = EXPORT_STARTED_MESSAGE + exportStopwatch.reset() + exportStopwatch.start() + transformer!!.start(composition, filePath) + Log.i(TAG, "Export started") + } + + fun createCompositionPlayer(): CompositionPlayer { + val playerBuilder = CompositionPlayer.Builder(getApplication()) + if (compositionLayout != COMPOSITION_LAYOUT[0]) { + playerBuilder + .setVideoGraphFactory(MultipleInputVideoGraph.Factory()) + } + val player = playerBuilder.build() + player.addListener(object : Player.Listener { + override fun onPlayerError(error: PlaybackException) { + toastMessage.value = "Preview error: $error" + Log.e(TAG, "Preview error", error) + } + }) + //player.repeatMode = Player.REPEAT_MODE_ALL + return player + } + + fun releasePlayer() { + compositionPlayer.stop() + compositionPlayer.release() + compositionPlayer = createCompositionPlayer() + } + + /** Cancels any ongoing export operation, and deletes output file contents. */ + fun cancelExport() { + transformer?.apply { + cancel() + } + transformer = null + outputFile?.apply { + delete() + } + outputFile = null + exportResultInformation = null + } + + /** + * Creates a [File] of the `fileName` in the application cache directory. + * + * + * If a file of that name already exists, it is overwritten. + */ + // TODO: b/320636291 - Refactor duplicate createExternalCacheFile functions. + @Throws(IOException::class) + private fun createExternalCacheFile(fileName: String): File { + val file = File(getApplication().externalCacheDir, fileName) + if (file.exists() && !file.delete()) { + throw IOException("Could not delete file: " + file.absolutePath) + } + if (!file.createNewFile()) { + throw IOException("Could not create file: " + file.absolutePath) + } + return file + } + + override fun onCleared() { + super.onCleared() + releasePlayer() + cancelExport() + exportStopwatch.reset() + } + + companion object { + private const val TAG = "CompPreviewVM" + private const val AUDIO_URI = + "https://storage.googleapis.com/exoplayer-test-media-0/play.mp3" + const val SAME_AS_INPUT_OPTION = "same as input" + val HDR_MODE_DESCRIPTIONS = mapOf( + Pair("Keep HDR", Composition.HDR_MODE_KEEP_HDR), + Pair( + "MediaCodec tone-map HDR to SDR", + Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_MEDIACODEC + ), + Pair( + "OpenGL tone-map HDR to SDR", + Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL + ), + Pair( + "Force Interpret HDR as SDR", + Composition.HDR_MODE_EXPERIMENTAL_FORCE_INTERPRET_HDR_AS_SDR + ) + ) + val RESOLUTION_HEIGHTS = listOf( + SAME_AS_INPUT_OPTION, "144", "240", "360", "480", "720", "1080", "1440", "2160" + ) + val MUXER_OPTIONS = listOf( + "Use Platform MediaMuxer", + "Use Media3 Mp4Muxer", + "Use Media3 FragmentedMp4Muxer" + ) + const val LAYOUT_EXTRA = "composition_layout" + val COMPOSITION_LAYOUT = listOf( + "Sequential", + "2x2 grid", + "PiP overlay" + ) + + fun getAudioBackgroundSequence(): EditedMediaItemSequence { + val audioMediaItem: MediaItem = MediaItem.Builder().setUri(AUDIO_URI).build() + val audioItem = + EditedMediaItem.Builder(audioMediaItem).setDurationUs(59_000_000).build() + return EditedMediaItemSequence.Builder(audioItem).setIsLooping(true).build() + } + } +} + +class CompositionPreviewViewModelFactory( + val application: Application, + val compositionLayout: String +) : ViewModelProvider.Factory { + init { + Log.d("CPVMF", "Creating ViewModel with $compositionLayout") + } + + override fun create(modelClass: Class): T { + return CompositionPreviewViewModel(application, compositionLayout) as T + } +} \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/MainActivity.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/MainActivity.kt new file mode 100644 index 00000000000..ff74d0794b8 --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/MainActivity.kt @@ -0,0 +1,86 @@ +package androidx.media3.demo.composition + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.res.stringArrayResource +import androidx.compose.ui.unit.dp +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.COMPOSITION_LAYOUT +import androidx.media3.demo.composition.CompositionPreviewViewModel.Companion.LAYOUT_EXTRA +import androidx.media3.demo.composition.ui.DropDownSpinner +import androidx.media3.demo.composition.ui.theme.CompositionDemoTheme + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + CompositionDemoTheme { + Scaffold( + topBar = { TopAppBar(title = { Text(text = "Composition Demo") }) }, + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + PresetSelector( + startPreviewActivity = ::startPreviewActivity, + modifier = Modifier.padding(innerPadding).padding(8.dp).fillMaxWidth() + ) + } + } + } + } + + fun startPreviewActivity(layoutSelection: String) { + val intent = Intent(this, CompositionPreviewActivity::class.java) + Log.d("CPVMF", "Sending layout of $layoutSelection") + intent.putExtra(LAYOUT_EXTRA, layoutSelection) + startActivity(intent) + } +} + +@Composable +fun PresetSelector(startPreviewActivity: (String) -> Unit, modifier: Modifier = Modifier) { + var selectedPreset by remember { mutableStateOf(COMPOSITION_LAYOUT[0]) } + var expanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier) { + Text(text = "Choose a layout preset:") + DropDownSpinner( + expanded, + selectedPreset, + COMPOSITION_LAYOUT, + { newExpandedState -> expanded = newExpandedState }, + { newSelection -> selectedPreset = newSelection }, + Modifier.fillMaxWidth() + ) + Button( + onClick = { + startPreviewActivity(selectedPreset) + Log.d("MainActivity", "Selected: $selectedPreset") + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Select") + } + } +} \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.java b/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.java deleted file mode 100644 index 85cc61d19f6..00000000000 --- a/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2024 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.composition; - -import android.graphics.Matrix; -import androidx.media3.common.C; -import androidx.media3.common.util.Util; -import androidx.media3.effect.GlMatrixTransformation; -import androidx.media3.effect.MatrixTransformation; - -/** - * Factory for {@link GlMatrixTransformation GlMatrixTransformations} and {@link - * MatrixTransformation MatrixTransformations} that create video effects by applying transformation - * matrices to the individual video frames. - */ -/* package */ final class MatrixTransformationFactory { - /** - * Returns a {@link MatrixTransformation} that rescales the frames over the first {@link - * #ZOOM_DURATION_SECONDS} seconds, such that the rectangle filled with the input frame increases - * linearly in size from a single point to filling the full output frame. - */ - public static MatrixTransformation createZoomInTransition() { - return MatrixTransformationFactory::calculateZoomInTransitionMatrix; - } - - /** - * Returns a {@link MatrixTransformation} that crops frames to a rectangle that moves on an - * ellipse. - */ - public static MatrixTransformation createDizzyCropEffect() { - return MatrixTransformationFactory::calculateDizzyCropMatrix; - } - - /** - * Returns a {@link GlMatrixTransformation} that rotates a frame in 3D around the y-axis and - * applies perspective projection to 2D. - */ - public static GlMatrixTransformation createSpin3dEffect() { - return MatrixTransformationFactory::calculate3dSpinMatrix; - } - - private static final float ZOOM_DURATION_SECONDS = 2f; - private static final float DIZZY_CROP_ROTATION_PERIOD_US = 5_000_000f; - - private static Matrix calculateZoomInTransitionMatrix(long presentationTimeUs) { - Matrix transformationMatrix = new Matrix(); - float scale = Math.min(1, presentationTimeUs / (C.MICROS_PER_SECOND * ZOOM_DURATION_SECONDS)); - transformationMatrix.postScale(/* sx= */ scale, /* sy= */ scale); - return transformationMatrix; - } - - private static android.graphics.Matrix calculateDizzyCropMatrix(long presentationTimeUs) { - double theta = presentationTimeUs * 2 * Math.PI / DIZZY_CROP_ROTATION_PERIOD_US; - float centerX = 0.5f * (float) Math.cos(theta); - float centerY = 0.5f * (float) Math.sin(theta); - android.graphics.Matrix transformationMatrix = new android.graphics.Matrix(); - transformationMatrix.postTranslate(/* dx= */ centerX, /* dy= */ centerY); - transformationMatrix.postScale(/* sx= */ 2f, /* sy= */ 2f); - return transformationMatrix; - } - - private static float[] calculate3dSpinMatrix(long presentationTimeUs) { - float[] transformationMatrix = new float[16]; - android.opengl.Matrix.frustumM( - transformationMatrix, - /* offset= */ 0, - /* left= */ -1f, - /* right= */ 1f, - /* bottom= */ -1f, - /* top= */ 1f, - /* near= */ 3f, - /* far= */ 5f); - android.opengl.Matrix.translateM( - transformationMatrix, /* mOffset= */ 0, /* x= */ 0f, /* y= */ 0f, /* z= */ -4f); - float theta = Util.usToMs(presentationTimeUs) / 10f; - android.opengl.Matrix.rotateM( - transformationMatrix, /* mOffset= */ 0, theta, /* x= */ 0f, /* y= */ 1f, /* z= */ 0f); - return transformationMatrix; - } -} diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.kt new file mode 100644 index 00000000000..bf15ac5eb0c --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/MatrixTransformationFactory.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 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.composition + +import android.graphics.Matrix +import androidx.media3.common.C +import androidx.media3.common.util.Util +import androidx.media3.effect.GlMatrixTransformation +import androidx.media3.effect.MatrixTransformation +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** + * Factory for [GlMatrixTransformations][GlMatrixTransformation] and + * [MatrixTransformations][MatrixTransformation] that create video effects by applying + * transformation matrices to the individual video frames. + */ +internal object MatrixTransformationFactory { + /** + * Returns a [MatrixTransformation] that rescales the frames over the first + * [ZOOM_DURATION_SECONDS] seconds, such that the rectangle filled with the input frame + * increases linearly in size from a single point to filling the full output frame. + */ + fun createZoomInTransition(): MatrixTransformation = + MatrixTransformation(::calculateZoomInTransitionMatrix) + + /** + * Returns a [MatrixTransformation] that crops frames to a rectangle that moves on an + * ellipse. + */ + fun createDizzyCropEffect(): MatrixTransformation = + MatrixTransformation(::calculateDizzyCropMatrix) + + /** + * Returns a [GlMatrixTransformation] that rotates a frame in 3D around the y-axis and + * applies perspective projection to 2D. + */ + fun createSpin3dEffect(): GlMatrixTransformation = + GlMatrixTransformation(::calculate3dSpinMatrix) + + private const val ZOOM_DURATION_SECONDS = 2f + private const val DIZZY_CROP_ROTATION_PERIOD_US = 5000000f + + private fun calculateZoomInTransitionMatrix(presentationTimeUs: Long): Matrix { + val transformationMatrix = Matrix() + val scale = min(1f, (presentationTimeUs / (C.MICROS_PER_SECOND * ZOOM_DURATION_SECONDS))) + transformationMatrix.postScale( /* sx= */scale, /* sy= */scale) + return transformationMatrix + } + + private fun calculateDizzyCropMatrix(presentationTimeUs: Long): Matrix { + val theta = presentationTimeUs * 2f * Math.PI.toFloat() / DIZZY_CROP_ROTATION_PERIOD_US + val centerX = 0.5f * cos(theta) + val centerY = 0.5f * sin(theta) + val transformationMatrix = Matrix() + transformationMatrix.postTranslate( /* dx= */centerX, /* dy= */centerY) + transformationMatrix.postScale( /* sx= */2f, /* sy= */2f) + return transformationMatrix + } + + private fun calculate3dSpinMatrix(presentationTimeUs: Long): FloatArray { + val transformationMatrix = FloatArray(16) + android.opengl.Matrix.frustumM( + transformationMatrix, + /* offset= */0, + /* left= */-1f, + /* right= */1f, + /* bottom= */-1f, + /* top= */1f, + /* near= */3f, + /* far= */5f + ) + android.opengl.Matrix.translateM( + transformationMatrix, /* mOffset= */0, /* x= */0f, /* y= */0f, /* z= */-4f + ) + val theta = Util.usToMs(presentationTimeUs) / 10f + android.opengl.Matrix.rotateM( + transformationMatrix, /* mOffset= */0, theta, /* x= */0f, /* y= */1f, /* z= */0f + ) + return transformationMatrix + } +} diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/UiComponents.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/UiComponents.kt new file mode 100644 index 00000000000..84272c78417 --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/UiComponents.kt @@ -0,0 +1,70 @@ +package androidx.media3.demo.composition.ui + +import android.util.Log +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringArrayResource +import androidx.media3.demo.composition.R + +@Composable +fun DropDownSpinner( + isDropDownOpen: Boolean, + selectedOption: T?, + dropDownOptions: List, + changeDropDownOpen: (Boolean) -> Unit, + changeSelectedOption: (T) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Box { + OutlinedTextField( + value = (selectedOption ?: "").toString(), + onValueChange = { }, + trailingIcon = { Icon(Icons.Outlined.ArrowDropDown, null) }, + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + // Detect click event on TextField to expand/close dropdown + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + upEvent?.let { + changeDropDownOpen(!isDropDownOpen) + } + } + }, + readOnly = true + ) + DropdownMenu(expanded = isDropDownOpen, onDismissRequest = { changeDropDownOpen(false) }) { + dropDownOptions.forEach { option -> + DropdownMenuItem( + text = { Text(text = option.toString()) }, + onClick = { + changeDropDownOpen(false) + changeSelectedOption(option) + }) + } + } + } + } +} \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Color.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Color.kt new file mode 100644 index 00000000000..4e32d823a88 --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package androidx.media3.demo.composition.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) \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Theme.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Theme.kt new file mode 100644 index 00000000000..4f10fd1f20a --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package androidx.media3.demo.composition.ui.theme + +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.ui.platform.LocalContext + +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 CompositionDemoTheme( + 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 + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Type.kt b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Type.kt new file mode 100644 index 00000000000..bcbd9c2552e --- /dev/null +++ b/demos/composition/src/main/java/androidx/media3/demo/composition/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package androidx.media3.demo.composition.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 + ) + */ +) \ No newline at end of file diff --git a/demos/composition/src/main/res/layout/composition_preview_activity.xml b/demos/composition/src/main/res/layout/composition_preview_activity.xml deleted file mode 100644 index 8c83dd443e5..00000000000 --- a/demos/composition/src/main/res/layout/composition_preview_activity.xml +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demos/composition/src/main/res/layout/export_settings.xml b/demos/composition/src/main/res/layout/export_settings.xml deleted file mode 100644 index f5276c048b3..00000000000 --- a/demos/composition/src/main/res/layout/export_settings.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demos/composition/src/main/res/layout/preset_item.xml b/demos/composition/src/main/res/layout/preset_item.xml deleted file mode 100644 index 41933efc0cd..00000000000 --- a/demos/composition/src/main/res/layout/preset_item.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - diff --git a/demos/composition/src/main/res/layout/spinner_item.xml b/demos/composition/src/main/res/layout/spinner_item.xml deleted file mode 100644 index 6519e31644a..00000000000 --- a/demos/composition/src/main/res/layout/spinner_item.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - diff --git a/demos/composition/src/main/res/values/strings.xml b/demos/composition/src/main/res/values/strings.xml index 7825d2ab200..3cc456bd27c 100644 --- a/demos/composition/src/main/res/values/strings.xml +++ b/demos/composition/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - - + +