Skip to content

Media playback as a Caliper-native product feature

Status: accepted

Date: 2026-06-26

Relates to: ADR-0041 (Caliper rail / shared telemetry projection seam), ADR-0042 (pseudonymous emission governance), ADR-0026 (EPUB/PDF reading lane — adjacent but distinct), caliper:1.2:conf:vocabulary/CAL-VOCAB-2.

The Caliper 1.2 rail (ADR-0041) is built and idle: a read-only telemetry-projection seam in apps/api/src/features/telemetry/ where stream readers walk canonical tables, project them through metric-profile projections, and two wire adapters render Caliper + xAPI. MediaProfile is one of two profiles parked in DEFERRED_PROFILES (action-vocabulary.ts) because no product surface emits it: emergent has no audio/video playback feature.

This is a genuine product gap the standard surfaced, not a badge to chase ([[feedback_standards_driven_product_discovery]]). Timed media playback is a first-class learning activity — distinct from the reading lane (ADR-0026, document_progress_event, which is paginated reading of an EPUB/PDF, projected as a single Caliper ViewEvent). Media is timed playback with a position on a timeline: play/pause/resume/seek against a media duration, projected as Caliper MediaEvents carrying mediaCurrentTime.

We build it as a real feature — schema, learner delivery, teacher authoring — with Caliper emission as one requirement, Caliper-native from the start.

  1. A first-class media surface, not a reuse of the External Package reading lane. Three tables: media_resource (the audio/video asset: org-owned catalog entity), media_assignment (its placement into an offering, mirroring external_package_assignment), and media_playback_event (append-only playback telemetry, mirroring document_progress_event). Media is conceptually and on the wire distinct from reading, so it gets its own surface rather than overloading external_package/document_progress_event.

  2. A media resource is uploaded or external. media_resource.source ∈ {uploaded, external}. uploaded carries a self-contained storage reference (storage_bucket + storage_key, reusing the existing private runtime bucket) and is delivered as a short-TTL Supabase signed URL exactly like document/web-content delivery; external stores a URL served directly (a CDN-hosted file, or a hosted platform such as YouTube/Vimeo). One delivery endpoint resolves both. The uploaded path deliberately does not model audio/video as an importable external_package (that enum + import pipeline is for processed packages) — it reuses only the signed-URL delivery mechanism and the existing bucket, so no new bucket and no external_package_kind change are needed.

  3. Rendering via react-player v3 (MIT), not a hand-built player. react-player wraps both file URLs and hosted platforms behind one component and is HTMLMediaElement-compatible in v3. We considered Vidstack (richer A11y + event model) but chose react-player for lower maintenance risk; Vidstack remains a future option if we need adaptive streaming/DRM. A media player is not something Mantine provides, so the dependency is justified.

  4. The full MediaProfile action set is the target, with an honest source-dependent floor. The media_playback_event.action enum holds all 15 emitted MediaProfile actions — lifecycle (Started, Paused, Resumed, Ended, Restarted), seek (ForwardedTo, JumpedTo), and the cosmetic state changes (ChangedSpeed, ChangedVolume, Muted, Unmuted, EnteredFullScreen, ExitedFullScreen, EnabledClosedCaptioning, DisabledClosedCaptioning). On HTML5/file sources the player wires the full set (react-player callbacks + native HTMLMediaElement listeners — volumechange, fullscreenchange, text-track mode changes). On hosted platforms (YouTube/Vimeo) the underlying element is not a raw <video>, so only the react-player-exposed subset (lifecycle + seek + rate) is captured. This floor is documented, not hidden.

  5. mediaCurrentTime rides on the Caliper MediaLocation target; the rail model gains two optional entity attributes, not a media-specific event field. validateCaliperEvent checks reference type strings against each profile’s allowed sets (not deep entity fields), so TelemetryObject gains optional duration? (ISO-8601, on the AudioObject/VideoObject) and currentTime? (ISO-8601, on the MediaLocation target) which ride as extra fields on the loose reference objects and validate. The MediaEvent object is AudioObject/VideoObject (with duration); the target is a MediaLocation (with currentTime). On the xAPI wire the same is rendered via the xAPI Video Profile result extensions (https://w3id.org/xapi/video/extensions/time and /length); Caliper is the binding wire if the xAPI Statement schema rejects the extension.

  6. Playback telemetry is owner-pseudonymous and rides the sync rail. media_playback_event is a defineSyncTable (sync category 5, LWW), captured offline into PGlite as the learner plays and synced up, exactly like reading progress. person_id is always set (media playback is never anonymous — it is assigned to offering members) and severed on erasure (on delete set null), so the reader skips severed rows. The emitted actor is pseudonymous (the account-IFI pseudonym, ADR-0042); no PII reaches the wire.

  7. One reader, one event per row. media-playback-reader walks media_playback_event in (updated_at_us, id) keyset order and projectMediaPlayback maps each row’s action enum to the Caliper MediaEvent action, building the object (from the joined media_resource kind + duration), the MediaLocation target (from current_time_ms), and the offering group. Idempotent via a deterministic sourceKey (mediaPlayback:<id>:<action>).

MediaProfile moves from DEFERRED_PROFILES to emitted: the Sensor now declares + emits MediaEvents, covered in the interop conformance lane. This does not flip CAL-VOCAB-2 — that requirement also binds an AggregateMeasure metric term and an Active/Inactive entity status, a separate deferred follow-up. CAL-VOCAB-1 (action vocabulary) stays green: every new action term is in conform-ed’s CALIPER_ACTIONS and permitted for MediaEvent.

  • One migration batch (the three media tables) — Anton’s domain ([[feedback_db_migrations_anton_domain]]); regenerated by the agent, applied by Anton.
  • The emission worker host stays held (ADR-0041 consequences) — built once after Media/Survey + the AggregateMeasure/status follow-up, to flip CAL-SND-1 + CAL-VOCAB-2 together.

Addendum (2026-06-26): teacher authoring built

Section titled “Addendum (2026-06-26): teacher authoring built”

The deferred teacher authoring surface is now built. A teacher gets media into an offering from the offering workspace’s Media tab: upload an audio/video file (server-proxied into the private runtime bucket external-package-runtime-packages, served later as a short-TTL signed URL by delivery) or register an external/hosted URL. The kind (audio/video) is derived from the upload’s content-type server-side; uploads cap at 200 MiB (host larger media externally). API: media-authoring service + /workflow/teacher/offerings/:offeringId/media routes (multipart upload, JSON /external, list, retire). Authoring is claim-aware (offering teacher/assistant or org/platform admin). Still out of scope: rich media authoring (chapters, caption editing) and offering-contents discovery wiring (learners reach assigned media via its delivery route).