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 =
5050interface Viewer {
5151 readonly stop: () => void;
5252 readonly command: (input: PreviewStreamInput) => void;
53 readonly togglePictureInPicture: () => Promise<void>;
53 readonly togglePictureInPicture: () => void;
5454}
5555
5656let activeViewer: Viewer | null = null;
......@@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) {
6666}
6767
6868export function pictureInPicture() {
69 void activeViewer?.togglePictureInPicture();
69 activeViewer?.togglePictureInPicture();
7070}
7171
7272export function start(configuration: PreviewStreamConfiguration) {
......@@ -147,6 +147,7 @@ export function start(configuration: PreviewStreamConfiguration) {
147147 };
148148 const painter = createPreviewFramePainter(canvas, () => {
149149 if (!streaming && !stopped) reportStatus("streaming");
150 sendPictureInPictureFrame();
150151 });
151152 const send = (message: PreviewStreamInput) => client?.send(message) ?? false;
152153 const clearInput = () => {
......@@ -620,10 +621,16 @@ export function start(configuration: PreviewStreamConfiguration) {
620621 const pictureInPictureSupported =
621622 interactive &&
622623 typeof canvas.captureStream === "function" &&
624 typeof CanvasCaptureMediaStreamTrack === "function" &&
623625 ((document.pictureInPictureEnabled === true &&
624626 typeof videoPrototype.requestPictureInPicture === "function") ||
625627 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.
626632 let video: PresentationVideo | null = null;
633 let track: CanvasCaptureMediaStreamTrack | null = null;
627634 const pictureInPictureActive = () =>
628635 video !== null &&
629636 (document.pictureInPictureElement === video ||
......@@ -635,16 +642,20 @@ export function start(configuration: PreviewStreamConfiguration) {
635642 active: pictureInPictureActive(),
636643 ...(detail ? { detail } : {}),
637644 });
638 const ensureVideo = () => {
639 if (video) return video;
645 // Called after each paint. A frame drawn before the player attached is lost, so retry.
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();
640654 const element: PresentationVideo = document.createElement("video");
641655 element.muted = true;
642656 element.playsInline = true;
643 element.autoplay = true;
644657 // Under the canvas at full size: WebKit pauses muted video it considers off screen.
645 element.srcObject = canvas.captureStream();
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);
658 element.srcObject = stream;
648659 for (const name of [
649660 "enterpictureinpicture",
650661 "leavepictureinpicture",
......@@ -656,25 +667,38 @@ export function start(configuration: PreviewStreamConfiguration) {
656667 });
657668 }
658669 container.prepend(element);
670 // Covers the letterbox, where a stale frame of another shape would show through.
671 canvas.style.background = configuration.background;
659672 video = element;
660 return element;
661 };
662 const togglePictureInPicture = async () => {
663 if (!pictureInPictureSupported) return;
664 try {
665 if (pictureInPictureActive()) {
666 if (document.pictureInPictureElement) await document.exitPictureInPicture();
667 else video?.webkitSetPresentationMode?.("inline");
668 return;
669 }
670 const element = ensureVideo();
671 await element.play();
672 if (document.pictureInPictureEnabled) await element.requestPictureInPicture();
673 else element.webkitSetPresentationMode?.("picture-in-picture");
674 } catch (error) {
673 track = captured instanceof CanvasCaptureMediaStreamTrack ? captured : null;
674 }
675 const togglePictureInPicture = () => {
676 const element = video;
677 if (!element) return;
678 if (pictureInPictureActive()) {
679 if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {});
680 else element.webkitSetPresentationMode?.("inline");
681 return;
682 }
683 if (element.readyState === HTMLMediaElement.HAVE_NOTHING) {
684 reportPictureInPicture("The page has not shown yet. Try again in a moment.");
685 return;
686 }
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();
675692 reportPictureInPicture(
676693 error instanceof Error ? error.message : "Picture in picture is unavailable.",
677694 );
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);
678702 }
679703 };
680704
......@@ -691,14 +715,8 @@ export function start(configuration: PreviewStreamConfiguration) {
691715 painter.stop();
692716 client?.stop();
693717 client = null;
694 if (video) {
695 for (const track of video.srcObject instanceof MediaStream
696 ? video.srcObject.getTracks()
697 : []) {
698 track.stop();
699 }
700 video.srcObject = null;
701 }
718 track?.stop();
719 if (video) video.srcObject = null;
702720 },
703721 command: (message) => {
704722 send(message);