Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:usesCleartextTraffic="true"
android:largeHeap="true">

<!-- 支持画中画操作 -->
<receiver
android:name="me.him188.ani.app.videoplayer.ui.PipActionReceiver"
android:exported="false"/>
<activity
android:name=".activity.MainActivity"
android:exported="true"
android:supportsPictureInPicture="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

package me.him188.ani.app.ui.subject.episode

import androidx.compose.animation.core.Spring.DampingRatioLowBouncy
import androidx.compose.animation.core.Spring.StiffnessLow
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
Expand All @@ -29,10 +33,15 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand All @@ -41,6 +50,7 @@ import kotlinx.coroutines.flow.map
import me.him188.ani.app.data.models.preference.DarkMode
import me.him188.ani.app.domain.media.player.MediaCacheProgressInfo
import me.him188.ani.app.domain.player.VideoLoadingState
import me.him188.ani.app.platform.LocalContext
import me.him188.ani.app.tools.rememberUiMonoTasker
import me.him188.ani.app.ui.foundation.LocalIsPreviewing
import me.him188.ani.app.ui.foundation.LocalPlatform
Expand All @@ -58,6 +68,7 @@ import me.him188.ani.app.ui.foundation.theme.AniTheme
import me.him188.ani.app.ui.subject.episode.video.components.EpisodeVideoSideSheetPage
import me.him188.ani.app.ui.subject.episode.video.components.rememberStatusBarHeightAsState
import me.him188.ani.app.ui.subject.episode.video.loading.EpisodeVideoLoadingIndicator
import me.him188.ani.app.videoplayer.ui.PictureInPictureController
import me.him188.ani.app.videoplayer.ui.PlaybackSpeedControllerState
import me.him188.ani.app.videoplayer.ui.PlayerControllerState
import me.him188.ani.app.videoplayer.ui.VideoPlayer
Expand Down Expand Up @@ -152,12 +163,36 @@ internal fun EpisodeVideoImpl(
}
}

// 给画中画过度动画用的
var videoViewBounds by remember {
mutableStateOf(Rect.Zero)
}

var videoOffsetY by remember {
mutableFloatStateOf(.0f)
}
val videoMaxOffsetY = with(LocalDensity.current) { 50.dp.toPx() }
val videoThresholdOffsetDpY = 30.dp
val videoAnimeOffsetDpY by animateDpAsState(
with(LocalDensity.current) {
videoOffsetY.toDp()
},
animationSpec = spring(
dampingRatio = DampingRatioLowBouncy,
stiffness = StiffnessLow,
),
)
val context = LocalContext.current
val pipController = remember(context, playerState) {
PictureInPictureController(context, playerState)
}
AniTheme(darkModeOverride = DarkMode.DARK) {
VideoScaffold(
expanded = expanded,
modifier = modifier
.hoverable(videoInteractionSource)
.cursorVisibility(showCursor),
.cursorVisibility(showCursor)
.offset(y = videoAnimeOffsetDpY),
contentWindowInsets = contentWindowInsets,
maintainAspectRatio = maintainAspectRatio,
controllerState = playerControllerState,
Expand Down Expand Up @@ -219,7 +254,10 @@ internal fun EpisodeVideoImpl(
.ifThen(statusBarHeight != 0.dp) {
offset(x = -statusBarHeight / 2, y = 0.dp)
}
.matchParentSize(),
.matchParentSize()
.onGloballyPositioned {
videoViewBounds = it.boundsInWindow()
},
)
}
},
Expand Down Expand Up @@ -274,6 +312,19 @@ internal fun EpisodeVideoImpl(
family = gestureFamily,
indicatorState,
fastForwardSpeed = fastForwardSpeed,
onDrag = { change ->
videoOffsetY = (videoOffsetY + change).coerceIn(-videoMaxOffsetY, videoMaxOffsetY)
// Implement platform-specific PiP functionality
},
onDragEnd = {
if (videoAnimeOffsetDpY > videoThresholdOffsetDpY) {
if (expanded) onExitFullscreen()
else pipController.enterPictureInPictureMode(videoViewBounds)
} else if (videoAnimeOffsetDpY < -videoThresholdOffsetDpY) {
onClickFullScreen()
}
videoOffsetY = 0f
},
)
},
floatingMessage = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package me.him188.ani.app.videoplayer.ui

import android.app.Activity
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Log
import android.util.Rational
import android.widget.Toast
import androidx.compose.ui.geometry.Rect
import me.him188.ani.app.platform.Context
import me.him188.ani.app.platform.findActivity
import org.openani.mediamp.MediampPlayer
import org.openani.mediamp.togglePause
import java.lang.ref.WeakReference

private const val TAG = "PictureInPictureController"

actual class PictureInPictureController actual constructor(
private val context: Context,
private val player: MediampPlayer
) {
/**
* Enters Picture-in-Picture mode and returns to the system home page.
* The video will continue playing in a small window.
*
* @param rect The bounds of the video view in window coordinates
*/
actual fun enterPictureInPictureMode(rect: Rect) {
val activity = context.findActivity() ?: return
val playbackState = player.playbackState.value
// Ensure video continues playing
if (!playbackState.isPlaying) {
player.togglePause()
}

// Check if PiP is supported on this device
if (!isPipSupported(activity)) {
Log.e(TAG, "Picture-in-Picture is not supported on this device or by this activity")
Toast.makeText(
activity,
"Picture-in-Picture is not supported on this device",
Toast.LENGTH_SHORT
).show()
return
}
runCatching {
// Create PiP params builder
val pipParams = updatePipActions(activity, player, rect)
// Enter PiP mode
activity.enterPictureInPictureMode(pipParams)
}.onFailure { e ->
Log.e(TAG,"Failed to enter Picture-in-Picture mode: ${e.message}")
Toast.makeText(
activity,
"Failed to enter Picture-in-Picture mode",
Toast.LENGTH_SHORT
).show()
}
}

/**
* Checks if Picture-in-Picture is supported on the device and by the activity.
*/
private fun isPipSupported(activity: Activity): Boolean {
// Check if the app has declared PiP support in the manifest
return activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}


}

/**
* Updates PiP actions and parameters.
*/
internal fun updatePipActions(activity: Activity, player: MediampPlayer, rect: Rect? = null): PictureInPictureParams {
val pipParamsBuilder = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.setActions(createPipActions(activity, player))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
pipParamsBuilder.setAutoEnterEnabled(true)
}
if (rect != null) {
pipParamsBuilder.setSourceRectHint(
android.graphics.Rect(
rect.left.toInt(),
rect.top.toInt(),
rect.right.toInt(),
rect.bottom.toInt()
)
)
}
return pipParamsBuilder.build()
}

/**
* Creates PiP actions for the controller.
*/
private fun createPipActions(activity: Activity, player: MediampPlayer): List<RemoteAction> {
val actions = mutableListOf<RemoteAction>()
PipActionReceiver.mediaPlayer = player
PipActionReceiver.currentActivity = WeakReference(activity)
// 后退按钮 (快退15秒)
val rewindIntent = Intent(activity, PipActionReceiver::class.java).apply {
action = PipActionReceiver.ACTION_REWIND
}
val rewindPendingIntent = PendingIntent.getBroadcast(
activity,
0,
rewindIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val rewindAction = RemoteAction(
Icon.createWithResource(activity, android.R.drawable.ic_media_rew),
"快退",
"快退15秒",
rewindPendingIntent
)
actions.add(rewindAction)

// 播放/暂停按钮
val playPauseIntent = Intent(activity, PipActionReceiver::class.java).apply {
action = PipActionReceiver.ACTION_PLAY_PAUSE
}
val playPausePendingIntent = PendingIntent.getBroadcast(
activity,
1,
playPauseIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val playPauseIcon = if (player.getCurrentPlaybackState().isPlaying) {
android.R.drawable.ic_media_pause
} else {
android.R.drawable.ic_media_play
}
val playPauseAction = RemoteAction(
Icon.createWithResource(activity, playPauseIcon),
"播放/暂停",
"播放或暂停视频",
playPausePendingIntent
)
actions.add(playPauseAction)

// 快进按钮 (快进15秒)
val fastForwardIntent = Intent(activity, PipActionReceiver::class.java).apply {
action = PipActionReceiver.ACTION_FAST_FORWARD
}
val fastForwardPendingIntent = PendingIntent.getBroadcast(
activity,
2,
fastForwardIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val fastForwardAction = RemoteAction(
Icon.createWithResource(activity, android.R.drawable.ic_media_ff),
"快进",
"快进15秒",
fastForwardPendingIntent
)
actions.add(fastForwardAction)

return actions
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package me.him188.ani.app.videoplayer.ui

import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Intent
import me.him188.ani.app.platform.Context
import org.openani.mediamp.MediampPlayer
import org.openani.mediamp.togglePause
import java.lang.ref.WeakReference

class PipActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_PLAY_PAUSE = "pip_action_play_pause"
const val ACTION_REWIND = "pip_action_rewind"
const val ACTION_FAST_FORWARD = "pip_action_fast_forward"

// 存储MediampPlayer实例的静态引用
var mediaPlayer: MediampPlayer? = null

var currentActivity: WeakReference<Activity>? = null
}

override fun onReceive(context: Context, intent: Intent) {

val player = mediaPlayer ?: return
when (intent.action) {
ACTION_PLAY_PAUSE -> {
player.togglePause()
}

ACTION_REWIND -> {
player.skip(-15000) // 后退15秒
}

ACTION_FAST_FORWARD -> {
player.skip(15000) // 快进15秒
}
}

// 更新PiP控制按钮状态
currentActivity?.get()?.let { activity ->
val params = updatePipActions(activity, player)
activity.setPictureInPictureParams(params)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2024 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.videoplayer.ui

import androidx.compose.ui.geometry.Rect
import me.him188.ani.app.platform.Context
import org.openani.mediamp.MediampPlayer

/**
* Controller for Picture-in-Picture functionality.
*
* This is a platform-specific implementation that will be provided for Android and iOS.
* The controller should be initialized early and share the lifecycle with the composable.
*
* @param context The application context
* @param player The MediampPlayer instance that should be used for PiP mode
*/
expect class PictureInPictureController(context: Context, player: MediampPlayer) {


/**
* Enters Picture-in-Picture mode and returns to the system home page.
* The video will continue playing in a small window.
*
* @param rect The bounds of the video view in window coordinates
*/
fun enterPictureInPictureMode(rect: Rect)
}
Loading
Loading