diff --git a/ui/src/Live/LiveCamera.tsx b/ui/src/Live/LiveCamera.tsx index 6aaf2e7..40477c1 100644 --- a/ui/src/Live/LiveCamera.tsx +++ b/ui/src/Live/LiveCamera.tsx @@ -12,7 +12,20 @@ import Alert from "@mui/material/Alert"; import useResizeObserver from "@react-hook/resize-observer"; import { fillAspect } from "../aspect"; +/// The media source API to use: +/// * Essentially everything but iPhone supports `MediaSource`. +/// (All major desktop browsers; Android browsers; and Safari on iPad are +/// fine.) +/// * Safari/macOS and Safari/iPhone on iOS 17+ support `ManagedMediaSource`. +/// * Safari/iPhone with older iOS does not support anything close to +/// `MediaSource`. +export const MediaSourceApi: typeof MediaSource | undefined = + (self as any).ManagedMediaSource ?? self.MediaSource; + interface LiveCameraProps { + /// Caller should provide a failure path when `MediaSourceApi` is undefined + /// and pass it back here otherwise. + mediaSourceApi: typeof MediaSource; camera: Camera | null; chooser: JSX.Element; } @@ -60,11 +73,14 @@ type PlaybackState = */ class LiveCameraDriver { constructor( + mediaSourceApi: typeof MediaSource, camera: Camera, setPlaybackState: (state: PlaybackState) => void, setAspect: (aspect: [number, number]) => void, video: HTMLVideoElement ) { + this.mediaSourceApi = mediaSourceApi; + this.src = new mediaSourceApi(); this.camera = camera; this.setPlaybackState = setPlaybackState; this.setAspect = setAspect; @@ -75,7 +91,12 @@ class LiveCameraDriver { video.addEventListener("timeupdate", this.videoTimeUpdate); video.addEventListener("waiting", this.videoWaiting); this.src.addEventListener("sourceopen", this.onMediaSourceOpen); - this.video.src = this.url; + + // This appears necessary for the `ManagedMediaSource` API to function + // on Safari/iOS. + video["disableRemotePlayback"] = true; + video.src = this.objectUrl = URL.createObjectURL(this.src); + video.load(); } unmount = () => { @@ -87,8 +108,8 @@ class LiveCameraDriver { v.removeEventListener("timeupdate", this.videoTimeUpdate); v.removeEventListener("waiting", this.videoWaiting); v.src = ""; + URL.revokeObjectURL(this.objectUrl); v.load(); - URL.revokeObjectURL(this.url); }; onMediaSourceOpen = () => { @@ -169,7 +190,7 @@ class LiveCameraDriver { return; } const part = result.part; - if (!MediaSource.isTypeSupported(part.mimeType)) { + if (!this.mediaSourceApi.isTypeSupported(part.mimeType)) { this.error(`unsupported mime type ${part.mimeType}`); return; } @@ -332,13 +353,14 @@ class LiveCameraDriver { setAspect: (aspect: [number, number]) => void; video: HTMLVideoElement; - src = new MediaSource(); + mediaSourceApi: typeof MediaSource; + src: MediaSource; buf: BufferState = { state: "closed" }; queue: Part[] = []; queuedBytes: number = 0; /// The object URL for the HTML video element, not the WebSocket URL. - url = URL.createObjectURL(this.src); + objectUrl: string; ws?: WebSocket; } @@ -350,7 +372,7 @@ class LiveCameraDriver { * should use React's key attribute to avoid unnecessarily mounting * and unmounting a camera. */ -const LiveCamera = ({ camera, chooser }: LiveCameraProps) => { +const LiveCamera = ({ mediaSourceApi, camera, chooser }: LiveCameraProps) => { const [aspect, setAspect] = React.useState<[number, number]>([16, 9]); const videoRef = React.useRef(null); const boxRef = React.useRef(null); @@ -372,11 +394,17 @@ const LiveCamera = ({ camera, chooser }: LiveCameraProps) => { if (camera === null || video === null) { return; } - const d = new LiveCameraDriver(camera, setPlaybackState, setAspect, video); + const d = new LiveCameraDriver( + mediaSourceApi, + camera, + setPlaybackState, + setAspect, + video + ); return () => { d.unmount(); }; - }, [camera]); + }, [mediaSourceApi, camera]); // Display circular progress after 100 ms of waiting. const [showProgress, setShowProgress] = React.useState(false); diff --git a/ui/src/Live/index.tsx b/ui/src/Live/index.tsx index 760895a..6fe07f9 100644 --- a/ui/src/Live/index.tsx +++ b/ui/src/Live/index.tsx @@ -5,7 +5,7 @@ import Container from "@mui/material/Container"; import ErrorIcon from "@mui/icons-material/Error"; import { Camera } from "../types"; -import LiveCamera from "./LiveCamera"; +import LiveCamera, { MediaSourceApi } from "./LiveCamera"; import Multiview, { MultiviewChooser } from "./Multiview"; import { FrameProps } from "../App"; import { useSearchParams } from "react-router-dom"; @@ -36,7 +36,8 @@ const Live = ({ cameras, Frame }: LiveProps) => { ); }, [searchParams]); - if ("MediaSource" in window === false) { + const mediaSourceApi = MediaSourceApi; + if (mediaSourceApi === undefined) { return ( @@ -72,7 +73,11 @@ const Live = ({ cameras, Frame }: LiveProps) => { layoutIndex={multiviewLayoutIndex} cameras={cameras} renderCamera={(camera: Camera | null, chooser: JSX.Element) => ( - + )} />