1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:57:44 +03:00

snap, PiP

This commit is contained in:
Anton Afanasyeu
2026-05-18 17:35:14 +02:00
parent bbf64d30b9
commit f3bcc31e92
8 changed files with 303 additions and 5 deletions

View File

@@ -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"

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>