diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ae0acec..1b5c90c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" /> 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 missingForReceiverPlayback(Activity activity) { + List 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; + } } diff --git a/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java b/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java new file mode 100644 index 0000000..3c32848 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/PictureInPictureHelper.java @@ -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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index 16942f6..039f4a9 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -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(); + } + } } diff --git a/app/src/main/res/drawable/ic_picture_in_picture.xml b/app/src/main/res/drawable/ic_picture_in_picture.xml new file mode 100644 index 0000000..3720eaa --- /dev/null +++ b/app/src/main/res/drawable/ic_picture_in_picture.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_playback.xml b/app/src/main/res/layout/activity_playback.xml index 38ce9eb..a178c4c 100644 --- a/app/src/main/res/layout/activity_playback.xml +++ b/app/src/main/res/layout/activity_playback.xml @@ -34,4 +34,16 @@ layout="@layout/overlay_cast_diagnostics" android:layout_width="match_parent" android:layout_height="match_parent" /> + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a67242a..eedc869 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -195,4 +195,10 @@ Speex Opus AUTO (AAC) + Картинка в картинке + Режим «картинка в картинке» недоступен на этом устройстве + Картинка в картинке отключена + Разрешите картинку в картинке для Android Cast в настройках системы. + Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне. + Уведомления (статус трансляции) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ca83d9..4ab4aec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,4 +195,10 @@ Speex Opus AUTO (AAC) + Picture-in-picture + Picture-in-picture is not available on this device + Picture-in-picture disabled + Allow picture-in-picture for Android Cast in system settings, then try again. + Notifications keep the receiver visible while casting in the background. + Notifications (cast status)