authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 23:13:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:13:32-07:00
log57b3780770a829ff81e62d8dc658683bf51d5155
treef6d390bdd1fd5460261bc6a9a4a918e6ea04231d
parenta7223f3969428170cc13bd7859aa59098b2f94b1
signature Signed by PGP key B5690EEEBB952194

fix(mobile): browser picture in picture opens from the header button (#17731)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

1 files changed, 49 insertions(+), 31 deletions(-)

apps/mobile/src/features/browser/preview-stream.browser.ts+49-31
...@@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG =...@@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG =
50interface Viewer {50interface Viewer {
51 readonly stop: () => void;51 readonly stop: () => void;
52 readonly command: (input: PreviewStreamInput) => void;52 readonly command: (input: PreviewStreamInput) => void;
53 readonly togglePictureInPicture: () => Promise<void>;53 readonly togglePictureInPicture: () => void;
54}54}
5555
56let activeViewer: Viewer | null = null;56let activeViewer: Viewer | null = null;
...@@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) {...@@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) {
66}66}
6767
68export function pictureInPicture() {68export function pictureInPicture() {
69 void activeViewer?.togglePictureInPicture();69 activeViewer?.togglePictureInPicture();
70}70}
7171
72export function start(configuration: PreviewStreamConfiguration) {72export function start(configuration: PreviewStreamConfiguration) {
...@@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) {...@@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) {
147 };147 };
148 const painter = createPreviewFramePainter(canvas, () => {148 const painter = createPreviewFramePainter(canvas, () => {
149 if (!streaming && !stopped) reportStatus("streaming");149 if (!streaming && !stopped) reportStatus("streaming");
150 sendPictureInPictureFrame();
150 });151 });
151 const send = (message: PreviewStreamInput) => client?.send(message) ?? false;152 const send = (message: PreviewStreamInput) => client?.send(message) ?? false;
152 const clearInput = () => {153 const clearInput = () => {
...@@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) {...@@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) {
620 const pictureInPictureSupported =621 const pictureInPictureSupported =
621 interactive &&622 interactive &&
622 typeof canvas.captureStream === "function" &&623 typeof canvas.captureStream === "function" &&
624 typeof CanvasCaptureMediaStreamTrack === "function" &&
623 ((document.pictureInPictureEnabled === true &&625 ((document.pictureInPictureEnabled === true &&
624 typeof videoPrototype.requestPictureInPicture === "function") ||626 typeof videoPrototype.requestPictureInPicture === "function") ||
625 typeof videoPrototype.webkitSetPresentationMode === "function");627 typeof videoPrototype.webkitSetPresentationMode === "function");
628 // Since the iOS 17 SDK, WebKit revokes the activation it grants an injected script when
629 // the script returns, so the native button's request must start before any await. The
630 // video is ready ahead of the tap: paused, and fed a frame only when it has none or
631 // floats, so the inline view captures nothing.
626 let video: PresentationVideo | null = null;632 let video: PresentationVideo | null = null;
633 let track: CanvasCaptureMediaStreamTrack | null = null;
627 const pictureInPictureActive = () =>634 const pictureInPictureActive = () =>
628 video !== null &&635 video !== null &&
629 (document.pictureInPictureElement === video ||636 (document.pictureInPictureElement === video ||
...@@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) {...@@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) {
635 active: pictureInPictureActive(),642 active: pictureInPictureActive(),
636 ...(detail ? { detail } : {}),643 ...(detail ? { detail } : {}),
637 });644 });
638 const ensureVideo = () => {645 // Called after each paint. A frame drawn before the player attached is lost, so retry.
639 if (video) return video;646 const sendPictureInPictureFrame = () => {
647 if (video && (video.readyState === HTMLMediaElement.HAVE_NOTHING || pictureInPictureActive())) {
648 track?.requestFrame();
649 }
650 };
651 if (pictureInPictureSupported) {
652 const stream = canvas.captureStream(0);
653 const [captured] = stream.getVideoTracks();
640 const element: PresentationVideo = document.createElement("video");654 const element: PresentationVideo = document.createElement("video");
641 element.muted = true;655 element.muted = true;
642 element.playsInline = true;656 element.playsInline = true;
643 element.autoplay = true;
644 // Under the canvas at full size: WebKit pauses muted video it considers off screen.657 // Under the canvas at full size: WebKit pauses muted video it considers off screen.
645 element.srcObject = canvas.captureStream();658 element.srcObject = stream;
646 // A static page sends no new frames; repaint once so the stream has one.
647 if (canvas.width > 0 && canvas.height > 0) canvas.getContext("2d")?.drawImage(canvas, 0, 0);
648 for (const name of [659 for (const name of [
649 "enterpictureinpicture",660 "enterpictureinpicture",
650 "leavepictureinpicture",661 "leavepictureinpicture",
...@@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) {...@@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) {
656 });667 });
657 }668 }
658 container.prepend(element);669 container.prepend(element);
670 // Covers the letterbox, where a stale frame of another shape would show through.
671 canvas.style.background = configuration.background;
659 video = element;672 video = element;
660 return element;673 track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null;
661 };674 }
662 const togglePictureInPicture = async () => {675 const togglePictureInPicture = () => {
663 if (!pictureInPictureSupported) return;676 const element = video;
664 try {677 if (!element) return;
665 if (pictureInPictureActive()) {678 if (pictureInPictureActive()) {
666 if (document.pictureInPictureElement) await document.exitPictureInPicture();679 if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {});
667 else video?.webkitSetPresentationMode?.("inline");680 else element.webkitSetPresentationMode?.("inline");
668 return;681 return;
669 }682 }
670 const element = ensureVideo();683 if (element.readyState === HTMLMediaElement.HAVE_NOTHING) {
671 await element.play();684 reportPictureInPicture("The page has not shown yet. Try again in a moment.");
672 if (document.pictureInPictureEnabled) await element.requestPictureInPicture();685 return;
673 else element.webkitSetPresentationMode?.("picture-in-picture");686 }
674 } catch (error) {687 // The page may have changed since the video's last frame. WebKit captures only on a draw.
688 track?.requestFrame();
689 canvas.getContext("2d")?.drawImage(canvas, 0, 0);
690 const fail = (error: unknown) => {
691 element.pause();
675 reportPictureInPicture(692 reportPictureInPicture(
676 error instanceof Error ? error.message : "Picture in picture is unavailable.",693 error instanceof Error ? error.message : "Picture in picture is unavailable.",
677 );694 );
695 };
696 if (document.pictureInPictureEnabled) {
697 const entering = element.requestPictureInPicture();
698 void Promise.all([entering, element.play()]).catch(fail);
699 } else {
700 element.webkitSetPresentationMode?.("picture-in-picture");
701 void element.play().catch(fail);
678 }702 }
679 };703 };
680704
...@@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) {...@@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) {
691 painter.stop();715 painter.stop();
692 client?.stop();716 client?.stop();
693 client = null;717 client = null;
694 if (video) {718 track?.stop();
695 for (const track of video.srcObject instanceof MediaStream719 if (video) video.srcObject = null;
696 ? video.srcObject.getTracks()
697 : []) {
698 track.stop();
699 }
700 video.srcObject = null;
701 }
702 },720 },
703 command: (message) => {721 command: (message) => {
704 send(message);722 send(message);