一,需求场景:
视频会议软件开启屏幕共享时有的会自动关闭本地摄像头视频预览,在这个时候我们想打开一个本地摄像头预览悬浮窗,并且会议软件重新打开本地视频,悬浮窗要消失。
实现以上需求分两步:
-
step 1:
要准确监听屏幕共享的开关
-
step 2:
本地视像头预览悬浮窗实现。包括拖动预览开启关闭等功能
二,监听屏幕共享的开关
安卓屏幕共享实现是通过MediaProjection捕获屏幕类容实现的,以下是一个简单的用例:
import android.content.Intent;
import android.media.MediaRecorder;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Bundle;
import android.os.Environment;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private static final int REQUEST_CODE_CAPTURE = 1000;
private MediaProjectionManager projectionManager;
private MediaProjection mediaProjection;
private MediaRecorder mediaRecorder;
private boolean isRecording = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
projectionManager = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
startScreenCapture();
}
private void startScreenCapture() {
if (!isRecording) {
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE_CAPTURE);
} else {
stopScreenCapture();
}
}
private void stopScreenCapture() {
if (mediaRecorder != null && isRecording) {
mediaRecorder.stop();
mediaRecorder.reset();
mediaRecorder.release();
mediaRecorder = null;
isRecording = false;
Toast.makeText(this, "Screen capture stopped", Toast.LENGTH_SHORT).show();
}
if (mediaProjection != null) {
mediaProjection.stop();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_CAPTURE) {
if (resultCode == RESULT_OK && data != null) {
startRecording(data);
} else {
Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show();
// Handle the case when the user denies screen capture permission
}
}
}
private void startRecording(Intent data) {
mediaProjection = projectionManager.getMediaProjection(resultCode, data);
mediaRecorder = new MediaRecorder();
mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264);
mediaRecorder.setOutputFile(Environment.getExternalStorageDirectory() + "/screen_record.mp4");
int width = 1280; // Set desired width
int height = 720; // Set desired height
int dpi = 240; // Set desired DPI
mediaRecorder.setVideoSize(width, height);
mediaRecorder.setVideoFrameRate(30);
mediaRecorder.setVideoEncodingBitRate(3000000);
try {
mediaRecorder.prepare();
} catch (Exception e) {
e.printStackTrace();
}
mediaProjection.createVirtualDisplay("MainActivity",
width, height, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mediaRecorder.getSurface(), null, null);
mediaRecorder.start();
isRecording = true;
Toast.makeText(this, "Screen capture started", Toast.LENGTH_SHORT).show();
}
@Override
protected void onDestroy() {
super.onDestroy();
stopScreenCapture();
}
}
开始屏幕捕获调用了mediaProjection.createVirtualDisplay(),停止屏幕捕获调用了mediaProjection.stop()。一开始我是在MediaProjection.java这两个方法中监听是否开启屏幕共享,但后来发现大部分会议app停止共享按钮是调用了stop()方法,但是结束会议却没有调用stop方法,这个场景监听这两个方法就是不可行的。
后面发现开启结束屏幕录制都会打印以下日志:
位置:frameworks/base/services/core/java/com/android/server/display/DisplayDeviceRepository.java
日志:Display device added:xxxxxxxxx ,Display device removed:xxxxxxx
以上日志意思是添加移除了一个display,后面了解到屏幕捕获添加移除的display类型是Display.TYPE_VIRTUAL。
Display.TYPE_VIRTUAL 是 Android 中的一个常量,它代表虚拟显示屏幕的类型。虚拟显示屏幕是在不需要物理显示屏幕的情况下创建的,允许应用程序进行图形渲染和图像处理等操作,而无需实际显示在物理屏幕上。
这个常量一般用于 VirtualDisplay 类的创建。VirtualDisplay 可以让你在 Android 设备上创建一个虚拟显示屏,并在这个虚拟显示屏上渲染内容,比如视频播放、屏幕录制、演示文稿等。虚拟显示屏幕不会在实际的物理屏幕上显示内容,而是供应用程序自行处理和操作。
TYPE_VIRTUAL 常量代表虚拟显示屏幕的类型,这种显示屏通常是通过 MediaProjection 和 MediaProjectionManager 创建的,用于屏幕捕获、录制和其它图像处理操作。
所以这个类型的display是符合屏幕共享场景的。最终监听实现如下:
private void handleDisplayDeviceAdded(DisplayDevice device) {
synchronized (mSyncRoot) {
DisplayDeviceInfo info = device.getDisplayDeviceInfoLocked();
if (mDisplayDevices.contains(device)) {
Slog.w(TAG, "Attempted to add already added display device: " + info);
return;
}
// add by cjx
onScreenShareDisplayAddOrRemoved(true, info.type);
// end
Slog.i(TAG, "Display device added: " + info);
device.mDebugLastLoggedDeviceInfo = info;
mDisplayDevices.add(device);
sendEventLocked(device, DISPLAY_DEVICE_EVENT_ADDED);
}
}
private void handleDisplayDeviceRemoved(DisplayDevice device) {
synchronized (mSyncRoot) {
DisplayDeviceInfo info = device.getDisplayDeviceInfoLocked();
if (!mDisplayDevices.remove(device)) {
Slog.w(TAG, "Attempted to remove non-existent display device: " + info);
return;
}
// add by cjx
onScreenShareDisplayAddOrRemoved(false, info.type);
// end
Slog.i(TAG, "Display device removed: " + info);
device.mDebugLastLoggedDeviceInfo = info;
sendEventLocked(device, DISPLAY_DEVICE_EVENT_REMOVED);
}
}
// 通知应用显示悬浮窗
String ACTION_START_CAMERA_FLOAT_WINDOW = "xxxxxxxxxxxxx.service.start_window_show";
String ACTION_STOP_CAMERA_FLOAT_WINDOW = "xxxxxxxxxxxxxx.service.stop_window_show";
String PKG_NAME_CAMERA_TOOL = "com.kandaovr.meeting.camera";
private void onScreenShareDisplayAddOrRemoved(boolean add, int type){
if(type != Display.TYPE_VIRTUAL){
Slog.d(TAG,"is add" + add +"--type:" + type);
return;
}
Log.i(TAG, "onScreenShareDisplayAddOrRemoved isAdd:" + add);
mHandler.postDelayed(() -> {
Intent intent = new Intent();
intent.setPackage(PKG_NAME_CAMERA_TOOL);
if (add) {
intent.setAction(ACTION_START_CAMERA_FLOAT_WINDOW);
} else {
intent.setAction(ACTION_STOP_CAMERA_FLOAT_WINDOW);
}
mContext.startService(intent);
},100);
}
三,camera悬浮窗实现
1. 悬浮窗这里采用继承dialog并设置dialog window的类型为TYPE_APPLICATION_OVERLAY(可悬浮于所有应用之上)
private void initWindow(Context context) {
Window window = getWindow();
window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY);
window.setFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
// 设置不响应焦点,点击外部不消失
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
wmParams = window.getAttributes();
wmParams.format = PixelFormat.TRANSLUCENT; // 设置背景透明
wmParams.gravity = Gravity.START | Gravity.TOP; //影响set x y
wmParams.alpha = 1.0f;
wmParams.dimAmount = 0.0f; //dimAmount在0.0f和1.0f之间,0.0f完全不暗,1.0f全暗
DisplayMetrics displayMetrics = new DisplayMetrics();
window.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
int screenWidth = displayMetrics.widthPixels;
int screenHeight = displayMetrics.heightPixels;
Log.i(TAG, String.format("initWindow w:%d, h:%d ", screenWidth, screenHeight));
int viewRootWidth = getContext().getResources().getDimensionPixelSize(R.dimen.dp_315);
int viewRootHeight = getContext().getResources().getDimensionPixelSize(R.dimen.dp_216);
int margin = getContext().getResources().getDimensionPixelSize(R.dimen.dp_30);
Log.i(TAG, String.format("initWindow viewRootWidth:%d, viewRootHeight:%d ", viewRootWidth, viewRootHeight));
// 设置dialog出现的初始位置
wmParams.x = screenWidth - viewRootWidth - margin;
wmParams.y = screenHeight - viewRootHeight - margin;
window.setAttributes(wmParams);
mWindowManager = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
}
2. 悬浮窗的拖拽事件,监听Touch事件拖动的位置不断设置window位置
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getActionMasked();
switch (action) {
case MotionEvent.ACTION_MOVE:
int mMoveWindowX = (int) event.getRawX();
int mMoveWindowY = (int) event.getRawY();
mCurrentPoint.x = mMoveWindowX;
mCurrentPoint.y = mMoveWindowY;
doActionMove(mMoveWindowX, mMoveWindowY);
return true;
case MotionEvent.ACTION_DOWN:
mDownPoint.x = (int) event.getRawX();
mDownPoint.y = (int) event.getRawY();
mCurrentPoint.x = mDownPoint.x;
mCurrentPoint.y = mDownPoint.y;
mDownMills = System.currentTimeMillis();
Log.i(TAG, String.format("ACTION_DOWN: (%d,%d)", mCurrentPoint.x, mCurrentPoint.y));
break;
case MotionEvent.ACTION_UP:
int upX = (int) event.getRawX();
int upY = (int) event.getRawY();
doUpAction(upX, upY);
return true;
}
return super.onTouchEvent(event);
}
/**
* 当参数值包含Gravity.LEFT时,对话框出现在左边,所以params.x就表示相对左边的偏移
* 当参数值包含Gravity.RIGHT时,对话框出现在右边,所以params.x就表示相对右边的偏移
* 当参数值包含Gravity.TOP时,对话框出现在上边,所以params.y就表示相对上边的偏移
* 当参数值包含Gravity.BOTTOM时,对话框出现在下边,所以params.y就表示相对下边的偏移
*/
private synchronized void doActionMove(int mMoveWindowX, int mMoveWindowY) {
if (Math.abs(mDownPoint.x - mCurrentPoint.x) < 5 && Math.abs(mDownPoint.y - mCurrentPoint.y) < 5) {
return;
}
/*更新拖拽view位置 view中心位置移动到触摸点*/
Log.i(TAG, String.format("doActionMove mMoveWindowX:%d, mMoveWindowY:%d", mMoveWindowX, mMoveWindowY));
int x = (int) (mMoveWindowX - mDragViewWidth / 2.0f);
int y = (int) (mMoveWindowY - mDragViewHeight / 2.0f);
wmParams.x = x;
wmParams.y = y;
mWindowManager.updateViewLayout(getWindow().getDecorView().getRootView(), wmParams);
}
3. 悬浮窗只有一个按钮的拖动和点击
将按钮的触摸事件交由dialog处理
mBinding.imgFold.setOnTouchListener(this);
@Override
public boolean onTouch(View v, MotionEvent event) {
if (!foldOpen) {
onTouchEvent(event);
return true;
}
return false;
}
同时在dialog的触摸事件甄别出点击的UP事件触发performClick();
private void doUpAction(int upX, int upY) {
if (Math.abs(mDownPoint.x - mCurrentPoint.x) < 5 && Math.abs(mDownPoint.y - mCurrentPoint.y) < 5) {
Logger.d(TAG, "doUpAction foldOpen: " + foldOpen);
if (!foldOpen) {
mBinding.imgFold.performClick();
}
}
}
4. 预览TextureView圆角实现
int radius = getContext().getResources().getDimensionPixelSize(R.dimen.dp_10);
textureView.setOutlineProvider(new TextureViewOutlineProvider(radius));
textureView.setClipToOutline(true);
public static class TextureViewOutlineProvider extends ViewOutlineProvider {
private int radius;
public TextureViewOutlineProvider(int radius) {
this.radius = radius;
}
@Override
public void getOutline(View view, Outline outline) {
Rect rect = new Rect();
view.getGlobalVisibleRect(rect);
Rect selfRect = new Rect(0, 0,
rect.right - rect.left,
rect.bottom - rect.top);
outline.setRoundRect(selfRect, radius);
}
}
5. 悬浮窗大小变换动画
这里直接对跟布局进行layout变换,对view进行显示隐藏的控制,通过给根布局设置
LayoutTransition过度动画实现平滑过度效果
private void reLayoutByCloseState() {
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mBinding.llRootView.getLayoutParams();
mCameraOpen = false;
// mBinding.imgPreviewEnable.setVisibility(foldOpen ? View.VISIBLE : View.GONE);
Log.i(TAG, "reLayoutByCloseState foldOpen: " + foldOpen);
if (foldOpen) {
// 打开--camera关闭状态
params.width = (int) getContext().getResources().getDimension(R.dimen.dp_315);
params.height = (int) getContext().getResources().getDimension(R.dimen.dp_36);
mBinding.imgPreviewEnable.setVisibility(View.VISIBLE);
mBinding.rlSurfaceRoot.setVisibility(View.VISIBLE);
mBinding.imgFold.setImageResource(R.drawable.icon_close_x);
} else {
// 关闭
params.width = (int) getContext().getResources().getDimension(R.dimen.dp_50);
params.height = (int) getContext().getResources().getDimension(R.dimen.dp_50);
mBinding.imgPreviewEnable.setVisibility(View.GONE);
mBinding.rlSurfaceRoot.setVisibility(View.GONE);
mBinding.imgFold.setImageResource(R.drawable.icon_window_folded);
}
// 设置布局动画
LayoutTransition transition = new LayoutTransition();
mBinding.llRootView.setLayoutTransition(transition);
mBinding.llRootView.setLayoutParams(params);
initRootViewSize();
closeCamera();
}
private void reLayoutByCameraState() {
mBinding.rlSurfaceRoot.setVisibility(mCameraOpen ? View.VISIBLE : View.GONE);
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mBinding.llRootView.getLayoutParams();
Log.i(TAG, "reLayoutByCameraStatem CameraOpen: " + mCameraOpen);
mBinding.imgPreviewEnable.setImageResource(mCameraOpen ? R.drawable.video_open : R.drawable.video_close);
if (mCameraOpen) {
params.width = (int) getContext().getResources().getDimension(R.dimen.dp_315);
params.height = (int) getContext().getResources().getDimension(R.dimen.dp_216);
} else {
params.width = (int) getContext().getResources().getDimension(R.dimen.dp_315);
params.height = (int) getContext().getResources().getDimension(R.dimen.dp_36);
}
LayoutTransition transition = new LayoutTransition();
mBinding.llRootView.setLayoutTransition(transition);
mBinding.llRootView.setLayoutParams(params);
initRootViewSize();
}
xml布局需要给父布局设置 android:animateLayoutChanges="true"属性
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll_rootView"
android:layout_width="@dimen/dp_315"
android:layout_height="@dimen/dp_216"
android:animateLayoutChanges="true"
android:background="@drawable/shape_float_window_bg"
android:gravity="center"
android:orientation="vertical">
<RelativeLayout
android:id="@+id/ll_bts"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_36"
android:animateLayoutChanges="true"
android:gravity="center"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:padding="@dimen/dp_8"
android:id="@+id/img_preview_enable"
android:layout_width="@dimen/dp_36"
android:layout_height="@dimen/dp_36"
android:src="@drawable/video_open" />
<ImageView
android:id="@+id/img_fold"
android:padding="@dimen/dp_8"
android:layout_width="@dimen/dp_36"
android:layout_height="@dimen/dp_36"
android:layout_alignParentEnd="true"
android:src="@drawable/icon_close_x" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/rl_surfaceRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/shape_float_surface_bg">
<TextureView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="1dp"
android:layerType="hardware" />
</RelativeLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
</FrameLayout>
完结!