diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80ac3bada..7f7b36974 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -226,6 +226,7 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref androidx-media3-effect = { module = "androidx.media3:media3-effect", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" } +androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3Ui" } androidx-media3-ui-compose-material3 = { module = "androidx.media3:media3-ui-compose-material3", version.ref = "media3Ui" } diff --git a/media/build.gradle.kts b/media/build.gradle.kts index 7d3545b0d..47900dc9b 100644 --- a/media/build.gradle.kts +++ b/media/build.gradle.kts @@ -26,5 +26,11 @@ android { } dependencies { + implementation(libs.androidx.media3.common) + implementation(libs.androidx.media3.effect) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.transformer) + implementation(libs.androidx.media3.ui) + implementation(libs.guava.android) } diff --git a/media/src/main/AndroidManifest.xml b/media/src/main/AndroidManifest.xml index 51685f593..46e31bc20 100644 --- a/media/src/main/AndroidManifest.xml +++ b/media/src/main/AndroidManifest.xml @@ -16,6 +16,18 @@ --> - + + + + + + + + + + diff --git a/media/src/main/java/com/example/media/AudioFocus.kt b/media/src/main/java/com/example/media/AudioFocus.kt new file mode 100644 index 000000000..4b4ade5f4 --- /dev/null +++ b/media/src/main/java/com/example/media/AudioFocus.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2026 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 + * + * https://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 com.example.media + +import android.content.Context +import android.content.ContextWrapper +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.session.MediaController +import android.os.Handler +import android.os.Looper +import java.util.concurrent.TimeUnit + +private class AudioFocusManager( + context: Context +) : ContextWrapper(context) { + private lateinit var audioManager: AudioManager + private lateinit var focusRequest: AudioFocusRequest + private val handler = Handler(Looper.getMainLooper()) + + private val afChangeListener = AudioFocusHandler() + + private var playbackDelayed = false + private var resumeOnFocusGain = false + private val focusLock = Any() + + private fun requestFocusPostOreo() { + // [START android_media_audio_focus_request] + // initializing variables for audio focus and playback management. + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { + setAudioAttributes( + AudioAttributes.Builder().run { + setUsage(AudioAttributes.USAGE_GAME) + setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + build() + } + ) + setAcceptsDelayedFocusGain(true) + setOnAudioFocusChangeListener(afChangeListener, handler) + build() + } + val focusLock = Any() + + var playbackDelayed = false + var playbackNowAuthorized = false + + // requesting audio focus and processing the response. + val res = audioManager.requestAudioFocus(focusRequest) + synchronized(focusLock) { + playbackNowAuthorized = when (res) { + AudioManager.AUDIOFOCUS_REQUEST_FAILED -> false + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + playbackNow() + true + } + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + playbackDelayed = true + false + } + else -> false + } + } + + // implementing OnAudioFocusChangeListener to react to focus changes. + // [START_EXCLUDE silent] + } + + private inner class AudioFocusHandler : AudioManager.OnAudioFocusChangeListener { + // [END_EXCLUDE] + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> + if (playbackDelayed || resumeOnFocusGain) { + synchronized(focusLock) { + playbackDelayed = false + resumeOnFocusGain = false + } + playbackNow() + } + AudioManager.AUDIOFOCUS_LOSS -> { + synchronized(focusLock) { + resumeOnFocusGain = false + playbackDelayed = false + } + pausePlayback() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + synchronized(focusLock) { + // only resume if playback is being interrupted. + resumeOnFocusGain = isPlaying() + playbackDelayed = false + } + pausePlayback() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // ... pausing or ducking depends on your app. + } + } + } + // [END android_media_audio_focus_request] + } + + private fun requestFocusPreOreo() { + // [START android_media_audio_focus_request_pre_o] + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + lateinit var afChangeListener: AudioManager.OnAudioFocusChangeListener + + // ... + // Request audio focus for playback. + val result: Int = audioManager.requestAudioFocus( + afChangeListener, + // Use the music stream. + AudioManager.STREAM_MUSIC, + // Request permanent focus. + AudioManager.AUDIOFOCUS_GAIN + ) + + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + // Start playback. + } + // [END android_media_audio_focus_request_pre_o] + } + + private fun abandonFocusExample(afChangeListener: AudioManager.OnAudioFocusChangeListener) { + // [START android_media_audio_focus_abandon] + audioManager.abandonAudioFocus(afChangeListener) + // [END android_media_audio_focus_abandon] + } + + private fun playbackNow() {} + private fun pausePlayback() {} + private fun isPlaying(): Boolean = true +} + +private class AudioFocusLegacyChangeHandler( + private val mediaController: MediaController +) { + // [START android_media_audio_focus_change_listener] + private val handler = Handler() + private val afChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + // Permanent loss of audio focus. + // Pause playback immediately. + mediaController.transportControls.pause() + // Wait 30 seconds before stopping playback. + handler.postDelayed(delayedStopRunnable, TimeUnit.SECONDS.toMillis(30)) + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + // Pause playback. + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + // Lower the volume, keep playing. + } + AudioManager.AUDIOFOCUS_GAIN -> { + // Your app has been granted audio focus again. + // Raise volume to normal, restart playback if necessary. + } + } + } + // [END android_media_audio_focus_change_listener] + + // [START android_media_audio_focus_delayed_stop_runnable] + private var delayedStopRunnable = Runnable { + mediaController.transportControls.stop() + } + // [END android_media_audio_focus_delayed_stop_runnable] +} diff --git a/media/src/main/java/com/example/media/EditingApp.kt b/media/src/main/java/com/example/media/EditingApp.kt new file mode 100644 index 000000000..64124ac55 --- /dev/null +++ b/media/src/main/java/com/example/media/EditingApp.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2026 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 + * + * https://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 com.example.media + +import android.content.Context +import android.graphics.Matrix +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.audio.ChannelMixingAudioProcessor +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Crop +import androidx.media3.effect.MatrixTransformation +import androidx.media3.effect.ScaleAndRotateTransformation +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.transformer.Composition +import androidx.media3.transformer.Composition.HDR_MODE_KEEP_HDR +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.EditedMediaItemSequence +import androidx.media3.transformer.Effects +import androidx.media3.transformer.Transformer +import com.google.common.collect.ImmutableList +import kotlin.math.min + +@OptIn(UnstableApi::class) +private fun configureTranscode(context: Context) { + // [START android_media_editing_transcode] + val transformer = Transformer.Builder(context) + .setVideoMimeType(MimeTypes.VIDEO_H265) + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .build() + // [END android_media_editing_transcode] +} + +@OptIn(UnstableApi::class) +private fun setHdrMode(videoSequence: EditedMediaItemSequence) { + // [START android_media_editing_set_hdr_mode] + val composition = Composition.Builder( + ImmutableList.of(videoSequence) + ) + .setHdrMode(HDR_MODE_KEEP_HDR) + .build() + // [END android_media_editing_set_hdr_mode] +} + +@OptIn(UnstableApi::class) +private fun trimVideo(videoUri: Uri) { + // [START android_media_editing_trim_video] + val clippingConfiguration = MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(10_000) // start at 10 seconds. + .setEndPositionMs(20_000) // end at 20 seconds. + .build() + val mediaItem = MediaItem.Builder() + .setUri(videoUri) + .setClippingConfiguration(clippingConfiguration) + .build() + // [END android_media_editing_trim_video] +} + +@OptIn(UnstableApi::class) +private fun builtInEffects(mediaItem: MediaItem) { + // [START android_media_editing_built_in_effects] + val channelMixingProcessor = ChannelMixingAudioProcessor() + val rotateEffect = ScaleAndRotateTransformation.Builder().setRotationDegrees(60f).build() + val cropEffect = Crop(-0.5f, 0.5f, -0.5f, 0.5f) + val effects = Effects(listOf(channelMixingProcessor), listOf(rotateEffect, cropEffect)) + + val editedMediaItem = EditedMediaItem.Builder(mediaItem) + .setEffects(effects) + .build() + // [END android_media_editing_built_in_effects] +} + +@OptIn(UnstableApi::class) +private fun customEffects(inputMediaItem: MediaItem) { + // [START android_media_editing_custom_effects] + val zoomEffect = MatrixTransformation { presentationTimeUs -> + val transformationMatrix = Matrix() + // Set the scaling factor based on the playback position. + val scale = min(1f, presentationTimeUs / 1_000f) + transformationMatrix.postScale(/* x */ scale, /* y */ scale) + transformationMatrix + } + + val editedMediaItem = EditedMediaItem.Builder(inputMediaItem) + .setEffects(Effects(listOf(), listOf(zoomEffect))) + .build() + // [END android_media_editing_custom_effects] +} + +@OptIn(UnstableApi::class) +private fun previewEffects(context: Context, inputMediaItem: MediaItem, zoomEffect: MatrixTransformation) { + // [START android_media_editing_preview_effects] + val player = ExoPlayer.Builder(context) + .build() + .also { exoPlayer -> + exoPlayer.setMediaItem(inputMediaItem) + exoPlayer.setVideoEffects(listOf(zoomEffect)) + exoPlayer.prepare() + } + // [END android_media_editing_preview_effects] +} + +@OptIn(UnstableApi::class) +private fun previewAudioEffects(context: Context, channelMixingProcessor: ChannelMixingAudioProcessor) { + // [START android_media_editing_preview_audio_effects] + val player = ExoPlayer.Builder( + context, + object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink? { + return DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessors(arrayOf(channelMixingProcessor)) + .build() + } + } + ).build() + // [END android_media_editing_preview_audio_effects] +} + +@OptIn(UnstableApi::class) +private fun startTransformation( + context: Context, + editedMediaItem: EditedMediaItem, + outputPath: String, + listener: Transformer.Listener +) { + // [START android_media_editing_start_transformation] + val transformer = Transformer.Builder(context) + .addListener(listener) + .build() + transformer.start(editedMediaItem, outputPath) + // [END android_media_editing_start_transformation] +} diff --git a/media/src/main/java/com/example/media/PlaybackApp.kt b/media/src/main/java/com/example/media/PlaybackApp.kt index 827eb6720..0b63d2435 100644 --- a/media/src/main/java/com/example/media/PlaybackApp.kt +++ b/media/src/main/java/com/example/media/PlaybackApp.kt @@ -16,11 +16,79 @@ package com.example.media +import android.app.Activity +import android.content.ComponentName import android.content.Context import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaController +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionToken +import androidx.media3.ui.PlayerView +import com.google.common.util.concurrent.MoreExecutors private fun createExoPlayer(context: Context) { // [START android_media_playback_app_create_exoplayer] val player = ExoPlayer.Builder(context).build() // [END android_media_playback_app_create_exoplayer] } + +private fun createMediaSession(context: Context) { + // [START android_media_playback_app_create_media_session] + val player = ExoPlayer.Builder(context).build() + val mediaSession = MediaSession.Builder(context, player).build() + // [END android_media_playback_app_create_media_session] +} + +// [START android_media_playback_app_playback_service] +class PlaybackService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + // Create your Player and MediaSession in the onCreate lifecycle event. + override fun onCreate() { + super.onCreate() + val player = ExoPlayer.Builder(this).build() + mediaSession = MediaSession.Builder(this, player).build() + } + + // Remember to release the player and media session in onDestroy. + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + // [START_EXCLUDE silent] + + // [START android_media_playback_app_on_get_session] + // This example always accepts the connection request. + override fun onGetSession( + controllerInfo: MediaSession.ControllerInfo + ): MediaSession? = mediaSession + // [END android_media_playback_app_on_get_session] + // [END_EXCLUDE] +} +// [END android_media_playback_app_playback_service] + +private class PlayerActivity : Activity() { + private lateinit var playerView: PlayerView + + // [START android_media_playback_app_connect_ui] + override fun onStart() { + super.onStart() + val sessionToken = SessionToken(this, ComponentName(this, PlaybackService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener( + { + // Call controllerFuture.get() to retrieve the MediaController. + // MediaController implements the Player interface, so it can be + // attached to the PlayerView UI component. + playerView.setPlayer(controllerFuture.get()) + }, + MoreExecutors.directExecutor() + ) + } + // [END android_media_playback_app_connect_ui] +} diff --git a/media/src/main/java/com/example/media/SurfacesMobile.kt b/media/src/main/java/com/example/media/SurfacesMobile.kt new file mode 100644 index 000000000..a3c91b881 --- /dev/null +++ b/media/src/main/java/com/example/media/SurfacesMobile.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2026 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 + * + * https://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 com.example.media + +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +private const val ACTION_FAVORITES = "ACTION_FAVORITES" + +// Wrapper avoids a collision with PlaybackService in PlaybackApp.kt. +private object CustomControlsSnippet { + @OptIn(UnstableApi::class) + // [START android_media_surfaces_mobile_custom_command_buttons] + class PlaybackService : MediaSessionService() { + private val customCommandFavorites = SessionCommand(ACTION_FAVORITES, Bundle.EMPTY) + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val favoriteButton = + CommandButton.Builder(CommandButton.ICON_HEART_UNFILLED) + .setDisplayName("Save to favorites") + .setSessionCommand(customCommandFavorites) + .build() + val player = ExoPlayer.Builder(this).build() + // Build the session with a custom layout. + mediaSession = + MediaSession.Builder(this, player) + .setCallback(MyCallback()) + .setMediaButtonPreferences(ImmutableList.of(favoriteButton)) + .build() + } + + private inner class MyCallback : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + // Set available player and session commands. + return AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .add(customCommandFavorites) + .build() + ) + .build() + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (customCommand.customAction == ACTION_FAVORITES) { + // Do custom logic here. + saveToFavorites(session.player.currentMediaItem) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + return super.onCustomCommand(session, controller, customCommand, args) + } + } + // [START_EXCLUDE silent] + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + // [END_EXCLUDE] + } + // [END android_media_surfaces_mobile_custom_command_buttons] +} + +private fun saveToFavorites(item: MediaItem?) {}