diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index 02021949cb..40dd9125de 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -34,10 +34,14 @@
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:usesCleartextTraffic="true"
android:largeHeap="true">
-
+
+
diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt
index 25244bb67e..e06b1d4823 100644
--- a/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt
+++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodeVideo.kt
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -219,7 +254,10 @@ internal fun EpisodeVideoImpl(
.ifThen(statusBarHeight != 0.dp) {
offset(x = -statusBarHeight / 2, y = 0.dp)
}
- .matchParentSize(),
+ .matchParentSize()
+ .onGloballyPositioned {
+ videoViewBounds = it.boundsInWindow()
+ },
)
}
},
@@ -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 = {
diff --git a/app/shared/video-player/src/androidMain/kotlin/ui/PictureInPictureController.android.kt b/app/shared/video-player/src/androidMain/kotlin/ui/PictureInPictureController.android.kt
new file mode 100644
index 0000000000..e18af82767
--- /dev/null
+++ b/app/shared/video-player/src/androidMain/kotlin/ui/PictureInPictureController.android.kt
@@ -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 {
+ val actions = mutableListOf()
+ 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
+}
\ No newline at end of file
diff --git a/app/shared/video-player/src/androidMain/kotlin/ui/PipActionReceiver.kt b/app/shared/video-player/src/androidMain/kotlin/ui/PipActionReceiver.kt
new file mode 100644
index 0000000000..f581995af2
--- /dev/null
+++ b/app/shared/video-player/src/androidMain/kotlin/ui/PipActionReceiver.kt
@@ -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? = 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)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/shared/video-player/src/commonMain/kotlin/ui/PictureInPictureController.kt b/app/shared/video-player/src/commonMain/kotlin/ui/PictureInPictureController.kt
new file mode 100644
index 0000000000..3208b91cab
--- /dev/null
+++ b/app/shared/video-player/src/commonMain/kotlin/ui/PictureInPictureController.kt
@@ -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)
+}
\ No newline at end of file
diff --git a/app/shared/video-player/src/commonMain/kotlin/ui/gesture/GestureLock.kt b/app/shared/video-player/src/commonMain/kotlin/ui/gesture/GestureLock.kt
index 269c02f6c9..abc697679a 100644
--- a/app/shared/video-player/src/commonMain/kotlin/ui/gesture/GestureLock.kt
+++ b/app/shared/video-player/src/commonMain/kotlin/ui/gesture/GestureLock.kt
@@ -158,6 +158,8 @@ fun LockableVideoGestureHost(
fastForwardSpeed = fastForwardSpeed,
)
},
+ onDrag: (Float) -> Unit = {},
+ onDragEnd: () -> Unit = {},
) {
if (locked) {
LockedScreenGestureHost(
@@ -183,6 +185,8 @@ fun LockableVideoGestureHost(
onExitFullscreen = onExitFullscreen,
onToggleDanmaku = onToggleDanmaku,
family = family,
+ onDrag = onDrag,
+ onDragEnd = onDragEnd,
)
}
}
diff --git a/app/shared/video-player/src/commonMain/kotlin/ui/gesture/PlayerGestureHost.kt b/app/shared/video-player/src/commonMain/kotlin/ui/gesture/PlayerGestureHost.kt
index 7d3c1f81bc..2c41271544 100644
--- a/app/shared/video-player/src/commonMain/kotlin/ui/gesture/PlayerGestureHost.kt
+++ b/app/shared/video-player/src/commonMain/kotlin/ui/gesture/PlayerGestureHost.kt
@@ -18,6 +18,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,6 +60,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -75,6 +77,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
@@ -82,6 +85,7 @@ import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
import me.him188.ani.app.tools.rememberUiMonoTasker
import me.him188.ani.app.ui.foundation.LocalPlatform
import me.him188.ani.app.ui.foundation.animation.AniAnimatedVisibility
@@ -448,6 +452,8 @@ fun PlayerGestureHost(
onToggleFullscreen: () -> Unit = {},
onExitFullscreen: () -> Unit = {},
onToggleDanmaku: () -> Unit = {},
+ onDrag: (Float) -> Unit,
+ onDragEnd: () -> Unit,
) {
val onTogglePauseResumeState by rememberUpdatedState(onTogglePauseResume)
@@ -684,6 +690,7 @@ fun PlayerGestureHost(
val indicatorTasker = rememberUiMonoTasker()
val focusManager by rememberUpdatedState(LocalFocusManager.current) // workaround for #288
+ val scope = rememberCoroutineScope()
if (family.autoHideController) {
LaunchedEffect(controllerState.visibility, controllerState.alwaysOn) {
@@ -787,53 +794,43 @@ fun PlayerGestureHost(
}
.fillMaxSize(),
) {
- Row(
+ Box(
Modifier.matchParentSize()
.systemGesturesPadding()
.ifThen(family.longPressForFastSkip) {
fastSkipState?.let {
longPressFastSkip(it, SkipDirection.FORWARD)
}
- },
- ) {
- Box(
- Modifier
- .ifThen(family.swipeLhsForBrightness) {
- swipeLevelControlWithIndicator(
- brightnessController,
- ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp),
- Orientation.Vertical,
- indicatorState,
- step = 0.01f,
- setup = {
- indicatorState.state = BRIGHTNESS
- },
- )
- }
- .weight(1f)
- .fillMaxHeight(),
- )
-
- Box(Modifier.weight(1f).fillMaxHeight())
-
- Box(
- Modifier
- .ifThen(family.swipeRhsForVolume) {
- swipeLevelControlWithIndicator(
- audioController,
- ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp),
- Orientation.Vertical,
- indicatorState,
- step = 0.05f,
- setup = {
- indicatorState.state = VOLUME
- },
- )
+ }.pointerInput(Unit) {
+ var startPointX = 0f
+ detectVerticalDragGestures(
+ onDragStart = { startPointX = it.x },
+ onDragEnd = { onDragEnd() }
+ ) { point, change ->
+
+ /**
+ * 左边亮度控制 (0%~40%)
+ * 中间触发画中画 (40%~60%)
+ * 右边音量控制 (60%~100%)
+ *
+ */
+ if (family.swipeLhsForBrightness && startPointX < size.width * 0.4f) {
+ // Left area - brightness control
+ val changeLevel = brightnessController.level + -(change / size.height)
+ brightnessController.setLevel(changeLevel)
+ scope.launch { indicatorState.showBrightnessRange(changeLevel) }
+ } else if (family.swipeRhsForVolume && startPointX > size.width * 0.6f) {
+ // Right area - volume control
+ val changeLevel = audioController.level + -(change / size.height)
+ audioController.setLevel(changeLevel)
+ scope.launch { indicatorState.showVolumeRange(changeLevel) }
+ } else {
+ // Trigger PiP mode when swiping down in the middle area
+ onDrag(change)
+ }
}
- .weight(1f)
- .fillMaxHeight(),
- )
- }
+ }.fillMaxHeight(),
+ )
}
// 状态栏区域响应点击手势
diff --git a/app/shared/video-player/src/desktopMain/kotlin/ui/PictureInPictureController.desktop.kt b/app/shared/video-player/src/desktopMain/kotlin/ui/PictureInPictureController.desktop.kt
new file mode 100644
index 0000000000..15a43f55ce
--- /dev/null
+++ b/app/shared/video-player/src/desktopMain/kotlin/ui/PictureInPictureController.desktop.kt
@@ -0,0 +1,14 @@
+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
+
+actual class PictureInPictureController actual constructor(
+ context: Context,
+ player: MediampPlayer
+) {
+ actual fun enterPictureInPictureMode(rect: Rect) {
+ // TODO
+ }
+}
\ No newline at end of file
diff --git a/app/shared/video-player/src/iosMain/kotlin/ui/AVPictureInPictureControllerDelegate.kt b/app/shared/video-player/src/iosMain/kotlin/ui/AVPictureInPictureControllerDelegate.kt
new file mode 100644
index 0000000000..6dc66042e2
--- /dev/null
+++ b/app/shared/video-player/src/iosMain/kotlin/ui/AVPictureInPictureControllerDelegate.kt
@@ -0,0 +1,68 @@
+package me.him188.ani.app.videoplayer.ui
+
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.AVFoundation.play
+import platform.AVKit.AVPictureInPictureController
+import platform.AVKit.AVPictureInPictureControllerDelegateProtocol
+import platform.Foundation.NSError
+import platform.Foundation.NSLog
+import platform.UIKit.UIApplication
+import platform.darwin.NSObject
+import platform.darwin.dispatch_async
+import platform.darwin.dispatch_get_main_queue
+import platform.darwin.sel_registerName
+
+internal class AVPictureInPictureControllerDelegate : NSObject(), AVPictureInPictureControllerDelegateProtocol {
+
+ @OptIn(ExperimentalForeignApi::class)
+ override fun pictureInPictureControllerWillStartPictureInPicture(
+ pictureInPictureController: AVPictureInPictureController
+ ) {
+ NSLog("Picture-in-Picture will start")
+ dispatch_async(dispatch_get_main_queue()) {
+ UIApplication.sharedApplication().performSelector(
+ sel_registerName("suspend")
+ )
+ pictureInPictureController.playerLayer.player?.play()
+ }
+ }
+
+ override fun pictureInPictureControllerDidStartPictureInPicture(
+ pictureInPictureController: AVPictureInPictureController
+ ) {
+ NSLog("Picture-in-Picture did start")
+
+
+ }
+
+ override fun pictureInPictureController(
+ pictureInPictureController: AVPictureInPictureController,
+ failedToStartPictureInPictureWithError: NSError
+ ) {
+ NSLog("Picture-in-Picture failed to start: ${failedToStartPictureInPictureWithError.localizedDescription}")
+
+ }
+
+ override fun pictureInPictureControllerWillStopPictureInPicture(
+ pictureInPictureController: AVPictureInPictureController
+ ) {
+ NSLog("Picture-in-Picture will stop")
+ }
+
+ override fun pictureInPictureControllerDidStopPictureInPicture(
+ pictureInPictureController: AVPictureInPictureController
+ ) {
+ NSLog("Picture-in-Picture did stop")
+// pictureInPictureController.playerLayer.removeFromSuperlayer()
+ }
+
+ override fun pictureInPictureController(
+ pictureInPictureController: AVPictureInPictureController,
+ restoreUserInterfaceForPictureInPictureStopWithCompletionHandler: (Boolean) -> Unit
+ ) {
+ NSLog("Picture-in-Picture restore user interface requested")
+ // 完成恢复
+ restoreUserInterfaceForPictureInPictureStopWithCompletionHandler(true)
+ }
+}
\ No newline at end of file
diff --git a/app/shared/video-player/src/iosMain/kotlin/ui/PictureInPictureController.ios.kt b/app/shared/video-player/src/iosMain/kotlin/ui/PictureInPictureController.ios.kt
new file mode 100644
index 0000000000..872c334aa2
--- /dev/null
+++ b/app/shared/video-player/src/iosMain/kotlin/ui/PictureInPictureController.ios.kt
@@ -0,0 +1,163 @@
+package me.him188.ani.app.videoplayer.ui
+
+import androidx.compose.runtime.RememberObserver
+import androidx.compose.ui.geometry.Rect
+import kotlinx.cinterop.ExperimentalForeignApi
+import me.him188.ani.app.platform.Context
+import org.openani.mediamp.MediampPlayer
+import org.openani.mediamp.avkit.AVKitMediampPlayer
+import org.openani.mediamp.avkit.PlayerUIView
+import platform.AVFoundation.AVPlayer
+import platform.AVFoundation.AVPlayerLayer
+import platform.AVKit.AVPictureInPictureController
+import platform.Foundation.NSLog
+import platform.UIKit.UIApplication
+import platform.UIKit.UIView
+import platform.UIKit.UIWindow
+import platform.UIKit.UIWindowScene
+import platform.darwin.DISPATCH_TIME_NOW
+import platform.darwin.dispatch_after
+import platform.darwin.dispatch_async
+import platform.darwin.dispatch_get_main_queue
+import platform.darwin.dispatch_time
+import platform.objc.sel_registerName
+
+@OptIn(ExperimentalForeignApi::class)
+actual class PictureInPictureController actual constructor(
+ private val context: Context,
+ private val player: MediampPlayer
+) : RememberObserver {
+
+
+ private lateinit var pipController: AVPictureInPictureController
+ private val pipDelegate = AVPictureInPictureControllerDelegate()
+
+ private fun initializePipController() = runCatching {
+ if (::pipController.isInitialized) {
+ return@runCatching
+ }
+
+ if (!isPictureInPictureSupported) {
+ NSLog("Picture-in-Picture is not supported on this device")
+ return@runCatching
+ }
+
+ val window: UIView? = getKeyWindow()
+ if (window == null) {
+ NSLog("There is no key window.")
+ return@runCatching
+ }
+
+ val playerUIView = findPlayerUIView(window)
+ if (playerUIView == null) {
+ NSLog("There is no playerUIView.")
+ return@runCatching
+ }
+
+ val playerLayer = findAVPlayerLayer(playerUIView)
+ if (playerLayer == null) {
+ NSLog("There is no playerLayer.")
+ return@runCatching
+ }
+
+ if (playerLayer.player == null) {
+ playerLayer.player = player.impl as AVPlayer
+ }
+
+ // 创建画中画控制器
+ pipController = AVPictureInPictureController(playerLayer).apply {
+ delegate = pipDelegate
+ // 启用自动画中画恢复
+ canStartPictureInPictureAutomaticallyFromInline = true
+ }
+ NSLog("PictureInPictureController initialized successfully")
+
+ }.onFailure { NSLog("Failed to setup PiP controller: ${it.message}") }
+
+ private val isPictureInPictureSupported: Boolean
+ get() = AVPictureInPictureController.isPictureInPictureSupported() &&
+ player is AVKitMediampPlayer
+
+ @OptIn(ExperimentalForeignApi::class)
+ actual fun enterPictureInPictureMode(rect: Rect) {
+ if (!::pipController.isInitialized) {
+ NSLog("Picture-in-Picture is not initialized")
+ return
+ }
+
+ if (!isPictureInPictureSupported) {
+ NSLog("Picture-in-Picture is not supported")
+ return
+ }
+
+ runCatching {
+ // 预检查画中画可用性
+ if (pipController.isPictureInPicturePossible()) {
+ dispatch_async(dispatch_get_main_queue()) {
+ if (pipController.isPictureInPictureActive()) {
+ pipController.stopPictureInPicture()
+ } else {
+ pipController.startPictureInPicture()
+ }
+ }
+ } else {
+ NSLog("Picture-in-Picture is not possible at this time")
+ }
+ }.onFailure {
+ NSLog("Failed to enter Picture-in-Picture mode: ${it.message}")
+ }
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ private fun getKeyWindow(): UIWindow? = UIApplication.sharedApplication().let { app ->
+ if (app.respondsToSelector(sel_registerName("connectedScenes"))) {
+ app.connectedScenes
+ .asSequence()
+ .filterIsInstance()
+ .flatMap { it.windows.asSequence() }
+ .filterIsInstance()
+ .firstOrNull { it.isKeyWindow() }
+ } else app.keyWindow
+ }
+
+ fun findPlayerUIView(view: UIView): PlayerUIView? {
+ if (view is PlayerUIView) {
+ return view
+ }
+
+ // 遍历子视图
+ for (subview in view.subviews) {
+ val result = findPlayerUIView(subview as UIView)
+ if (result != null) {
+ return result // 找到匹配的视图,返回
+ }
+ }
+
+ return null // 未找到匹配的视图
+ }
+
+ fun findAVPlayerLayer(playerUIView: PlayerUIView): AVPlayerLayer? {
+ return playerUIView.layer as? AVPlayerLayer
+ }
+
+ fun cleanup() {
+ if (::pipController.isInitialized) {
+ pipController.delegate = null
+ }
+ }
+
+ override fun onRemembered() {
+ val delay: Long = 1
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay), dispatch_get_main_queue()) {
+ initializePipController()
+ }
+ }
+
+ override fun onForgotten() {
+ cleanup()
+ }
+
+ override fun onAbandoned() {
+ cleanup()
+ }
+}
\ No newline at end of file