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.
Context
Section titled “Context”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.
Decisions
Section titled “Decisions”-
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, mirroringexternal_package_assignment), andmedia_playback_event(append-only playback telemetry, mirroringdocument_progress_event). Media is conceptually and on the wire distinct from reading, so it gets its own surface rather than overloadingexternal_package/document_progress_event. -
A media resource is uploaded or external.
media_resource.source ∈ {uploaded, external}.uploadedcarries 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;externalstores 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 importableexternal_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 noexternal_package_kindchange are needed. -
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.
-
The full MediaProfile action set is the target, with an honest source-dependent floor. The
media_playback_event.actionenum 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 + nativeHTMLMediaElementlisteners —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. -
mediaCurrentTimerides on the CaliperMediaLocationtarget; the rail model gains two optional entity attributes, not a media-specific event field.validateCaliperEventchecks reference type strings against each profile’s allowed sets (not deep entity fields), soTelemetryObjectgains optionalduration?(ISO-8601, on theAudioObject/VideoObject) andcurrentTime?(ISO-8601, on theMediaLocationtarget) which ride as extra fields on the loose reference objects and validate. The MediaEvent object isAudioObject/VideoObject(withduration); the target is aMediaLocation(withcurrentTime). On the xAPI wire the same is rendered via the xAPI Video Profile result extensions (https://w3id.org/xapi/video/extensions/timeand/length); Caliper is the binding wire if the xAPI Statement schema rejects the extension. -
Playback telemetry is owner-pseudonymous and rides the sync rail.
media_playback_eventis adefineSyncTable(sync category 5, LWW), captured offline into PGlite as the learner plays and synced up, exactly like reading progress.person_idis 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. -
One reader, one event per row.
media-playback-readerwalksmedia_playback_eventin(updated_at_us, id)keyset order andprojectMediaPlaybackmaps each row’sactionenum to the Caliper MediaEvent action, building the object (from the joinedmedia_resourcekind + duration), theMediaLocationtarget (fromcurrent_time_ms), and the offeringgroup. Idempotent via a deterministicsourceKey(mediaPlayback:<id>:<action>).
Conformance impact
Section titled “Conformance impact”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.
Operator follow-ups
Section titled “Operator follow-ups”- 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).