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 @@
-
-
+
+
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 18330fcba80..000b5ef12e8 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Thu Mar 13 09:28:51 PDT 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists