diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f841d1b..1725f4db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,7 @@ agp = "8.9.1" fragmentCompose = "1.8.6" kotlin = "2.1.10" -coreKtx = "1.17.0-alpha01" +coreKtx = "1.12.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" @@ -103,9 +103,9 @@ play-services-location = { module = "com.google.android.gms:play-services-locati # Core dependencies android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } -androidx-activity = "androidx.activity:activity-ktx:1.10.0" +androidx-activity = "androidx.activity:activity-ktx:1.13.0-alpha01" -androidx-core = "androidx.core:core-ktx:1.12.0" +androidx-core = "androidx.core:core-ktx:1.18.0-alpha01" androidx-exifinterface = "androidx.exifinterface:exifinterface:1.3.7" # Fragment 1.7.0 alpha and Transition 1.5.0 alpha are required for predictive back to work with Fragments and transitions androidx-fragment = "androidx.fragment:fragment-ktx:1.7.0-alpha10" @@ -170,6 +170,7 @@ androidx-window-rxjava2 = { module = "androidx.window:window-rxjava2", version.r androidx-window-core = { module = "androidx.window:window-core", version.ref = "androidx-window" } androidx-media = "androidx.media:media:1.7.0" androidx-constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4" +androidx-corepip = "androidx.core:core-pip:1.0.0-SNAPSHOT" androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } androidx-draganddrop = "androidx.draganddrop:draganddrop:1.0.0" androidx-dynamicanimation = "androidx.dynamicanimation:dynamicanimation-ktx:1.0.0-alpha03" diff --git a/samples/user-interface/picture-in-picture/build.gradle.kts b/samples/user-interface/picture-in-picture/build.gradle.kts index 8e7341c9..18a5ccf8 100644 --- a/samples/user-interface/picture-in-picture/build.gradle.kts +++ b/samples/user-interface/picture-in-picture/build.gradle.kts @@ -27,7 +27,7 @@ android { compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 targetSdk = 35 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -45,6 +45,7 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.media) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.corepip) // Testing androidTestImplementation(libs.androidx.test.core) diff --git a/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPMovieActivity.kt b/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPMovieActivity.kt index 8a569c1d..9d047b13 100644 --- a/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPMovieActivity.kt +++ b/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPMovieActivity.kt @@ -8,18 +8,17 @@ * 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. - */ +* 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.android.pip -import android.app.PictureInPictureParams -import android.app.PictureInPictureUiState + import android.content.res.Configuration -import android.graphics.Rect import android.os.Build import android.os.Bundle import android.support.v4.media.MediaMetadataCompat @@ -32,45 +31,61 @@ import android.util.Rational import android.view.View import androidx.activity.ComponentActivity import androidx.annotation.RequiresApi +import androidx.core.app.PictureInPictureParamsCompat +import androidx.core.pip.PictureInPictureDelegate +import androidx.core.pip.VideoPlaybackPictureInPicture import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.core.view.doOnLayout import com.example.android.pip.databinding.PipMovieActivityBinding import com.example.android.pip.widget.MovieView + /** * Demonstrates usage of Picture-in-Picture when using [MediaSessionCompat]. */ @RequiresApi(Build.VERSION_CODES.O) -class PiPMovieActivity : ComponentActivity() { +class PiPMovieActivity : ComponentActivity(), + PictureInPictureDelegate.OnPictureInPictureEventListener { + companion object { + private const val TAG = "MediaSessionPlaybackActivity" + private const val MEDIA_ACTIONS_PLAY_PAUSE = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE + private const val MEDIA_ACTIONS_ALL = MEDIA_ACTIONS_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + private const val PLAYLIST_SIZE = 2 } + private lateinit var binding: PipMovieActivityBinding + + private lateinit var pictureInPictureImpl: VideoPlaybackPictureInPicture + + private lateinit var session: MediaSessionCompat + /** * Callbacks from the [MovieView] showing the video playback. */ private val movieListener = object : MovieView.MovieListener() { + override fun onMovieStarted() { // We are playing the video now. Update the media session state and the PiP window will // update the actions. @@ -81,6 +96,7 @@ class PiPMovieActivity : ComponentActivity() { ) } + override fun onMovieStopped() { // The video stopped or reached its end. Update the media session state and the PiP // window will update the actions. @@ -90,50 +106,63 @@ class PiPMovieActivity : ComponentActivity() { binding.movie.getVideoResourceId(), ) } - - override fun onMovieMinimized() { - // The MovieView wants us to minimize it. We enter Picture-in-Picture mode now. - minimize() - } } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = PipMovieActivityBinding.inflate(layoutInflater) + pictureInPictureImpl = VideoPlaybackPictureInPicture(this) + pictureInPictureImpl.setPlayerView(binding.movie) + pictureInPictureImpl.setEnabled(true) + + setContentView(binding.root) + try { Linkify.addLinks(binding.explanation, Linkify.WEB_URLS) } catch (e: Exception) { Log.w("PiP", "Failed to add links", e) } - binding.pip.setOnClickListener { minimize() } - // Configure parameters for the picture-in-picture mode. We do this at the first layout of - // the MovieView because we use its layout position and size. - binding.movie.doOnLayout { updatePictureInPictureParams() } + binding.pip.setOnClickListener { + enterPictureInPictureMode(updatePictureInPictureParams()) + } // Set up the video; it automatically starts. binding.movie.setMovieListener(movieListener) } + override fun onStart() { super.onStart() initializeMediaSession() } + + private fun updatePictureInPictureParams(): PictureInPictureParamsCompat { + return PictureInPictureParamsCompat.Builder() + .setAspectRatio(Rational(binding.movie.width, binding.movie.height)) + .build() + } + + private fun initializeMediaSession() { session = MediaSessionCompat(this, TAG) session.isActive = true MediaControllerCompat.setMediaController(this, session.controller) + val metadata = MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, binding.movie.title) .build() session.setMetadata(metadata) + session.setCallback(MediaSessionCallback(binding.movie)) + val state = if (binding.movie.isPlaying) { PlaybackStateCompat.STATE_PLAYING } else { @@ -147,6 +176,7 @@ class PiPMovieActivity : ComponentActivity() { ) } + override fun onStop() { super.onStop() // On entering Picture-in-Picture mode, onPause is called, but not onStop. @@ -155,6 +185,7 @@ class PiPMovieActivity : ComponentActivity() { session.release() } + override fun onRestart() { super.onRestart() if (!isInPictureInPictureMode) { @@ -163,11 +194,13 @@ class PiPMovieActivity : ComponentActivity() { } } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) adjustFullScreen(newConfig) } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { @@ -175,62 +208,13 @@ class PiPMovieActivity : ComponentActivity() { } } - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, newConfig: Configuration, - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - if (isInPictureInPictureMode) { - // Hide the controls in picture-in-picture mode. - binding.movie.hideControls() - } else { - // Show the video controls if the video is not playing - if (!binding.movie.isPlaying) { - binding.movie.showControls() - } - } - } - - @RequiresApi(35) - override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) { - super.onPictureInPictureUiStateChanged(pipState) - if (pipState.isTransitioningToPip) { - binding.movie.hideControls() - } - } - - private fun updatePictureInPictureParams(): PictureInPictureParams { - // Calculate the aspect ratio of the PiP screen. - val aspectRatio = Rational(binding.movie.width, binding.movie.height) - // The movie view turns into the picture-in-picture mode. - val visibleRect = Rect() - binding.movie.getGlobalVisibleRect(visibleRect) - val params = PictureInPictureParams.Builder() - .setAspectRatio(aspectRatio) - // Specify the portion of the screen that turns into the picture-in-picture mode. - // This makes the transition animation smoother. - .setSourceRectHint(visibleRect) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // The screen automatically turns into the picture-in-picture mode when it is hidden - // by the "Home" button. - params.setAutoEnterEnabled(true) - } - return params.build().also { - setPictureInPictureParams(it) - } - } - /** - * Enters Picture-in-Picture mode. - */ - private fun minimize() { - enterPictureInPictureMode(updatePictureInPictureParams()) - } /** * Adjusts immersive full-screen flags depending on the screen orientation. + * @param config The current [Configuration]. */ private fun adjustFullScreen(config: Configuration) { @@ -248,9 +232,11 @@ class PiPMovieActivity : ComponentActivity() { } } + /** * Overloaded method that persists previously set media actions. + * @param state The state of the video, e.g. playing, paused, etc. * @param position The position of playback in the video. * @param mediaId The media id related to the video in the media session. @@ -264,6 +250,7 @@ class PiPMovieActivity : ComponentActivity() { updatePlaybackState(state, actions, position, mediaId) } + private fun updatePlaybackState( @PlaybackStateCompat.State state: Int, playbackActions: Long, @@ -277,6 +264,15 @@ class PiPMovieActivity : ComponentActivity() { session.setPlaybackState(builder.build()) } + + override fun onPictureInPictureEvent( + event: PictureInPictureDelegate.Event, + config: Configuration?, + ) { + TODO("Not yet implemented") + } + + /** * Updates the [MovieView] based on the callback actions.

* Simulates a playlist that will disable actions when you cannot skip through the playlist in a @@ -286,16 +282,20 @@ class PiPMovieActivity : ComponentActivity() { private val movieView: MovieView, ) : MediaSessionCompat.Callback() { + private var indexInPlaylist: Int = 1 + override fun onPlay() { movieView.play() } + override fun onPause() { movieView.pause() } + override fun onSkipToNext() { movieView.startVideo() if (indexInPlaylist < PLAYLIST_SIZE) { @@ -318,6 +318,7 @@ class PiPMovieActivity : ComponentActivity() { } } + override fun onSkipToPrevious() { movieView.startVideo() if (indexInPlaylist > 0) { diff --git a/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPSampleActivity.kt b/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPSampleActivity.kt index 1d5e967a..3c5f8379 100644 --- a/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPSampleActivity.kt +++ b/samples/user-interface/picture-in-picture/src/main/java/com/example/android/pip/PiPSampleActivity.kt @@ -17,8 +17,6 @@ package com.example.android.pip import android.app.PendingIntent -import android.app.PictureInPictureParams -import android.app.PictureInPictureUiState import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context @@ -26,7 +24,6 @@ import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.graphics.drawable.Icon -import android.os.Build import android.os.Bundle import android.util.Rational import android.view.View @@ -37,7 +34,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.core.app.ActivityCompat +import androidx.core.app.PictureInPictureParamsCompat import androidx.core.content.ContextCompat +import androidx.core.pip.BasicPictureInPicture +import androidx.core.pip.PictureInPictureDelegate import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -59,10 +59,12 @@ private const val REQUEST_START_OR_PAUSE = 4 * Demonstrates usage of Picture-in-Picture mode on phones and tablets. */ @RequiresApi(26) -class PiPSampleActivity : ComponentActivity() { +class PiPSampleActivity : ComponentActivity(), + PictureInPictureDelegate.OnPictureInPictureEventListener { private val viewModel: PiPViewModel by viewModels() private lateinit var binding: PipActivityBinding + private lateinit var pictureInPictureImpl: BasicPictureInPicture /** * A [BroadcastReceiver] for handling action items on the picture-in-picture mode. @@ -84,6 +86,7 @@ class PiPSampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = PipActivityBinding.inflate(layoutInflater) + initializePictureInPicture() setContentView(binding.root) // Event handlers binding.clear.setOnClickListener { viewModel.clear() } @@ -117,47 +120,41 @@ class PiPSampleActivity : ComponentActivity() { ) } - // This is called when the activity gets into or out of the picture-in-picture mode. - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration, - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - // Toggle visibility of in-app buttons. They cannot be interacted in the picture-in-picture - // mode, and their features are provided as the action icons. - toggleControls(if (isInPictureInPictureMode) View.GONE else View.VISIBLE) - } - private fun toggleControls(view: Int) { binding.clear.visibility = view binding.startOrPause.visibility = view } - @RequiresApi(35) - override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) { - super.onPictureInPictureUiStateChanged(pipState) - if (pipState.isTransitioningToPip) { - toggleControls(View.GONE) - } + private fun initializePictureInPicture() { + pictureInPictureImpl = BasicPictureInPicture(this) + pictureInPictureImpl.addOnPictureInPictureEventListener(ContextCompat.getMainExecutor(this), this) + pictureInPictureImpl + .setAspectRatio(Rational(16, 9)) + .setEnabled(true) + .setActions( + listOf( + // "Clear" action. + createRemoteAction( + R.drawable.ic_refresh_24dp, + R.string.clear, + REQUEST_CLEAR, + CONTROL_TYPE_CLEAR, + ), + ), + ) + } /** * Updates the parameters of the picture-in-picture mode for this activity based on the current * [started] state of the stopwatch. */ - private fun updatePictureInPictureParams(started: Boolean): PictureInPictureParams { - val params = PictureInPictureParams.Builder() + private fun updatePictureInPictureParams(started: Boolean): PictureInPictureParamsCompat { + val params = PictureInPictureParamsCompat.Builder() // Set action items for the picture-in-picture mode. These are the only custom controls // available during the picture-in-picture mode. .setActions( listOf( - // "Clear" action. - createRemoteAction( - R.drawable.ic_refresh_24dp, - R.string.clear, - REQUEST_CLEAR, - CONTROL_TYPE_CLEAR, - ), if (started) { // "Pause" action when the stopwatch is already started. createRemoteAction( @@ -177,16 +174,6 @@ class PiPSampleActivity : ComponentActivity() { }, ), ) - // Set the aspect ratio of the picture-in-picture mode. - .setAspectRatio(Rational(16, 9)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Turn the screen into the picture-in-picture mode if it's hidden by the "Home" button. - params.setAutoEnterEnabled(true) - // Disables the seamless resize. The seamless resize works great for videos where the - // content can be arbitrarily scaled, but you can disable this for non-video content so - // that the picture-in-picture mode is resized with a cross fade animation. - .setSeamlessResizeEnabled(false) - } return params.build().also { setPictureInPictureParams(it) } @@ -215,4 +202,21 @@ class PiPSampleActivity : ComponentActivity() { ), ) } + + override fun onPictureInPictureEvent( + event: PictureInPictureDelegate.Event, + config: Configuration?, + ) { + when (event) { + PictureInPictureDelegate.Event.ENTER_ANIMATION_START -> { + toggleControls(View.GONE) + } + PictureInPictureDelegate.Event.ENTERED -> { + toggleControls(View.GONE) + } + PictureInPictureDelegate.Event.EXITED -> { + toggleControls(View.VISIBLE) + } + } + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 178c8daa..76de2e01 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://androidx.dev/snapshots/builds/14715467/artifacts/repository") } } }