diff --git a/app/src/main/java/live/mehiz/mpvkt/preferences/PlayerPreferences.kt b/app/src/main/java/live/mehiz/mpvkt/preferences/PlayerPreferences.kt index faa2763..be2a7b8 100644 --- a/app/src/main/java/live/mehiz/mpvkt/preferences/PlayerPreferences.kt +++ b/app/src/main/java/live/mehiz/mpvkt/preferences/PlayerPreferences.kt @@ -27,4 +27,6 @@ class PlayerPreferences( val defaultSpeed = preferenceStore.getFloat("default_speed", 1f) val savePositionOnQuit = preferenceStore.getBoolean("save_position", true) + + val automaticallyEnterPip = preferenceStore.getBoolean("automatic_pip") } diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/PipActions.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/PipActions.kt new file mode 100644 index 0000000..10f607c --- /dev/null +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/PipActions.kt @@ -0,0 +1,72 @@ +package live.mehiz.mpvkt.ui.player + +import android.app.PendingIntent +import android.app.RemoteAction +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build.VERSION_CODES.O +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import live.mehiz.mpvkt.R + +@RequiresApi(O) +fun createPipActions( + context: Context, + isPaused: Boolean, +): ArrayList = arrayListOf( + createPipAction( + context, + "fast rewind", + R.drawable.baseline_fast_rewind_24, + PIP_FR, + ), + if (isPaused) { + createPipAction( + context, + "play", + R.drawable.baseline_play_arrow_24, + PIP_PLAY, + ) + } else { + createPipAction( + context, + "pause", + R.drawable.baseline_pause_24, + PIP_PAUSE, + ) + }, + createPipAction( + context, + "fast forward", + R.drawable.baseline_fast_forward_24, + PIP_FF + ) +) + +@RequiresApi(O) +fun createPipAction( + context: Context, + title: String, + @DrawableRes icon: Int, + actionCode: Int, +): RemoteAction { + return RemoteAction( + Icon.createWithResource(context, icon), + title, + title, + PendingIntent.getBroadcast( + context, + actionCode, + Intent(PIP_INTENTS_FILTER).putExtra(PIP_INTENT_ACTION, actionCode).setPackage(context.packageName), + PendingIntent.FLAG_IMMUTABLE, + ), + ) +} + +const val PIP_INTENTS_FILTER = "pip_control" +const val PIP_INTENT_ACTION = "media_control" +const val PIP_PAUSE = 1 +const val PIP_PLAY = 2 +const val PIP_FF = 3 +const val PIP_FR = 4 diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerActivity.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerActivity.kt index d0ff493..5f8236f 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerActivity.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerActivity.kt @@ -1,17 +1,29 @@ package live.mehiz.mpvkt.ui.player +import android.app.PictureInPictureParams +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.res.Configuration import android.media.AudioManager import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.ParcelFileDescriptor import android.util.Log +import android.util.Rational import android.view.KeyEvent import android.view.WindowManager import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toAndroidRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.core.view.WindowCompat import androidx.documentfile.provider.DocumentFile import androidx.media.AudioAttributesCompat @@ -56,6 +68,16 @@ class PlayerActivity : AppCompatActivity() { private var audioFocusRequest: AudioFocusRequestCompat? = null private var restoreAudioFocus: () -> Unit = {} + private var pipRect: android.graphics.Rect? = null + private val isPipSupported by lazy { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + false + } else { + packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + } + private var pipReceiver: BroadcastReceiver? = null + override fun onCreate(savedInstanceState: Bundle?) { if (playerPreferences.drawOverDisplayCutout.get()) enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -78,25 +100,58 @@ class PlayerActivity : AppCompatActivity() { binding.controls.setContent { MpvKtTheme { - controls.Content() + controls.Content( + modifier = Modifier.onGloballyPositioned { + pipRect = it.boundsInWindow().toAndroidRect() + }, + ) } } } override fun onDestroy() { + super.onDestroy() audioFocusRequest?.let { AudioManagerCompat.abandonAudioFocusRequest(audioManager, it) } audioFocusRequest = null MPVLib.destroy() - super.onDestroy() + } + + override fun finish() { + endPlayback(EndPlaybackReason.ExternalAction) + super.finish() } override fun onPause() { super.onPause() + CoroutineScope(Dispatchers.IO).launch { + saveVideoPlaybackState() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (isInPictureInPictureMode) return + } viewModel.pause() } + override fun onUserLeaveHint() { + if (!isPipSupported) { + super.onUserLeaveHint() + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && player.paused == false) { + enterPictureInPictureMode() + } + super.onUserLeaveHint() + } + + override fun onStart() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setPictureInPictureParams(createPipParams()) + } + super.onStart() + } + private fun setupMPV() { Utils.copyAssets(this) copyMPVConfigFiles() @@ -178,7 +233,8 @@ class PlayerActivity : AppCompatActivity() { private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { when (it) { AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + -> { val oldRestore = restoreAudioFocus val wasPlayerPaused = player.paused ?: false viewModel.pause() @@ -207,7 +263,10 @@ class PlayerActivity : AppCompatActivity() { } private fun setupIntents(intent: Intent) { - intent.getStringExtra("title")?.ifBlank { viewModel.mediaTitle.update { it } } + intent.getStringExtra("title")?.let { + viewModel.mediaTitle.update { _ -> it } + MPVLib.setPropertyString("force-media-title", it) + } player.timePos = intent.getIntExtra("position", 0) / 1000 } @@ -327,6 +386,10 @@ class PlayerActivity : AppCompatActivity() { } } + @Suppress("EmptyFunctionBlock", "UnusedParameter") + internal fun efEvent(err: String?) { + } + private suspend fun saveVideoPlaybackState() { mpvKtDatabase.videoDataDao().upsert( PlaybackStateEntity( @@ -349,16 +412,11 @@ class PlayerActivity : AppCompatActivity() { } } - override fun finish() { - endPlayback(EndPlaybackReason.ExternalAction) - } - private fun endPlayback(reason: EndPlaybackReason) { CoroutineScope(Dispatchers.IO).launch { saveVideoPlaybackState() } if (!intent.getBooleanExtra("return_result", false)) { - super.finish() return } val returnIntent = Intent() @@ -366,11 +424,68 @@ class PlayerActivity : AppCompatActivity() { player.timePos?.let { returnIntent.putExtra("position", it * 1000) } player.duration?.let { returnIntent.putExtra("duration", it * 1000) } setResult(RESULT_OK, returnIntent) - super.finish() } - @Suppress("EmptyFunctionBlock", "UnusedParameter") - internal fun efEvent(err: String?) { + @RequiresApi(Build.VERSION_CODES.O) + fun createPipParams(): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder.setTitle(viewModel.mediaTitle.value) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val autoEnter = playerPreferences.automaticallyEnterPip.get() + builder.setAutoEnterEnabled(player.paused == false && autoEnter) + builder.setSeamlessResizeEnabled(player.paused == false && autoEnter) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setActions(createPipActions(this, player.paused ?: true)) + } + builder.setSourceRectHint(pipRect) + player.videoH?.let { + val height = it + val width = it * player.getVideoOutAspect()!! + val rational = Rational(height, width.toInt()).toFloat() + if (rational in 0.41..2.40) builder.setAspectRatio(Rational(width.toInt(), height)) + } + return builder.build() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + if (!isInPictureInPictureMode) { + pipReceiver?.let { + unregisterReceiver(pipReceiver) + pipReceiver = null + } + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setPictureInPictureParams(createPipParams()) + } + viewModel.hideControls() + viewModel.hideSeekBar() + viewModel.isBrightnessSliderShown.update { false } + viewModel.isVolumeSliderShown.update { false } + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null || intent.action != PIP_INTENTS_FILTER) return + when (intent.getIntExtra(PIP_INTENT_ACTION, 0)) { + PIP_PAUSE -> viewModel.pause() + PIP_PLAY -> viewModel.unpause() + PIP_FF -> viewModel.seekBy(playerPreferences.doubleTapToSeekDuration.get()) + PIP_FR -> viewModel.seekBy(-playerPreferences.doubleTapToSeekDuration.get()) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setPictureInPictureParams(createPipParams()) + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pipReceiver, IntentFilter(PIP_INTENTS_FILTER), RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(pipReceiver, IntentFilter(PIP_INTENTS_FILTER)) + } + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) } private fun setOrientation() { diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt index f52ff8d..45de9ed 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/PlayerViewModel.kt @@ -2,6 +2,7 @@ package live.mehiz.mpvkt.ui.player import android.media.AudioManager import android.net.Uri +import android.os.Build import android.provider.Settings import android.util.DisplayMetrics import androidx.core.view.WindowInsetsCompat @@ -237,6 +238,11 @@ class PlayerViewModel( fun pause() { activity.player.paused = true _paused.update { true } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatching { + activity.setPictureInPictureParams(activity.createPipParams()) + } + } } fun unpause() { @@ -349,6 +355,11 @@ class PlayerViewModel( MPVLib.setPropertyDouble("video-aspect-override", ratio) playerPreferences.videoAspect.set(aspect) playerUpdate.update { PlayerUpdates.AspectRatio } + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity.setPictureInPictureParams(activity.createPipParams()) + } + } } } diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/BottomRightPlayerControls.kt b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/BottomRightPlayerControls.kt index 784334c..ed33368 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/BottomRightPlayerControls.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/player/controls/BottomRightPlayerControls.kt @@ -1,13 +1,17 @@ package live.mehiz.mpvkt.ui.player.controls +import android.os.Build import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AspectRatio +import androidx.compose.material.icons.filled.PictureInPictureAlt import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import live.mehiz.mpvkt.preferences.PlayerPreferences import live.mehiz.mpvkt.preferences.preference.collectAsState +import live.mehiz.mpvkt.ui.player.PlayerActivity import live.mehiz.mpvkt.ui.player.PlayerViewModel import live.mehiz.mpvkt.ui.player.VideoAspect import live.mehiz.mpvkt.ui.player.controls.components.ControlsButton @@ -16,11 +20,24 @@ import org.koin.compose.koinInject @Composable fun BottomRightPlayerControls( viewModel: PlayerViewModel, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val playerPreferences = koinInject() val aspect by playerPreferences.videoAspect.collectAsState() Row(modifier) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val activity = LocalContext.current as PlayerActivity + ControlsButton( + Icons.Default.PictureInPictureAlt, + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + activity.enterPictureInPictureMode(activity.createPipParams()) + } else { + activity.enterPictureInPictureMode() + } + }, + ) + } ControlsButton( Icons.Default.AspectRatio, onClick = { diff --git a/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PlayerPreferencesScreen.kt b/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PlayerPreferencesScreen.kt index 91fb8d3..df06d6c 100644 --- a/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PlayerPreferencesScreen.kt +++ b/app/src/main/java/live/mehiz/mpvkt/ui/preferences/PlayerPreferencesScreen.kt @@ -1,5 +1,6 @@ package live.mehiz.mpvkt.ui.preferences +import android.content.pm.PackageManager import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -38,7 +39,6 @@ object PlayerPreferencesScreen : Screen { val navigator = LocalNavigator.currentOrThrow val context = LocalContext.current val preferences = koinInject() - val doubleTapToPause by preferences.doubleTapToPause.collectAsState() val doubleTapToSeek by preferences.doubleTapToSeek.collectAsState() Scaffold( topBar = { @@ -98,6 +98,13 @@ object PlayerPreferencesScreen : Screen { summary = { Text(text = "${it}s") }, enabled = { doubleTapToSeek }, ) + if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + switchPreference( + key = preferences.automaticallyEnterPip.key(), + defaultValue = preferences.automaticallyEnterPip.defaultValue(), + title = { Text(text = stringResource(id = R.string.pref_player_automatically_enter_pip)) }, + ) + } preferenceCategory( "gestures", title = { Text(stringResource(R.string.pref_player_gestures)) }, @@ -120,7 +127,7 @@ object PlayerPreferencesScreen : Screen { switchPreference( preferences.holdForDoubleSpeed.key(), defaultValue = preferences.holdForDoubleSpeed.defaultValue(), - title = { Text(stringResource(R.string.pref_player_gestures_hold_for_double_speed)) } + title = { Text(stringResource(R.string.pref_player_gestures_hold_for_double_speed)) }, ) preferenceCategory( "controls", diff --git a/app/src/main/res/drawable/baseline_fast_forward_24.xml b/app/src/main/res/drawable/baseline_fast_forward_24.xml new file mode 100644 index 0000000..a19b235 --- /dev/null +++ b/app/src/main/res/drawable/baseline_fast_forward_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_fast_rewind_24.xml b/app/src/main/res/drawable/baseline_fast_rewind_24.xml new file mode 100644 index 0000000..0313dac --- /dev/null +++ b/app/src/main/res/drawable/baseline_fast_rewind_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_pause_24.xml b/app/src/main/res/drawable/baseline_pause_24.xml new file mode 100644 index 0000000..ae853f2 --- /dev/null +++ b/app/src/main/res/drawable/baseline_pause_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_play_arrow_24.xml b/app/src/main/res/drawable/baseline_play_arrow_24.xml new file mode 100644 index 0000000..b176182 --- /dev/null +++ b/app/src/main/res/drawable/baseline_play_arrow_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b2d825..821e245 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Double tap to pause Double tap to seek Double tap seek duration + Automatically switch to PiP Gestures Horizontal seek gestures