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