Skip to content

Latest commit

 

History

History
1018 lines (770 loc) · 40.1 KB

File metadata and controls

1018 lines (770 loc) · 40.1 KB

@lookout/react — React SDK Documentation

Package: @lookout/react v0.3.7 Peer Dependencies: React 18+ or 19+ Exports: ESM + CJS with TypeScript declarations


Quick Start

import { LookoutProvider, LookoutRecorder } from "@lookout/react";

function App() {
  return (
    <LookoutProvider token="your-64-char-hex-token" apiBaseUrl="https://lookout.hackclub.com">
      <LookoutRecorder />
    </LookoutProvider>
  );
}

For headless usage:

import { LookoutProvider, useLookout } from "@lookout/react";

function MyRecorder() {
  const { state, actions } = useLookout();

  return (
    <div>
      <p>Status: {state.status}</p>
      <p>Time: {state.displaySeconds}s</p>
      <button onClick={actions.startSharing}>Start</button>
      <button onClick={actions.pause}>Pause</button>
      <button onClick={() => actions.stop({ name: "My timelapse" })}>Stop</button>
    </div>
  );
}

function App() {
  return (
    <LookoutProvider token="..." apiBaseUrl="https://lookout.hackclub.com">
      <MyRecorder />
    </LookoutProvider>
  );
}

For camera (webcam) capture:

import { LookoutProvider, LookoutRecorder } from "@lookout/react";

function App() {
  return (
    <LookoutProvider
      token="your-64-char-hex-token"
      apiBaseUrl="https://lookout.hackclub.com"
      capture={{ mode: "camera" }}
    >
      <LookoutRecorder />
    </LookoutProvider>
  );
}

Provider

<LookoutProvider>

Context provider that configures the API client and settings for all child hooks/components.

<LookoutProvider
  token="..."
  apiBaseUrl="https://lookout.hackclub.com"
  capture={{ intervalMs: 30000, jpegQuality: 0.9 }}
  autoStart
>
  {children}
</LookoutProvider>

Props (LookoutProviderProps extends LookoutConfig):

Prop Type Default Description
token TokenProvider required Session token — string, sync getter, or async getter
apiBaseUrl string "" (same origin) Server API base URL
client LookoutClient fetch client for apiBaseUrl + token Bring your own API client. For hosts that must not speak HTTP from the webview — the desktop app routes every server call through its Rust core
capture CaptureSettings See below Screenshot capture settings
retry RetrySettings See below Upload retry/buffer settings
callbacks LookoutCallbacks {} Lifecycle event callbacks
statusPollIntervalMs number 3000 Compilation status poll interval (ms)
autoStart boolean false Auto-start screen sharing on mount
appName string Host program embedding Lookout (e.g. "Fallout"). Reported in client telemetry as Lookout Sdk (Fallout)/<version> (…) and surfaced server-side as the session's clientInfo.
accentColor string #3b82f6 Replace Lookout's blue with your brand colour — primary buttons, focus rings, progress. Any CSS colour.
accentTextColor string #fff Colour drawn on the accent. Set it if your accent is light enough that white labels would be unreadable.
children ReactNode required Child components

TokenProvider

type TokenProvider =
  | string                     // static token
  | (() => string)             // sync getter
  | (() => Promise<string>);   // async getter (e.g. fetch from your backend)

CaptureSettings

Field Type Default Description
intervalMs number 60000 Screenshot interval in ms
jpegQuality number 0.85 JPEG quality (0–1)
maxWidth number 1920 Max capture width in px
maxHeight number 1080 Max capture height in px
displayMediaConstraints DisplayMediaStreamOptions Override getDisplayMedia constraints
mode CaptureMode "screen" Capture source: "screen" or "camera"
camera CameraSettings {} Camera-specific settings (only used when mode is "camera")

CaptureMode

type CaptureMode = "screen" | "camera";

CameraSettings

Field Type Default Description
deviceId string Preferred camera device ID (from enumerateDevices). Omit for default camera
userMediaConstraints MediaTrackConstraints Additional getUserMedia video constraints (merged with defaults)

RetrySettings

Field Type Default Description
maxRetries number 3 Max retries per upload step
retryDelays number[] [2000, 4000, 8000] Backoff delays per attempt (ms)
maxPendingBuffer number 5 Max screenshots buffered in memory

Hooks

useLookout()

Primary hook — composes all lower-level hooks and orchestrates the capture-upload loop. Must be used within <LookoutProvider>.

const { state, actions } = useLookout();

Returns:

state: LookoutState

Field Type Description
status RecorderStatus Current status (see below)
isSharing boolean Whether media capture is active (screen sharing or camera recording)
isRecording boolean true when actively capturing (isSharing && (status === "active" || status === "pending")). Use this instead of compound checks in UI logic.
trackedSeconds number Server-authoritative tracked time — max of session state and the value returned by the last upload confirmation. Updates per-upload, not just on poll. Bounded by what the server has credited; never inflated by client-side estimation.
displaySeconds number Client-interpolated display time (ticks every second via RAF). Caps interpolation at one capture interval (60s) ahead of the last server credit, so stop/compile reveals at most a 60s drop. The baseRef ratchet defends against backward jumps on stale-read sync.
screenshotCount number Number of confirmed screenshots — max of server count and local upload count, so it updates immediately on upload.
uploads UploadState Upload queue: { pending, completed, failed }
lastScreenshotUrl string | null Object URL of last captured screenshot
videoUrl string | null Video URL when complete. Auto-fetched from server when status reaches "complete".
error string | null Error message when status is "error"
captureMode CaptureMode Active capture mode ("screen" or "camera")
availableCameras MediaDeviceInfo[] Available camera devices (populated when mode is "camera")
selectedCameraId string | null Currently selected camera device ID
isPreviewing boolean Whether camera is in preview mode (stream live, capture loop not started). Camera mode only.
previewStream MediaStream | null Live camera MediaStream for rendering in a <video> element. Available during preview and recording in camera mode.

actions: LookoutActions

Method Signature Description
startSharing () => Promise<void> Start capture source and begin the capture-upload loop. In camera mode, reuses the preview stream if one is active.
stopSharing () => void Stop capture source without stopping session (auto-pauses)
pause () => Promise<void> Pause the session
resume () => Promise<void> Resume a paused session
stop (options?: { name?: string; edit?: boolean }) => Promise<void> Stop the session and trigger compilation. Optionally name it first. edit: true holds it unpublished so the user can cut it before programs see it.
selectCamera (deviceId: string) => void Select a camera device by ID. Works during preview and recording.
startPreview () => Promise<void> Acquire camera stream for live preview without starting the capture loop. Camera mode only.
stopPreview () => void Stop the preview stream. Camera mode only.

RecorderStatus

Server states plus client-only states:

type RecorderStatus =
  | "pending"    // session created, not yet started
  | "active"     // recording in progress
  | "paused"     // paused by user or auto-pause
  | "stopped"    // stopped, waiting for compilation
  | "compiling"  // video being compiled
  | "complete"   // video ready
  | "failed"     // compilation failed
  | "loading"    // (client-only) initial session fetch
  | "no-token"   // (client-only) no token provided/resolved
  | "error";     // (client-only) error state

useScreenCapture(overrides?)

Handles getDisplayMedia, canvas snapshots, and stream lifecycle. Can be used standalone (without provider) by passing explicit settings.

const { isSharing, startSharing, takeScreenshot, stopSharing } = useScreenCapture();

Parameters:

Param Type Description
overrides CaptureSettings Optional overrides (merged with provider config)

Returns:

Field Type Description
isSharing boolean Whether screen sharing is active
startSharing () => Promise<void> Prompt user for screen sharing
takeScreenshot () => Promise<CaptureResult | null> Capture current frame as JPEG blob
stopSharing () => void Stop all media tracks

CaptureResult

interface CaptureResult {
  blob: Blob;            // JPEG image blob
  width: number;         // Pixel width
  height: number;        // Pixel height
  capturedAtMs?: number; // Client-clock ms when the frame was grabbed
                         // (vs. when the upload eventually arrives).
                         // Forwarded as `capturedAt` to opt into credit
                         // mode; uploader falls back to Date.now() at
                         // enqueue if missing.
}

useCameraCapture(overrides?)

Handles getUserMedia (webcam), device enumeration, canvas snapshots, and stream lifecycle. Supports a two-phase flow: preview (stream live, no capture loop) → recording (isSharing = true, triggers capture loop in useLookout). Can be used standalone (without provider) by passing explicit settings.

const {
  isSharing, startSharing, takeScreenshot, stopSharing,
  devices, selectedDeviceId, selectDevice,
  isPreviewing, previewStream, startPreview, stopPreview,
} = useCameraCapture();

Parameters:

Param Type Description
overrides CaptureSettings Optional overrides (merged with provider config)

Returns:

Field Type Description
isSharing boolean Whether camera is recording (capture loop active)
startSharing () => Promise<void> Start recording — reuses preview stream if active, otherwise acquires one
takeScreenshot () => Promise<CaptureResult | null> Capture current frame as JPEG blob
stopSharing () => void Stop recording and release camera stream
devices MediaDeviceInfo[] Available camera devices (auto-updated on connect/disconnect)
selectedDeviceId string | null Currently selected camera device ID
selectDevice (deviceId: string) => void Switch to a different camera (restarts stream, preserves preview/recording mode)
isPreviewing boolean Whether camera is in preview mode (stream live, not recording)
previewStream MediaStream | null Live camera MediaStream — render in a <video> element for live preview
startPreview () => Promise<void> Acquire camera stream for preview without starting the capture loop
stopPreview () => void Stop preview and release camera stream

Notes:

  • Enumerates devices on mount and on the devicechange event.
  • Safari may return devices with empty labels before first getUserMedia call — labels are populated after the first stream is acquired.
  • selectDevice while streaming will restart the stream with the new device, preserving the current mode (preview or recording).
  • Stream is cleaned up on unmount if the user navigates away without recording.

useUploader()

Runs the serial upload pipeline (getUploadUrl → R2 PUT → confirmScreenshot) end-to-end with per-leg retries. Must be used within <LookoutProvider>. The pre-0.2.4 fire-and-forget queue (enqueue/nextExpectedAt) was replaced with this synchronous form — matches the desktop Rust capture loop.

const { captureUploadConfirm, uploads, trackedSeconds, lastScreenshotUrl, lastError, sessionConflict, resetConflict } = useUploader();

Returns:

Field Type Description
captureUploadConfirm (capture: CaptureResult) => Promise<{ trackedSeconds, nextExpectedAt }> Run the full pipeline. Resolves with the fresh tracked_seconds and next_expected_at from THIS capture's confirm response. Throws after retries — caller (the tick chain) catches and falls back to the local interval.
uploads UploadState { pending, completed, failed } counts. Note: completed reflects successful confirms, not credited captures — a confirm can succeed with credited_seconds = 0 in credit mode.
trackedSeconds number Server-reported tracked time from the last successful confirm
lastScreenshotUrl string | null Object URL of last uploaded screenshot
lastError string | null Last upload error message
sessionConflict boolean true when a 409 was received (session paused/stopped server-side)
resetConflict () => void Clear the sessionConflict flag after handling

useSession()

Manages session state, status polling, and server interactions. Must be used within <LookoutProvider>.

const session = useSession();

Returns:

Field Type Description
status RecorderStatus Current session status
name string Timelapse name
trackedSeconds number Server-tracked seconds
screenshotCount number Confirmed screenshot count
startedAt string | null Session start timestamp
createdAt string | null Session creation timestamp
totalActiveSeconds number Accumulated active time
error string | null Error message
pause () => Promise<void> Pause the session
resume () => Promise<void> Resume the session
stop (name?: string, opts?: { edit?: boolean }) => Promise<void> Stop the session. Optionally name it first (non-fatal if rename fails). edit: true requests an edit hold.
reload () => Promise<void> Re-fetch session from server
syncStatus () => Promise<void> Best-effort fetch of latest server status (used when a 409 surfaces in the uploader to reconcile local state)
updateTrackedSeconds (seconds: number) => void Update tracked seconds locally
setError (error: string | null) => void Set error state

useSessionTimer(serverTrackedSeconds, isActive)

Client-side interpolated timer. Uses the server-provided seconds as ground truth, ticks every second via requestAnimationFrame, and caps interpolation at one capture interval (60s) ahead of the last server-credited value. Maintains a monotonic ratchet so a stale-read sync returning a lower value doesn't make the display jump backward.

When a new serverTrackedSeconds arrives, baseRef ratchets to max(baseRef, serverTrackedSeconds) and the elapsed-since-sync clock resets. Between credits, the displayed value is baseRef + min(60, elapsed_seconds) — so if captures stall, the display freezes 60s ahead of the last credit instead of running unbounded. When the next credit lands, the new server value equals the previously-frozen display (no visible jump).

When isActive flips to false (pause/stop/compile), the display snaps to baseRef — the maximum possible drop the user sees is 60s, never the full session length.

Important: feed this hook only server-authoritative values. Do not derive a synthetic value from uploads.completed — in credit mode, not every successful upload credits a minute, so derived counts inflate the display. useLookout already enforces this via computeBestTrackedSeconds.

const displaySeconds = useSessionTimer(trackedSeconds, isActive);

Parameters:

Param Type Description
serverTrackedSeconds number Server-authoritative tracked time (from state.trackedSeconds, an upload-confirm response, or a status poll)
isActive boolean Whether to tick the timer. Set to false on pause/stop/compile to snap display to the server value

Returns: number — display seconds (baseRef + capped interpolated elapsed while active; baseRef when inactive)


useSessionTimerState(serverTrackedSeconds, isActive)

Same timer, but returns the interpolation anchor alongside the display value. Use this when another surface has to tick its own copy of the clock — the desktop app's menu-bar title (Rust) and tray popup window both do, so they stay live while the main WebView is throttled.

Returns: SessionTimerState

Field Type Description
displaySeconds number What to render
baseSeconds number The ratcheted server-authoritative value the display is anchored to
anchorAt number Date.now() when baseSeconds last advanced

deriveDisplaySeconds(baseSeconds, anchorAt, isActive, now)

The pure function behind the hook, exported so independently-ticking surfaces derive the clock identically instead of reimplementing it:

const seconds = deriveDisplaySeconds(baseSeconds, anchorAt, isRecording, Date.now());

Any surface ticking its own clock must go through this (or mirror it exactly — see tray_display_seconds in the desktop crate). Two rules are easy to get wrong and both produce a visibly wrong clock:

  • Interpolate from baseSeconds, never from displaySeconds. The latter already contains the interpolated remainder, so extrapolating from it double-counts and the surface drifts ahead of the main window.
  • Pass through anchorAt unchanged. It marks when the base last advanced; re-stamping it to "now" on each push restarts the interpolation window and loses time the main window is still counting.

useTokenStore()

Manages session tokens in localStorage with cross-tab sync. No provider required.

const store = useTokenStore();

Returns (UseTokenStore):

Field Type Description
tokens TokenEntry[] Active (non-archived) tokens
archivedTokens TokenEntry[] Archived tokens
addToken (token: string, label?: string) => void Add a token
archiveToken (token: string) => void Archive a token
unarchiveToken (token: string) => void Unarchive a token
removeToken (token: string) => void Permanently remove a token
getAllTokenValues () => string[] Get all active token strings
hasToken (token: string) => boolean Check if a token exists

TokenEntry

interface TokenEntry {
  token: string;
  addedAt: string;    // ISO timestamp
  label?: string;
  archived: boolean;
}

Storage key: lookout-tokens


useGallery(options)

Fetches multiple sessions for gallery display via the batch endpoint. Auto-refreshes on tab focus. No provider required.

const { sessions, loading, error, refresh } = useGallery({
  apiBaseUrl: "https://lookout.hackclub.com",
  tokens: ["token1", "token2"],
});

Parameters (UseGalleryOptions):

Field Type Description
apiBaseUrl string Server API base URL
tokens string[] Token strings to fetch
fetchSessions (tokens) => Promise<BatchSessionsResponse>? Bring your own POST /api/sessions/batch lookup (called per chunk of ≤100 tokens). Defaults to fetch against apiBaseUrl

Returns (UseGallery):

Field Type Description
sessions SessionSummary[] Fetched sessions (newest first)
loading boolean Whether fetch is in progress
error string | null Fetch error message
refresh () => void Manually re-fetch

useHashRouter()

Simple hash-based router for single-page app navigation. No provider required.

const { route, navigate } = useHashRouter();

Returns:

Field Type Description
route Route Current route
navigate (route: Route) => void Navigate to a route

Route

type Route =
  | { page: "gallery" }                   // #/
  | { page: "record"; token: string }     // #/record?token=...
  | { page: "session"; token: string };   // #/session?token=...

Components

<LookoutRecorder>

Drop-in recorder widget. Handles the full lifecycle: capture, upload, pause/resume/stop, compilation polling, and video display. Adapts its UI based on the configured capture.mode. Must be used within <LookoutProvider>.

<LookoutRecorder />

Props (LookoutRecorderProps):

Prop Type Description
editing boolean? Offer "Edit & save" when stopping (default true). Pass false to keep stopping a single click.

Everything else is read from context.

Stopping opens a <StopChoiceModal> with three ways out: keep recording, stop and save, or edit and save. Choosing to edit stops the session with a hold, so it compiles without publishing, and swaps the view for a <TimelapseEditor> until the user publishes.

Under one credited minute (MIN_STOPPABLE_TRACKED_SECONDS) there is no capture unit to compile — the seed capture is dropped from the video and the flush on the way out is a single still. The modal says so and leads with "Keep recording", dropping the name field and "Edit & save"; stopping stays available as "Stop anyway", because someone who opened a session by mistake should be able to leave. The threshold reads the server's tracked count, not the on-screen clock, which interpolates past 1:00 before that minute exists as a capture unit. POST /stop is unchanged and accepts a stop at any duration.

Renders based on status:

  • loading — spinner
  • no-token — "no session token" message
  • error — error display
  • stopped / compiling / complete / failed<ProcessingState>
  • pending / active / paused — capture UI (varies by mode, see below)

Screen mode (capture.mode: "screen", default):

  • <StatusBar> + <ScreenPreview> + <RecordingControls>
  • Copy: "Share Screen & Start Recording", "Share Screen & Resume"

Camera mode (capture.mode: "camera"):

  • Three-phase flow: idle → preview → recording
  • Idle: "Start Camera" button (acquires camera stream for preview)
  • Preview: <CameraPreview> (live video) + <CameraSelector> (if multiple cameras) + "Start Recording" / "Cancel"
  • Recording: <CameraPreview> + standard Pause/Stop controls
  • Copy adapts: "Start Camera", "Start Recording", "Start Camera & Resume"

<StatusBar>

Displays timer, screenshot count, and upload queue status.

<StatusBar displaySeconds={120} screenshotCount={5} uploads={{ pending: 1, completed: 4, failed: 0 }} />

Props (StatusBarProps):

Prop Type Description
displaySeconds number Seconds to display (formatted as H:MM:SS)
screenshotCount number Confirmed screenshot count
uploads UploadState { pending, completed, failed }

<RecordingControls>

Action buttons for start/pause/resume/stop, adapts to current state.

<RecordingControls
  status="active"
  isSharing={true}
  onStartSharing={() => {}}
  onPause={() => {}}
  onResume={() => {}}
  onStop={() => {}}
/>

Props (RecordingControlsProps):

Prop Type Description
status RecorderStatus Current session status
isSharing boolean Whether screen sharing is active
onStartSharing () => void Start screen sharing callback
onPause () => void Pause callback
onResume () => void Resume callback
onStop () => void Stop callback
loading boolean? Show loading state on buttons

<ScreenPreview>

Displays the last captured screenshot. Renders nothing if no image.

<ScreenPreview imageUrl={lastScreenshotUrl} />

Props (ScreenPreviewProps):

Prop Type Description
imageUrl string | null Object URL of the screenshot

<CameraPreview>

Live camera preview using a <video> element. Falls back to a static image when no stream is provided. Mirrors the video horizontally for a natural selfie-view.

<CameraPreview stream={state.previewStream} fallbackImageUrl={state.lastScreenshotUrl} />

Props (CameraPreviewProps):

Prop Type Description
stream MediaStream | null Live camera MediaStream to display
fallbackImageUrl string | null? Fallback static image URL (e.g. last captured screenshot)

<CameraSelector>

Camera device picker dropdown. Renders nothing if no devices are available.

<CameraSelector
  devices={state.availableCameras}
  selectedDeviceId={state.selectedCameraId}
  onSelect={actions.selectCamera}
  disabled={state.isSharing}
/>

Props (CameraSelectorProps):

Prop Type Description
devices MediaDeviceInfo[] Available camera devices
selectedDeviceId string | null Currently selected device ID
onSelect (deviceId: string) => void Device selection callback
disabled boolean? Disable selection (e.g., while recording)

<ProcessingState>

Displays compilation progress, video player, or failure state.

<ProcessingState status="compiling" trackedSeconds={300} />
<ProcessingState status="complete" trackedSeconds={300} videoUrl="https://..." />

Props (ProcessingStateProps):

Prop Type Description
status string Session status
trackedSeconds number Tracked time to display
videoUrl string? Video URL (shown when complete)
error string? Error message
onVideoLoaded () => void? Callback when video element loads

<ResultView>

Wraps <ProcessingState> with automatic video URL fetching from the API. Must be used within <LookoutProvider>.

<ResultView status="complete" trackedSeconds={300} />

Props (ResultViewProps):

Prop Type Description
status RecorderStatus Session status
trackedSeconds number Tracked time

<Gallery>

Grid of session cards with loading, empty, and error states.

<Gallery
  sessions={sessions}
  loading={false}
  error={null}
  onSessionClick={(token) => navigate({ page: "session", token })}
  onArchive={(token) => store.archiveToken(token)}
  onRefresh={refresh}
/>

Props (GalleryProps):

Prop Type Description
sessions SessionSummary[] Sessions to display
loading boolean Show skeleton loader
error string | null Error message
onSessionClick (token: string) => void? Card click handler
onArchive (token: string) => void? Archive button handler
onRefresh () => void? Refresh button handler

<SessionCard>

Individual session card with thumbnail, status badge, timelapse name, tracked time, and recording date.

<SessionCard session={session} onClick={() => {}} onArchive={() => {}} />

Props (SessionCardProps):

Prop Type Description
session SessionSummary Session data
onClick () => void? Click handler
onArchive () => void? Archive button handler

<SessionDetail>

Full session detail view with video player, stats, and compilation polling. Standalone (no provider needed).

<SessionDetail
  token="..."
  apiBaseUrl="https://lookout.hackclub.com"
  onBack={() => navigate({ page: "gallery" })}
  onArchive={() => store.archiveToken(token)}
/>

Props (SessionDetailProps):

Prop Type Description
token string Session token
apiBaseUrl string Server API base URL
client LookoutClient? Bring your own API client (see <LookoutProvider>); defaults to the fetch client
onBack () => void? Back button handler
onArchive () => void? Archive button handler
onEdit () => void? Override the review panel's "Edit & save" — open your own editor surface instead of the inline one

A session in its edit hold (stopped with edit, not yet published) renders a review panel instead of the compile spinner: "Edit & save" opens the editor, "Publish as recorded" ends the hold immediately, and a countdown shows when it publishes on its own. Pass onEdit to open your own editor surface (the desktop app opens a separate window).


useEditLease(client, active?)

Holds a held session's edit lease open while an editing surface is mounted. The server publishes a held timelapse once nothing has renewed the lease for ~2 minutes, so "is the user still editing?" is answered by the surface existing rather than by a countdown. Returns false once the session is no longer held (published, failed, or past the ceiling).

Called automatically by <TimelapseEditor> and by <SessionDetail>'s review panel. Use it directly only if you build your own editing surface.


<StopChoiceModal>

The stop confirmation: keep recording, stop and save, or edit and save. Rendered automatically by <LookoutRecorder>; exported for custom recorders.

Props (StopChoiceModalProps):

Prop Type Description
onResume () => void Keep recording
onStopAndSave (name: string | null) => void Stop and publish as recorded
onEditAndSave ((name: string | null) => void)? Stop with a hold, then edit. Omit to hide the option
withName boolean? Show a name field (default false)
loading boolean? Disable inputs while the stop is in flight
tooShort boolean? The session has no full minute yet, so there is no timelapse to save. Swaps the copy, drops the name field and "Edit & save", leads with "Keep recording" and offers "Stop anyway". Pass isTooShortToCompile(trackedSeconds).

<TimelapseEditor>

The "Edit & save" step. The session is compiled but deliberately unpublished, so nothing downstream has consumed it yet; this previews that video (1 second = 1 capture unit = 1 real-world minute), lets the user drag out cut regions on a filmstrip timeline, and publishes — with the cuts baked in (a lossless server-side stream copy) or without them. Standalone (no provider needed).

<TimelapseEditor
  token="..."
  apiBaseUrl="https://lookout.hackclub.com"
  onApplied={() => refetchStatus()}
/>

Props (TimelapseEditorProps):

Prop Type Description
token string Session token
apiBaseUrl string Server API base URL
client LookoutClient? Bring your own API client (see <LookoutProvider>); defaults to the fetch client
onApplied () => void? The timelapse was published — return to your detail view and poll /status
onCancel () => void? Dismiss the editor. Only surfaced when it can't load; there is no "leave without deciding" exit
onCutsChange ((cuts, dirty) => void)? Fires on every cut-list change, so a host can publish the working edit when the user closes it

The editor normally opens before the preview video exists — the compile starts at stop and takes tens of seconds — so it polls through that state and shows a <ProgressRing> sized from the session's capture count, then swaps to the timeline when the video lands.

<LookoutRecorder> and <SessionDetail> both present this in an <Overlay> — a modal panel portalled to document.body, so it gets the viewport rather than whatever width the host gave the recorder, and so a transformed ancestor in the host page can't trap it. It is deliberately not dismissible: closing without deciding would leave the session unpublished, so Save is the way out (and if the tab goes away, the edit lease lapses and it publishes as recorded).

The timeline ruler labels at a step chosen from the track width (rulerStep), with a grabbable playhead tag above it. While mounted the editor holds the session's edit lease (see useEditLease), so there's no deadline for the user to race: the timelapse stays unpublished for as long as the editor is open, and publishes on its own about two minutes after it closes.

Interactions:

  • Drag on the filmstrip creates a cut region in one gesture (edges snap to whole minutes); plain click seeks; the ruler lane scrubs.
  • Regions are first-class objects: drag to move, edge handles to resize (the preview follows the dragged edge, showing the boundary frame), click to select, Delete/Backspace to remove.
  • Space plays/pauses. Playback skips cut regions (previewing the published result); scrubbing passes through them with a "will be removed" overlay so edges can be judged.
  • Footer shows server-authoritative "kept / removed" durations; recording pauses appear as dashed gap markers on the strip.
  • Shows "n edits remaining" as the per-session recompile budget runs low, and a not-editable state once the original video has been purged.

Callbacks

Pass via LookoutProvider's callbacks prop:

<LookoutProvider
  token="..."
  callbacks={{
    onShareStart: () => console.log("sharing started"),
    onCapture: (capture) => console.log("captured", capture.width, "x", capture.height),
    onUploadSuccess: ({ screenshotId, trackedSeconds }) => {},
    onUploadFailure: (error) => {},
    onPause: ({ totalActiveSeconds }) => {},
    onResume: () => {},
    onStop: ({ trackedSeconds, totalActiveSeconds }) => {},
    onComplete: ({ videoUrl }) => {},
    onCompilationFailed: () => {},
    onError: (error, context) => {},
    onStatusChange: (prev, next) => {},
  }}
>
Callback Arguments When
onShareStart Screen sharing started
onShareStop Screen sharing ended
onCapture CaptureResult Screenshot captured (before upload)
onUploadSuccess { screenshotId, trackedSeconds } Screenshot uploaded and confirmed
onUploadFailure Error Upload failed after all retries
onPause { totalActiveSeconds } Session paused
onResume Session resumed
onStop { trackedSeconds, totalActiveSeconds } Session stopped
onComplete { videoUrl } Compilation complete, video ready
onCompilationFailed Compilation failed
onError (Error, context: string) Any non-fatal error
onStatusChange (prev, next) Status transition

API Client

createLookoutClient(options)

Standalone API client with no React dependency. Useful for server-side or non-React contexts.

import { createLookoutClient } from "@lookout/react";

const client = createLookoutClient({
  baseUrl: "https://lookout.hackclub.com",
  token: "your-token",
});

const session = await client.getSession();

Options (CreateClientOptions):

Field Type Description
baseUrl string Server API base URL
token TokenProvider Session token (string, sync, or async getter)

Returns (LookoutClient):

Method Signature Description
resolveToken () => Promise<string> Resolve the token value
getSession () => Promise<SessionResponse> Fetch session status
getUploadUrl () => Promise<UploadUrlResponse> Get presigned upload URL
confirmScreenshot (body) => Promise<ConfirmScreenshotResponse> Confirm upload
uploadToR2 (uploadUrl, blob) => Promise<void> PUT blob to presigned URL
pause () => Promise<PauseResponse> Pause session
resume () => Promise<ResumeResponse> Resume session
stop (opts?: { edit?: boolean }) => Promise<StopResponse> Stop session; edit: true holds it for editing before publication
rename (name: string) => Promise<RenameSessionResponse> Rename the timelapse
getStatus () => Promise<StatusResponse> Poll compilation status
getVideo () => Promise<VideoResponse> Get video URL
getUnits () => Promise<UnitsResponse> Editor metadata: unit map, cuts, presigned preview-video URL
setCuts (cuts: CutInterval[]) => Promise<SetCutsResponse> Replace the session's cut list ([] clears). Only during an edit hold
applyCuts () => Promise<ApplyCutsResponse> Publish the held timelapse with its cuts baked in
heartbeatEditing () => Promise<EditHeartbeatResponse> Renew the edit lease — "an editor is still open"

UI Primitives

The SDK exports styled UI primitives used by its components. All use inline styles (no CSS imports needed).

Export Description
Button Styled button with variants: primary, secondary, success, warning, danger, ghost and sizes: sm, md, lg
Spinner Loading spinner with sizes: sm, md, lg
ProgressRing Determinate circular progress (progress 0–1, optional showPercent) — for waits long enough that a spinner under-informs
MinutesFlow A minute count with rolling digits (@number-flow/react), splitting into hours past 60
Badge Status badge with variants: default, overlay
Card Styled card container
ErrorDisplay Error message display with variants: inline, banner, page
PageContainer Page layout wrapper
Overlay Modal panel portalled to document.body (immune to transformed ancestors), with backdrop, scroll lock, and optional dismiss
Skeleton / GallerySkeleton / SessionDetailSkeleton / RecordPageSkeleton Loading skeletons
colors / spacing / radii / fontSize / fontWeight / statusConfig Theme tokens
setAccentColor(accent, on?) Imperative accent override, for surfaces used without <LookoutProvider> (<SessionDetail>, <TimelapseEditor>). Pass null to restore the default.

Theming the accent

<LookoutProvider token="…" apiBaseUrl="…" accentColor="#16a34a">
  <LookoutRecorder />
</LookoutProvider>

That recolours the primary buttons ("Edit & save", "Save"), keyboard focus rings, and the compile progress ring — everywhere the UI says this is the main action. The hover shade is derived from your colour with color-mix, so you don't supply a second one.

Two deliberate limits:

  • Semantic colours don't change. Success green, warning amber, and the red that marks removed footage carry meaning rather than brand, and a green "this will be deleted" would be worse than an off-brand one.
  • It's set on the document root, not a wrapper. The stop dialog and the editor portal to document.body, so a scoped subtree wouldn't reach them. The provider restores the previous value on unmount.

For surfaces rendered outside a provider, call setAccentColor("#16a34a") once at startup instead.


Utilities

formatTime(totalSeconds)

Formats seconds as H:MM:SS or M:SS. Used for the live timer display.

import { formatTime } from "@lookout/react";

formatTime(0);     // "0:00"
formatTime(65);    // "1:05"
formatTime(3661);  // "1:01:01"

formatTrackedTime(totalSeconds)

Formats seconds as human-readable tracked time. Used for static time displays (gallery cards, stats) where second-level precision is unnecessary.

import { formatTrackedTime } from "@lookout/react";

formatTrackedTime(0);      // "< 1min"
formatTrackedTime(300);    // "5min"
formatTrackedTime(5640);   // "1h 34min"
formatTrackedTime(7200);   // "2h"