Skip to content

Commit

Permalink
attempt at iPhone support (#121)
Browse files Browse the repository at this point in the history
  • Loading branch information
scottlamb committed Apr 17, 2024
1 parent 9acb095 commit 93a9ad9
Show file tree
Hide file tree
Showing 2 changed files with 44 additions and 11 deletions.
44 changes: 36 additions & 8 deletions ui/src/Live/LiveCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 = () => {
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -350,7 +372,7 @@ class LiveCameraDriver {
* should use React's <tt>key</tt> 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<HTMLVideoElement>(null);
const boxRef = React.useRef<HTMLElement>(null);
Expand All @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions ui/src/Live/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,7 +36,8 @@ const Live = ({ cameras, Frame }: LiveProps) => {
);
}, [searchParams]);

if ("MediaSource" in window === false) {
const mediaSourceApi = MediaSourceApi;
if (mediaSourceApi === undefined) {
return (
<Frame>
<Container>
Expand Down Expand Up @@ -72,7 +73,11 @@ const Live = ({ cameras, Frame }: LiveProps) => {
layoutIndex={multiviewLayoutIndex}
cameras={cameras}
renderCamera={(camera: Camera | null, chooser: JSX.Element) => (
<LiveCamera camera={camera} chooser={chooser} />
<LiveCamera
mediaSourceApi={mediaSourceApi}
camera={camera}
chooser={chooser}
/>
)}
/>
</Frame>
Expand Down

0 comments on commit 93a9ad9

Please sign in to comment.