diff --git a/CHANGELOG.md b/CHANGELOG.md index 566fc8355..6f856883d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [55.3.0] +- [PictureInPicture] Added `PipService` with `Enter()`, `Enter(ratioWidth, ratioHeight)`, `IsSupported`, and `PipModeChanged` event for Android and iOS. On iOS 15+, uses `AVPictureInPictureController` with `AVSampleBufferDisplayLayer` to display a snapshot of the current window. On Android, uses `EnterPictureInPictureMode`. Includes a sample in the Components app demonstrating usage. + ## [55.2.2] - [iOS26][Tip] Added more padding. diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml new file mode 100644 index 000000000..646b3d651 --- /dev/null +++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs new file mode 100644 index 000000000..22abe9cb0 --- /dev/null +++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamples.xaml.cs @@ -0,0 +1,18 @@ +namespace Components.ComponentsSamples.PictureInPicture; + +public partial class PictureInPictureSamples +{ + public PictureInPictureSamples() + { + InitializeComponent(); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + if (BindingContext is PictureInPictureSamplesViewModel vm) + { + vm.Unsubscribe(); + } + } +} diff --git a/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs new file mode 100644 index 000000000..48c7888ab --- /dev/null +++ b/src/app/Components/ComponentsSamples/PictureInPicture/PictureInPictureSamplesViewModel.cs @@ -0,0 +1,56 @@ +using System.Windows.Input; +using DIPS.Mobile.UI.API.PictureInPicture; +using DIPS.Mobile.UI.MVVM; + +namespace Components.ComponentsSamples.PictureInPicture; + +public class PictureInPictureSamplesViewModel : ViewModel +{ + private string m_notes = string.Empty; + private string m_statusText = string.Empty; + + public PictureInPictureSamplesViewModel() + { + EnterPipCommand = new Command(EnterPip); + PipService.PipModeChanged += OnPipModeChanged; + } + + private void EnterPip() + { + PipService.Enter(); + } + + private void OnPipModeChanged(object? sender, bool isInPipMode) + { + StatusText = isInPipMode + ? "App is in Picture in Picture mode." + : "App returned from Picture in Picture mode."; + } + + public void Unsubscribe() + { + PipService.PipModeChanged -= OnPipModeChanged; + } + + public bool IsPipSupported => PipService.IsSupported; + + public string Notes + { + get => m_notes; + set => RaiseWhenSet(ref m_notes, value); + } + + public string StatusText + { + get => m_statusText; + set + { + RaiseWhenSet(ref m_statusText, value); + RaisePropertyChanged(nameof(HasStatus)); + } + } + + public bool HasStatus => !string.IsNullOrEmpty(StatusText); + + public ICommand EnterPipCommand { get; } +} diff --git a/src/app/Components/Platforms/Android/MainActivity.cs b/src/app/Components/Platforms/Android/MainActivity.cs index 3e16d5b75..0852acb87 100644 --- a/src/app/Components/Platforms/Android/MainActivity.cs +++ b/src/app/Components/Platforms/Android/MainActivity.cs @@ -1,13 +1,23 @@ using Android.App; +using Android.Content; using Android.Content.PM; using Android.OS; using AndroidX.Activity; using AndroidX.Core.View; +using DIPS.Mobile.UI.API.PictureInPicture; namespace Components; [Activity(Theme = "@style/DIPS.Mobile.UI.Style", MainLauncher = true, ScreenOrientation = ScreenOrientation.Portrait, + SupportsPictureInPicture = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] -public class MainActivity : MauiAppCompatActivity; \ No newline at end of file +public class MainActivity : MauiAppCompatActivity +{ + public override void OnPictureInPictureModeChanged(bool isInPictureInPictureMode, Configuration newConfig) + { + base.OnPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); + PipService.NotifyPipModeChanged(isInPictureInPictureMode); + } +} \ No newline at end of file diff --git a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs index 0a0ec44ee..d14c70507 100644 --- a/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs +++ b/src/app/Components/REGISTER_YOUR_SAMPLES_HERE.cs @@ -1,6 +1,7 @@ using Components.AccessibilitySamples; using Components.AccessibilitySamples.VoiceOverSamples; using Components.ComponentsSamples.Alerting; +using Components.ComponentsSamples.PictureInPicture; using Components.ComponentsSamples.AmplitudeView; using Components.ComponentsSamples.BarcodeScanning; using Components.ComponentsSamples.BottomSheets; @@ -73,6 +74,7 @@ public static List RegisterSamples() new(SampleType.Components, "Zoom Container", () => new PanZoomContainerSample()), new(SampleType.Components, "Gallery", () => new GallerySample()), new(SampleType.Components, "TIFF Viewer", () => new TiffViewerSample()), + new(SampleType.Components, "Picture in Picture", () => new PictureInPictureSamples()), new(SampleType.Accessibility, "VoiceOver/TalkBack", () => new VoiceOverSamples()), diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs new file mode 100644 index 000000000..cb143a52c --- /dev/null +++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/Android/PipService.cs @@ -0,0 +1,43 @@ +using Android.Content.PM; +using Android.OS; +using Android.Util; + +namespace DIPS.Mobile.UI.API.PictureInPicture; + +public static partial class PipService +{ + public static partial bool IsSupported + { + get + { + if (Build.VERSION.SdkInt < BuildVersionCodes.O) + return false; + + return Platform.AppContext.PackageManager + ?.HasSystemFeature(PackageManager.FeaturePictureInPicture) == true; + } + } + + public static partial void Enter() => Enter(9, 16); + + public static partial void Enter(int ratioWidth, int ratioHeight) + { + if (!IsSupported) + return; + + var activity = Platform.CurrentActivity; + if (activity is null) + return; + + var paramsBuilder = new Android.App.PictureInPictureParams.Builder(); + + if (Build.VERSION.SdkInt >= BuildVersionCodes.S) + { + paramsBuilder.SetSeamlessResizeEnabled(true); + } + + paramsBuilder.SetAspectRatio(new Rational(ratioWidth, ratioHeight)); + + activity.EnterPictureInPictureMode(paramsBuilder.Build()); + } +} diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs new file mode 100644 index 000000000..bec226824 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/PipService.cs @@ -0,0 +1,57 @@ +namespace DIPS.Mobile.UI.API.PictureInPicture; + +/// +/// Service for entering Picture in Picture (PiP) mode, allowing the app to be displayed +/// in a small floating window while the user interacts with other apps or navigates away. +/// +public static partial class PipService +{ + /// + /// Gets a value indicating whether Picture in Picture mode is supported on this device. + /// + public static partial bool IsSupported { get; } + + /// + /// Enters Picture in Picture mode. The app will be displayed in a small floating window + /// with a default 9:16 (portrait) aspect ratio. + /// + /// + /// On Android, the activity must have android:supportsPictureInPicture="true" in the manifest, + /// or SupportsPictureInPicture = true in the [Activity] attribute. + /// On iOS 15+, this captures a snapshot of the current window and displays it in a PiP window using + /// AVPictureInPictureController with AVSampleBufferDisplayLayer. + /// + public static partial void Enter(); + + /// + /// Enters Picture in Picture mode with a specific aspect ratio for the PiP window. + /// + /// The width component of the desired aspect ratio. + /// The height component of the desired aspect ratio. + /// + /// On Android, the activity must have android:supportsPictureInPicture="true" in the manifest, + /// or SupportsPictureInPicture = true in the [Activity] attribute. + /// On iOS 15+, this captures a snapshot of the current window and displays it in a PiP window using + /// AVPictureInPictureController with AVSampleBufferDisplayLayer. + /// + public static partial void Enter(int ratioWidth, int ratioHeight); + + /// + /// Raised when the app enters or exits Picture in Picture mode. + /// + /// + /// On Android, override OnPictureInPictureModeChanged in your MainActivity and call + /// for this event to fire. + /// + public static event EventHandler? PipModeChanged; + + /// + /// Notifies the service that the PiP mode has changed. + /// Call this from your MainActivity.OnPictureInPictureModeChanged override on Android. + /// + /// true if the app entered PiP mode; false if it exited. + public static void NotifyPipModeChanged(bool isInPipMode) + { + PipModeChanged?.Invoke(null, isInPipMode); + } +} diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs new file mode 100644 index 000000000..e75124df5 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/dotnet/PipService.cs @@ -0,0 +1,12 @@ +using DIPS.Mobile.UI.Exceptions; + +namespace DIPS.Mobile.UI.API.PictureInPicture; + +public static partial class PipService +{ + public static partial bool IsSupported => throw new Only_Here_For_UnitTests(); + + public static partial void Enter() => throw new Only_Here_For_UnitTests(); + + public static partial void Enter(int ratioWidth, int ratioHeight) => throw new Only_Here_For_UnitTests(); +} diff --git a/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs b/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs new file mode 100644 index 000000000..bfb77ba83 --- /dev/null +++ b/src/library/DIPS.Mobile.UI/API/PictureInPicture/iOS/PipService.cs @@ -0,0 +1,218 @@ +using AVFoundation; +using AVKit; +using CoreGraphics; +using CoreMedia; +using CoreVideo; +using Foundation; +using UIKit; + +namespace DIPS.Mobile.UI.API.PictureInPicture; + +public static partial class PipService +{ + private static AVPictureInPictureController? s_pipController; + private static PipSampleBufferView? s_pipView; + private static PipPlaybackDelegate? s_playbackDelegate; + private static PipControllerDelegate? s_controllerDelegate; + + private static readonly CGColorSpace s_rgbColorSpace = CGColorSpace.CreateDeviceRGB(); + + public static partial bool IsSupported => + AVPictureInPictureController.IsPictureInPictureSupported(); + + public static partial void Enter() => Enter(9, 16); + + public static partial void Enter(int ratioWidth, int ratioHeight) + { + if (!IsSupported) + return; + + var window = GetKeyWindow(); + if (window is null) + return; + + var windowWidth = window.Bounds.Width; + var windowHeight = windowWidth * ratioHeight / ratioWidth; + var frameSize = new CGSize(windowWidth, windowHeight); + + s_pipView?.Dispose(); + s_pipView = new PipSampleBufferView(frameSize); + + s_playbackDelegate?.Dispose(); + s_playbackDelegate = new PipPlaybackDelegate(window, frameSize, s_pipView.SampleBufferDisplayLayer); + + s_controllerDelegate?.Dispose(); + s_controllerDelegate = new PipControllerDelegate(); + + var contentSource = new AVPictureInPictureControllerContentSource( + s_pipView.SampleBufferDisplayLayer, + s_playbackDelegate); + + s_pipController?.Dispose(); + s_pipController = new AVPictureInPictureController(contentSource) + { + Delegate = s_controllerDelegate + }; + + s_playbackDelegate.PushCurrentFrame(); + + if (s_pipController.PictureInPicturePossible) + { + s_pipController.StartPictureInPicture(); + } + } + + private static UIWindow? GetKeyWindow() + { + foreach (var scene in UIApplication.SharedApplication.ConnectedScenes) + { + if (scene is UIWindowScene windowScene && + windowScene.ActivationState == UISceneActivationState.ForegroundActive) + { + return windowScene.Windows.FirstOrDefault(w => w.IsKeyWindow); + } + } + return null; + } + + private sealed class PipSampleBufferView : UIView + { + public AVSampleBufferDisplayLayer SampleBufferDisplayLayer { get; } + + public PipSampleBufferView(CGSize size) : base(new CGRect(CGPoint.Empty, size)) + { + SampleBufferDisplayLayer = new AVSampleBufferDisplayLayer(); + SampleBufferDisplayLayer.Frame = Bounds; + Layer.AddSublayer(SampleBufferDisplayLayer); + } + } + + [Register("PipPlaybackDelegate")] + private sealed class PipPlaybackDelegate : NSObject, IAVPictureInPictureSampleBufferPlaybackDelegate + { + private readonly UIView m_sourceView; + private readonly CGSize m_frameSize; + private readonly AVSampleBufferDisplayLayer m_displayLayer; + + public PipPlaybackDelegate(UIView sourceView, CGSize frameSize, AVSampleBufferDisplayLayer displayLayer) + { + m_sourceView = sourceView; + m_frameSize = frameSize; + m_displayLayer = displayLayer; + } + + public void PushCurrentFrame() + { + var sampleBuffer = CreateSampleBuffer(); + if (sampleBuffer is not null) + { + m_displayLayer.Enqueue(sampleBuffer); + sampleBuffer.Dispose(); + } + } + + /// + /// Static snapshot display — play/pause controls are not applicable. + /// + [Export("pictureInPictureController:setPlaying:")] + public void SetPlaying(AVPictureInPictureController pictureInPictureController, bool playing) { } + + /// + /// Static snapshot display is always "paused" from PiP's perspective. + /// + [Export("pictureInPictureControllerIsPlaybackPaused:")] + public bool IsPlaybackPaused(AVPictureInPictureController pictureInPictureController) => true; + + /// + /// Static snapshot display has no seekable time range — skip is a no-op. + /// + [Export("pictureInPictureController:skipByInterval:completionHandler:")] + public void SkipByInterval(AVPictureInPictureController pictureInPictureController, CMTime skipInterval, + Action completionHandler) + { + completionHandler(); + } + + /// + /// Returns an indefinite time range so the PiP controller does not show a playback position. + /// + [Export("pictureInPictureControllerTimeRangeForPlayback:")] + public CMTimeRange GetTimeRange(AVPictureInPictureController pictureInPictureController) => + new CMTimeRange { Start = CMTime.Zero, Duration = CMTime.Indefinite }; + + [Export("pictureInPictureController:didTransitionToRenderSize:")] + public void DidTransitionToRenderSize(AVPictureInPictureController pictureInPictureController, + CMVideoDimensions newRenderSize) { } + + private CMSampleBuffer? CreateSampleBuffer() + { + var width = (nint)m_frameSize.Width; + var height = (nint)m_frameSize.Height; + + if (width <= 0 || height <= 0) + return null; + + var pixelBufferAttrs = new CVPixelBufferAttributes + { + CGImageCompatibility = true, + CGBitmapContextCompatibility = true + }; + + var pixelBuffer = new CVPixelBuffer(width, height, CVPixelFormatType.CV32BGRA, pixelBufferAttrs); + pixelBuffer.Lock(CVOptionFlags.None); + + try + { + using var bitmapContext = new CGBitmapContext( + pixelBuffer.BaseAddress, + width, height, + 8, + pixelBuffer.BytesPerRow, + s_rgbColorSpace, + CGBitmapFlags.ByteOrder32Little | CGBitmapFlags.NoneSkipFirst); + + bitmapContext.TranslateCTM(0, height); + bitmapContext.ScaleCTM(1, -1); + m_sourceView.Layer.RenderInContext(bitmapContext); + } + finally + { + pixelBuffer.Unlock(CVOptionFlags.None); + } + + var formatDescription = CMVideoFormatDescription.CreateForImageBuffer(pixelBuffer); + if (formatDescription is null) + { + pixelBuffer.Dispose(); + return null; + } + + var timing = new CMSampleTimingInfo + { + Duration = CMTime.Invalid, + PresentationTimeStamp = CMTime.Zero, + DecodeTimeStamp = CMTime.Invalid + }; + + CMSampleBuffer.CreateReadyWithImageBuffer(pixelBuffer, formatDescription, timing, out var sampleBuffer); + formatDescription.Dispose(); + pixelBuffer.Dispose(); + + return sampleBuffer; + } + } + + [Register("PipControllerDelegate")] + private sealed class PipControllerDelegate : AVPictureInPictureControllerDelegate + { + public override void DidStartPictureInPicture(AVPictureInPictureController pictureInPictureController) + { + NotifyPipModeChanged(true); + } + + public override void DidStopPictureInPicture(AVPictureInPictureController pictureInPictureController) + { + NotifyPipModeChanged(false); + } + } +}