Skip to content

ADR-001: Shared Media Controls Architecture

Accepted

2026-04-11

StoryKeep museums embed media alongside photo sets — Vimeo video stories with chapter markers that sync to photos, and (planned) audio narration stories with chapter-driven photo advancement. Both media types need transport controls: play/pause, chapter-aware seek bar, time display, volume, mute, and fullscreen.

Building separate controls for each media type would duplicate UI logic, styling, and accessibility work. The Vimeo Player SDK provides a chromeless embed mode (controls: false) that allows custom UI, so we are not constrained by Vimeo’s native controls.

Introduce a media-agnostic control layer with three pieces:

1. MediaState + MediaController interfaces

Section titled “1. MediaState + MediaController interfaces”
MediaState (declarative — what to display):
playing, currentTime, duration, muted, volume
MediaController (imperative — what buttons do):
play(), pause(), seek(seconds), setVolume(v), setMuted(m)

Each media source (Vimeo, future <audio>) implements both by reading from and writing to its native API.

A shared, stateless control bar that renders:

  • Play/pause toggle
  • Chapter progress bar (clickable segments, intra-chapter fill, proportional sizing)
  • Seekable single-bar fallback when no chapters exist
  • Current / total time display
  • Mute toggle + volume slider
  • Fullscreen toggle (via Fullscreen API on a parent ref)

MediaControls has zero knowledge of Vimeo, <audio>, or any media source. It receives MediaState, MediaController, and optional MediaChapter[] as props.

VimeoPlayerInner (the Vimeo wrapper):

  • Creates a Vimeo Player instance with controls: false (chromeless)
  • Listens to timeupdate, play, pause, volumechange events to derive MediaState
  • Builds a MediaController that delegates to playerRef.current.play(), .pause(), .setCurrentTime(), etc.
  • Detects chapter transitions from currentTime and fires onChapterChange to the parent (photo lightbox)
  • Renders the chromeless iframe + centered play overlay + <MediaControls>

Future audio wrapper would manage an <audio> element, derive the same MediaState, build the same MediaController, and render <MediaControls> — identical UI, different media source.

  • Segments are <button> elements with flex-grow proportional to chapter duration
  • Each segment shows a fill bar: past chapters = 100%, current = proportional to playback position, future = 0%
  • Fill animates with transition: width 200ms linear (respects prefers-reduced-motion)
  • Active chapter title displayed above the bar as an 11px uppercase heading
  • Native title attribute provides hover tooltips on desktop
  • Segments use min-height: 24px for a generous click target around the 3px visual bar
  • When no chapters exist, a single seekable bar spans the full width

The PhotoLightbox component receives onChapterChange callbacks from VimeoPlayerInner. When a chapter changes (via playback or user clicking a chapter segment), the lightbox updates chapterIndex, which crossfades the photo and updates the story text. The image container is locked to the first photo’s aspect ratio to prevent layout shifts during transitions.

Easier:

  • Adding audio chapter player requires only an <audio> wrapper — controls are already built
  • Consistent media UI across all museum themes (controls consume CSS custom properties)
  • Chapter seeking, progress visualization, and fullscreen work identically for any media type
  • Accessibility is centralized (aria-labels, keyboard support, reduced motion)

Harder:

  • Chromeless Vimeo embed means we own all UI — if Vimeo changes its SDK behavior, we need to adapt
  • The play overlay required special handling (optimistic hide) to avoid blocking the iframe
  • Volume control via setVolume(0) for mute is a workaround since the Vimeo SDK doesn’t expose a dedicated mute API on the Player constructor options