| ... | @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = | ... | @@ -50,7 +50,7 @@ const AGENT_CURSOR_SVG = |
| 50 | interface Viewer { | 50 | interface 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 | } |
| 55 | | 55 | |
| 56 | let activeViewer: Viewer | null = null; | 56 | let activeViewer: Viewer | null = null; |
| ... | @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { | ... | @@ -66,7 +66,7 @@ export function command(input: PreviewStreamInput) { |
| 66 | } | 66 | } |
| 67 | | 67 | |
| 68 | export function pictureInPicture() { | 68 | export function pictureInPicture() { |
| 69 | void activeViewer?.togglePictureInPicture(); | 69 | activeViewer?.togglePictureInPicture(); |
| 70 | } | 70 | } |
| 71 | | 71 | |
| 72 | export function start(configuration: PreviewStreamConfiguration) { | 72 | export 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 | }; |
| 680 | | 704 | |
| ... | @@ -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 MediaStream | 719 | 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); |