diff --git a/android/app/src/main/java/com/example/piliplus/AndroidHelper.java b/android/app/src/main/java/com/example/piliplus/AndroidHelper.java index c82d140b1..52e4866b2 100644 --- a/android/app/src/main/java/com/example/piliplus/AndroidHelper.java +++ b/android/app/src/main/java/com/example/piliplus/AndroidHelper.java @@ -3,6 +3,7 @@ package com.example.piliplus; import android.app.Activity; import android.app.PendingIntent; import android.app.PictureInPictureParams; +import android.app.RemoteAction; import android.app.SearchManager; import android.content.ComponentName; import android.content.Context; @@ -15,6 +16,7 @@ import android.graphics.BitmapFactory; import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.Icon; +import android.media.session.PlaybackState; import android.net.Uri; import android.os.Build; import android.provider.MediaStore; @@ -22,12 +24,15 @@ import android.provider.Settings; import android.util.Rational; import android.view.WindowManager; +import androidx.annotation.DrawableRes; import androidx.annotation.Keep; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import com.github.dart_lang.jni_flutter.JniFlutterPlugin; import java.util.ArrayList; +import java.util.Objects; @Keep public final class AndroidHelper { @@ -149,12 +154,13 @@ public final class AndroidHelper { return false; } - public static void enterPip(int width, int height, boolean autoEnter, long engineId) { + public static void enterPip(long engineId, int width, int height, boolean autoEnter, boolean isLive, boolean isPlaying) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Activity activity = JniFlutterPlugin.getActivity(engineId); assert activity != null; PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder() .setAspectRatio(new Rational(width, height)); + setPipActions(activity, builder, isLive, isPlaying); if (autoEnter) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(true); @@ -166,6 +172,42 @@ public final class AndroidHelper { } } + @RequiresApi(api = Build.VERSION_CODES.O) + public static void updatePipActions(long engineId, boolean isLive, boolean isPlaying) { + Activity activity = JniFlutterPlugin.getActivity(engineId); + assert activity != null; + PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder(); + setPipActions(activity, builder, isLive, isPlaying); + activity.setPictureInPictureParams(builder.build()); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static void setPipActions(Activity activity, PictureInPictureParams.Builder builder, boolean isLive, boolean isPlaying) { + ArrayList actionList = new ArrayList<>(); + if (!isLive) { + actionList.add(getRemoteAction(activity, R.drawable.ic_baseline_replay_10_24, "ACTION_REWIND", PlaybackState.ACTION_REWIND)); + } + if (isPlaying) { + actionList.add(getRemoteAction(activity, android.R.drawable.ic_media_pause, "ACTION_PAUSE", PlaybackState.ACTION_PAUSE)); + } else { + actionList.add(getRemoteAction(activity, android.R.drawable.ic_media_play, "ACTION_PLAY", PlaybackState.ACTION_PLAY)); + } + if (!isLive) { + actionList.add(getRemoteAction(activity, R.drawable.ic_baseline_forward_10_24, "ACTION_FAST_FORWARD", PlaybackState.ACTION_FAST_FORWARD)); + } + builder.setActions(actionList); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static RemoteAction getRemoteAction(Activity activity, @DrawableRes int resId, String title, long action) { + return new RemoteAction( + Icon.createWithResource(activity, resId), + title, + title, + Objects.requireNonNull(MediaHelper.buildMediaButtonPendingIntent(activity, action)) + ); + } + public static void disableAutoEnterPip(long engineId) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Activity activity = JniFlutterPlugin.getActivity(engineId); @@ -225,4 +267,4 @@ public final class AndroidHelper { private ToDart() { } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/example/piliplus/MediaHelper.java b/android/app/src/main/java/com/example/piliplus/MediaHelper.java new file mode 100644 index 000000000..93e864033 --- /dev/null +++ b/android/app/src/main/java/com/example/piliplus/MediaHelper.java @@ -0,0 +1,100 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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. + */ + +package com.example.piliplus; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.media.session.PlaybackState; +import android.os.Build; +import android.util.Log; +import android.view.KeyEvent; + +import java.util.List; + +public class MediaHelper { + private static final String TAG = "MediaButtonReceiver"; + + public static PendingIntent buildMediaButtonPendingIntent(Context context, long action) { + ComponentName mbrComponent = getMediaButtonReceiverComponent(context); + if (mbrComponent == null) { + Log.w(TAG, "A unique media button receiver could not be found in the given context, so " + + "couldn't build a pending intent."); + return null; + } + return buildMediaButtonPendingIntent(context, mbrComponent, action); + } + + public static PendingIntent buildMediaButtonPendingIntent(Context context, ComponentName mbrComponent, long action) { + if (mbrComponent == null) { + Log.w(TAG, "The component name of media button receiver should be provided."); + return null; + } + int keyCode = PlaybackStateCompat_toKeyCode(action); + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + Log.w(TAG, + "Cannot build a media button pending intent with the given action: " + action); + return null; + } + Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); + intent.setComponent(mbrComponent); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + return PendingIntent.getBroadcast(context, keyCode, intent, + Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0); + } + + public static int PlaybackStateCompat_toKeyCode(long action) { + if (action == PlaybackState.ACTION_PLAY) { + return KeyEvent.KEYCODE_MEDIA_PLAY; + } else if (action == PlaybackState.ACTION_PAUSE) { + return KeyEvent.KEYCODE_MEDIA_PAUSE; + } else if (action == PlaybackState.ACTION_SKIP_TO_NEXT) { + return KeyEvent.KEYCODE_MEDIA_NEXT; + } else if (action == PlaybackState.ACTION_SKIP_TO_PREVIOUS) { + return KeyEvent.KEYCODE_MEDIA_PREVIOUS; + } else if (action == PlaybackState.ACTION_STOP) { + return KeyEvent.KEYCODE_MEDIA_STOP; + } else if (action == PlaybackState.ACTION_FAST_FORWARD) { + return KeyEvent.KEYCODE_MEDIA_FAST_FORWARD; + } else if (action == PlaybackState.ACTION_REWIND) { + return KeyEvent.KEYCODE_MEDIA_REWIND; + } else if (action == PlaybackState.ACTION_PLAY_PAUSE) { + return KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; + } + return KeyEvent.KEYCODE_UNKNOWN; + } + + public static ComponentName getMediaButtonReceiverComponent(Context context) { + Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + queryIntent.setPackage(context.getPackageName()); + PackageManager pm = context.getPackageManager(); + List resolveInfos = pm.queryBroadcastReceivers(queryIntent, 0); + if (resolveInfos.size() == 1) { + ResolveInfo resolveInfo = resolveInfos.get(0); + return new ComponentName(resolveInfo.activityInfo.packageName, + resolveInfo.activityInfo.name); + } else if (resolveInfos.size() > 1) { + Log.w(TAG, "More than one BroadcastReceiver that handles " + + Intent.ACTION_MEDIA_BUTTON + " was found, returning null."); + } + return null; + } +} diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index c8d5bfbf4..e55aa24ab 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -276,6 +276,7 @@ class PlPlayerController with BlockConfigMixin { } late bool _isAutoEnterPip = false; + bool get isAutoEnterPip => _isAutoEnterPip; static bool get _isCurrVideoPage { final routing = Get.routing; @@ -296,6 +297,8 @@ class PlPlayerController with BlockConfigMixin { autoEnter: autoEnter, width: state.width == 0 ? width : state.width, height: state.height == 0 ? height : state.height, + isLive: isLive, + isPlaying: playerStatus.isPlaying, ); } } @@ -1075,18 +1078,10 @@ class PlPlayerController with BlockConfigMixin { // } // }), // 媒体通知监听 - if (videoPlayerServiceHandler != null) ...[ - playerStatus.listen((PlayerStatus event) { - videoPlayerServiceHandler!.onStatusChange( - event, - isBuffering.value, - isLive, - ); - }), + if (videoPlayerServiceHandler != null) positionSeconds.listen((int event) { videoPlayerServiceHandler!.onPositionChange(Duration(seconds: event)); }), - ], ]; } diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart index 742017df0..c35ce26a3 100644 --- a/lib/services/audio_handler.dart +++ b/lib/services/audio_handler.dart @@ -1,4 +1,5 @@ -import 'dart:io' show File; +import 'dart:io' show File, Platform; +import 'dart:ui' show PlatformDispatcher; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/grpc/bilibili/app/listener/v1.pb.dart' show DetailItem; @@ -9,6 +10,7 @@ import 'package:PiliPlus/models_new/video/video_detail/data.dart'; import 'package:PiliPlus/models_new/video/video_detail/page.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; +import 'package:PiliPlus/utils/android/bindings.g.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; @@ -120,6 +122,15 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { }, ), ); + if (Platform.isAndroid && + (AndroidHelper.isPipMode || + PlPlayerController.instance?.isAutoEnterPip == true)) { + AndroidHelper.updatePipActions( + PlatformDispatcher.instance.engineId!, + isLive, + playing, + ); + } } void onStatusChange(PlayerStatus status, bool isBuffering, isLive) { diff --git a/lib/utils/android/android_helper.dart b/lib/utils/android/android_helper.dart index 4229b31e1..c77df635a 100644 --- a/lib/utils/android/android_helper.dart +++ b/lib/utils/android/android_helper.dart @@ -73,13 +73,20 @@ abstract final class PiliAndroidHelper { } @pragma('vm:prefer-inline') - static void enterPip(int width, int height, bool autoEnter) => - AndroidHelper.enterPip( - width, - height, - autoEnter, - PlatformDispatcher.instance.engineId!, - ); + static void enterPip( + int width, + int height, { + required bool autoEnter, + required bool isLive, + required bool isPlaying, + }) => AndroidHelper.enterPip( + PlatformDispatcher.instance.engineId!, + width, + height, + autoEnter, + isLive, + isPlaying, + ); @pragma('vm:prefer-inline') static void disableAutoEnterPip() => diff --git a/lib/utils/android/bindings.g.dart b/lib/utils/android/bindings.g.dart index 7372d2374..9388d0913 100644 --- a/lib/utils/android/bindings.g.dart +++ b/lib/utils/android/bindings.g.dart @@ -447,7 +447,7 @@ extension type AndroidHelper._(jni$_.JObject _$this) implements jni$_.JObject { static final _id_enterPip = _class.staticMethodId( r'enterPip', - r'(IIZJ)V', + r'(JIIZZZ)V', ); static final _enterPip = @@ -457,7 +457,14 @@ extension type AndroidHelper._(jni$_.JObject _$this) implements jni$_.JObject { jni$_.Pointer, jni$_.JMethodIDPtr, jni$_.VarArgs< - (jni$_.Int32, jni$_.Int32, jni$_.Int32, jni$_.Int64) + ( + jni$_.Int64, + jni$_.Int32, + jni$_.Int32, + jni$_.Int32, + jni$_.Int32, + jni$_.Int32, + ) >, ) > @@ -470,24 +477,71 @@ extension type AndroidHelper._(jni$_.JObject _$this) implements jni$_.JObject { core$_.int, core$_.int, core$_.int, + core$_.int, + core$_.int, ) >(); - /// from: `static public void enterPip(int width, int height, boolean autoEnter, long engineId)` + /// from: `static public void enterPip(long engineId, int width, int height, boolean autoEnter, boolean isLive, boolean isPlaying)` static void enterPip( + core$_.int engineId, core$_.int width, core$_.int height, core$_.bool autoEnter, - core$_.int engineId, + core$_.bool isLive, + core$_.bool isPlaying, ) { final _$$classRef = _class.reference; _enterPip( _$$classRef.pointer, _id_enterPip.pointer, + engineId, width, height, autoEnter ? 1 : 0, + isLive ? 1 : 0, + isPlaying ? 1 : 0, + ).check(); + } + + static final _id_updatePipActions = _class.staticMethodId( + r'updatePipActions', + r'(JZZ)V', + ); + + static final _updatePipActions = + jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Int64, jni$_.Int32, jni$_.Int32)>, + ) + > + >('globalEnv_CallStaticVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + core$_.int, + core$_.int, + core$_.int, + ) + >(); + + /// from: `static public void updatePipActions(long engineId, boolean isLive, boolean isPlaying)` + static void updatePipActions( + core$_.int engineId, + core$_.bool isLive, + core$_.bool isPlaying, + ) { + final _$$classRef = _class.reference; + _updatePipActions( + _$$classRef.pointer, + _id_updatePipActions.pointer, engineId, + isLive ? 1 : 0, + isPlaying ? 1 : 0, ).check(); } diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index be553733c..2fa7b7c81 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -193,7 +193,13 @@ abstract final class PageUtils { return (min <= aspectRatio) && (aspectRatio <= max); } - static void enterPip({int? width, int? height, bool autoEnter = false}) { + static void enterPip({ + int? width, + int? height, + bool autoEnter = false, + required bool isLive, + required bool isPlaying, + }) { if (width != null && height != null && !_fitsInAndroidRequirements(width, height)) { @@ -205,7 +211,13 @@ abstract final class PageUtils { height = 9; } } - PiliAndroidHelper.enterPip(width ?? 16, height ?? 9, autoEnter); + PiliAndroidHelper.enterPip( + width ?? 16, + height ?? 9, + autoEnter: autoEnter, + isLive: isLive, + isPlaying: isPlaying, + ); } static Future pushDynDetail( diff --git a/tool/jnigen.dart b/tool/jnigen.dart index 9d33d62e3..3d41a6925 100644 --- a/tool/jnigen.dart +++ b/tool/jnigen.dart @@ -15,7 +15,7 @@ void main(List args) { androidSdkConfig: AndroidSdkConfig(addGradleDeps: true), sourcePath: [packageRoot.resolve('android/app/src/main/java')], classes: [ - 'com.example.piliplus', + 'com.example.piliplus.AndroidHelper', 'java.lang.Runnable', ], ),