mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:39:15 +03:00
snap, PiP
This commit is contained in:
@@ -67,8 +67,10 @@
|
||||
android:name=".receiver.ReceiverPlaybackActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:theme="@style/Theme.AndroidCast.Fullscreen"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
||||
|
||||
<service
|
||||
android:name=".sender.ScreenCastService"
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.List;
|
||||
/** Requests and explains runtime permissions for LAN cast. */
|
||||
public final class PermissionHelper {
|
||||
public static final int REQUEST_CODE = 1001;
|
||||
public static final int REQUEST_RECEIVER_PLAYBACK = 1002;
|
||||
|
||||
public static final class PermissionItem {
|
||||
public final String permission;
|
||||
@@ -115,9 +116,47 @@ public final class PermissionHelper {
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static void openAppSettings(Activity activity) {
|
||||
public static void openAppSettings(Activity activity) {
|
||||
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
intent.setData(Uri.fromParts("package", activity.getPackageName(), null));
|
||||
activity.startActivity(intent);
|
||||
}
|
||||
|
||||
/** Permissions needed before receiver cast / PiP (notifications for foreground service). */
|
||||
public static boolean hasReceiverPlaybackReady(Activity activity) {
|
||||
return missingForReceiverPlayback(activity).isEmpty();
|
||||
}
|
||||
|
||||
public static void remindForReceiverPlayback(Activity activity) {
|
||||
List<PermissionItem> missing = missingForReceiverPlayback(activity);
|
||||
if (missing.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
StringBuilder body = new StringBuilder(activity.getString(R.string.pip_permissions_hint));
|
||||
body.append("\n\n");
|
||||
for (PermissionItem item : missing) {
|
||||
body.append("• ").append(item.label).append('\n');
|
||||
}
|
||||
new AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.permissions_missing_title)
|
||||
.setMessage(body.toString().trim())
|
||||
.setPositiveButton(R.string.permissions_grant, (d, w) ->
|
||||
ActivityCompat.requestPermissions(activity,
|
||||
permissionsOnly(missing), REQUEST_RECEIVER_PLAYBACK))
|
||||
.setNeutralButton(R.string.permissions_app_settings, (d, w) -> openAppSettings(activity))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private static List<PermissionItem> missingForReceiverPlayback(Activity activity) {
|
||||
List<PermissionItem> needed = new ArrayList<>();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS,
|
||||
activity.getString(R.string.permission_notifications_cast), true));
|
||||
}
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.foxx.androidcast;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AppOpsManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Rational;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import android.app.PictureInPictureParams;
|
||||
|
||||
/**
|
||||
* Picture-in-picture for receiver playback (API 26+). No runtime permission; user can disable PiP per app.
|
||||
*/
|
||||
public final class PictureInPictureHelper {
|
||||
private PictureInPictureHelper() {}
|
||||
|
||||
public static boolean isSupported() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
|
||||
}
|
||||
|
||||
/** Whether the app is allowed to enter PiP (system / user app-ops). */
|
||||
public static boolean isAllowedForApp(@NonNull Context context) {
|
||||
if (!isSupported()) {
|
||||
return false;
|
||||
}
|
||||
PackageManager pm = context.getPackageManager();
|
||||
if (!pm.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
|
||||
return false;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
|
||||
if (appOps == null) {
|
||||
return true;
|
||||
}
|
||||
int mode = appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
|
||||
android.os.Process.myUid(), context.getPackageName());
|
||||
return mode == AppOpsManager.MODE_ALLOWED;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static PictureInPictureParams buildParams(int videoWidth, int videoHeight) {
|
||||
PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(videoWidth, videoHeight));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(true);
|
||||
builder.setSeamlessResizeEnabled(true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static void applyParams(@NonNull Activity activity, int videoWidth, int videoHeight) {
|
||||
if (!isSupported()) {
|
||||
return;
|
||||
}
|
||||
activity.setPictureInPictureParams(buildParams(videoWidth, videoHeight));
|
||||
}
|
||||
|
||||
public static boolean enter(@NonNull Activity activity, int videoWidth, int videoHeight) {
|
||||
if (!isSupported() || !isAllowedForApp(activity)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return activity.enterPictureInPictureMode(buildParams(videoWidth, videoHeight));
|
||||
} catch (IllegalStateException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void showEnableInSettingsDialog(@NonNull Activity activity) {
|
||||
new AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.pip_disabled_title)
|
||||
.setMessage(R.string.pip_disabled_message)
|
||||
.setPositiveButton(R.string.permissions_app_settings, (d, w) -> openPipSettings(activity))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
public static void openPipSettings(@NonNull Activity activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent intent = new Intent("android.settings.APP_PICTURE_IN_PICTURE_SETTINGS",
|
||||
Uri.fromParts("package", activity.getPackageName(), null));
|
||||
activity.startActivity(intent);
|
||||
} else {
|
||||
PermissionHelper.openAppSettings(activity);
|
||||
}
|
||||
}
|
||||
|
||||
private static Rational aspectRatio(int width, int height) {
|
||||
if (width > 0 && height > 0) {
|
||||
return new Rational(width, height);
|
||||
}
|
||||
return new Rational(16, 9);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.RemoteException;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.Toast;
|
||||
import android.graphics.SurfaceTexture;
|
||||
import android.view.Surface;
|
||||
import android.view.TextureView;
|
||||
@@ -26,6 +28,8 @@ import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.DrawerHostActivity;
|
||||
import com.foxx.androidcast.CastThemeHelper;
|
||||
import com.foxx.androidcast.PermissionHelper;
|
||||
import com.foxx.androidcast.PictureInPictureHelper;
|
||||
import com.foxx.androidcast.ICastReceiverService;
|
||||
import com.foxx.androidcast.ICastStatusCallback;
|
||||
import com.foxx.androidcast.R;
|
||||
@@ -53,7 +57,11 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
private TextView diagnosticsText;
|
||||
private ImageView robotView;
|
||||
private TextView awaitingMessage;
|
||||
private ImageButton pipButton;
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
/** Sender connected or actively streaming — PiP allowed after permissions. */
|
||||
private boolean castSessionActive;
|
||||
private boolean inPictureInPicture;
|
||||
private SurfaceTexture surfaceTexture;
|
||||
private int videoWidth;
|
||||
private int videoHeight;
|
||||
@@ -70,7 +78,10 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
|
||||
@Override
|
||||
public void onAuthenticated(String senderName) {
|
||||
runOnUiThread(() -> showAwaitingOverlay(R.string.playback_awaiting_stream));
|
||||
runOnUiThread(() -> {
|
||||
onCastSessionStarted();
|
||||
showAwaitingOverlay(R.string.playback_awaiting_stream);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -104,7 +115,10 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
|
||||
@Override
|
||||
public void onDisconnected() {
|
||||
runOnUiThread(() -> onStreamLost());
|
||||
runOnUiThread(() -> {
|
||||
onCastSessionEnded();
|
||||
onStreamLost();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,8 +178,13 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
diagnosticsText = findViewById(R.id.text_diagnostics);
|
||||
robotView = findViewById(R.id.image_robot);
|
||||
awaitingMessage = findViewById(R.id.text_awaiting_message);
|
||||
pipButton = findViewById(R.id.button_pip);
|
||||
if (pipButton != null) {
|
||||
pipButton.setOnClickListener(v -> tryEnterPictureInPicture());
|
||||
}
|
||||
textureView.setSurfaceTextureListener(surfaceListener);
|
||||
applyAspectRatio();
|
||||
updatePipUi();
|
||||
|
||||
if (getIntent().getBooleanExtra(EXTRA_AUTO_START_LISTENING, false)) {
|
||||
startListeningService();
|
||||
@@ -214,11 +233,38 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
if (!inPictureInPicture) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
}
|
||||
if (awaitingOverlay != null && awaitingOverlay.getVisibility() == View.VISIBLE) {
|
||||
startRobotAnimation();
|
||||
}
|
||||
mainHandler.post(diagnosticsRefresh);
|
||||
updateDiagnosticsVisibility();
|
||||
updatePipUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUserLeaveHint() {
|
||||
super.onUserLeaveHint();
|
||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
tryEnterPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode);
|
||||
inPictureInPicture = isInPictureInPictureMode;
|
||||
applyPipChromeVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == PermissionHelper.REQUEST_RECEIVER_PLAYBACK) {
|
||||
updatePipUi();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -357,6 +403,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
}
|
||||
videoWidth = width;
|
||||
videoHeight = height;
|
||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
PictureInPictureHelper.applyParams(this, videoWidth, videoHeight);
|
||||
}
|
||||
applyAspectRatio();
|
||||
applyTextureFitCenter();
|
||||
if (surfaceTexture != null) {
|
||||
@@ -453,8 +502,81 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) {
|
||||
if (hasFocus && !inPictureInPicture) {
|
||||
applyFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
private void onCastSessionStarted() {
|
||||
castSessionActive = true;
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
updatePipUi();
|
||||
}
|
||||
|
||||
private void onCastSessionEnded() {
|
||||
castSessionActive = false;
|
||||
updatePipUi();
|
||||
}
|
||||
|
||||
private void updatePipUi() {
|
||||
if (pipButton == null) {
|
||||
return;
|
||||
}
|
||||
boolean show = castSessionActive && PictureInPictureHelper.isSupported()
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
if (show) {
|
||||
pipButton.bringToFront();
|
||||
}
|
||||
}
|
||||
|
||||
private void tryEnterPictureInPicture() {
|
||||
if (!castSessionActive) {
|
||||
return;
|
||||
}
|
||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.isSupported()) {
|
||||
Toast.makeText(this, R.string.pip_unavailable, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.isAllowedForApp(this)) {
|
||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||
return;
|
||||
}
|
||||
if (!PictureInPictureHelper.enter(this, videoWidth, videoHeight)) {
|
||||
PictureInPictureHelper.showEnableInSettingsDialog(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPipChromeVisibility() {
|
||||
View handle = findViewById(R.id.settings_drawer_handle);
|
||||
if (handle != null) {
|
||||
handle.setVisibility(inPictureInPicture ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
if (pipButton != null) {
|
||||
pipButton.setVisibility(inPictureInPicture || !castSessionActive ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
if (inPictureInPicture) {
|
||||
if (diagnosticsText != null) {
|
||||
diagnosticsText.setVisibility(View.GONE);
|
||||
}
|
||||
if (awaitingOverlay != null && streamRendering) {
|
||||
awaitingOverlay.setVisibility(View.GONE);
|
||||
}
|
||||
stopRobotAnimation();
|
||||
} else {
|
||||
updateDiagnosticsVisibility();
|
||||
if (!streamRendering && awaitingOverlay != null
|
||||
&& awaitingOverlay.getVisibility() == View.VISIBLE) {
|
||||
startRobotAnimation();
|
||||
}
|
||||
if (castSessionActive) {
|
||||
applyFullscreen();
|
||||
}
|
||||
updatePipUi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
app/src/main/res/drawable/ic_picture_in_picture.xml
Normal file
10
app/src/main/res/drawable/ic_picture_in_picture.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M19,7h-8v6h8V7zM21,3H3C1.9,3 1,3.9 1,5v14c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2V5C23,3.9 22.1,3 21,3zM21,19H3V5h18V19z" />
|
||||
</vector>
|
||||
@@ -34,4 +34,16 @@
|
||||
layout="@layout/overlay_cast_diagnostics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_pip"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="16dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/pip_enter"
|
||||
android:padding="8dp"
|
||||
android:src="@drawable/ic_picture_in_picture"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
@@ -195,4 +195,10 @@
|
||||
<string name="codec_speex">Speex</string>
|
||||
<string name="codec_opus">Opus</string>
|
||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||
<string name="pip_enter">Картинка в картинке</string>
|
||||
<string name="pip_unavailable">Режим «картинка в картинке» недоступен на этом устройстве</string>
|
||||
<string name="pip_disabled_title">Картинка в картинке отключена</string>
|
||||
<string name="pip_disabled_message">Разрешите картинку в картинке для Android Cast в настройках системы.</string>
|
||||
<string name="pip_permissions_hint">Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне.</string>
|
||||
<string name="permission_notifications_cast">Уведомления (статус трансляции)</string>
|
||||
</resources>
|
||||
|
||||
@@ -195,4 +195,10 @@
|
||||
<string name="codec_speex">Speex</string>
|
||||
<string name="codec_opus">Opus</string>
|
||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||
<string name="pip_enter">Picture-in-picture</string>
|
||||
<string name="pip_unavailable">Picture-in-picture is not available on this device</string>
|
||||
<string name="pip_disabled_title">Picture-in-picture disabled</string>
|
||||
<string name="pip_disabled_message">Allow picture-in-picture for Android Cast in system settings, then try again.</string>
|
||||
<string name="pip_permissions_hint">Notifications keep the receiver visible while casting in the background.</string>
|
||||
<string name="permission_notifications_cast">Notifications (cast status)</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user