The sixth delivered slice brings video to the lesson editor. Instructors paste a Bunny Stream URL, the system normalises it to an embed URL with autoplay disabled, and students see the video above the lesson body. The editor shows a slate-blue banner and a highlighted toolbar button so the attached state is always obvious.

What was built

The Instructor can now:

  • Click the video camera button in the lesson editor toolbar to open a modal
  • Paste either the browser address bar URL (player.mediadelivery.net/play/…) or the embed URL directly — both work
  • See a slate-blue banner appear below the header with the URL and a remove (×) button
  • See the video camera icon turn blue to indicate the Lesson has a video attached
  • See a dark 16:9 placeholder in the preview pane confirming the video will appear above the body
  • Remove the video at any time by clicking × in the banner
  • Save the Lesson — the video URL is persisted through the standard “Save Lesson” submit

The Student sees:

  • A 16:9 Bunny Stream iframe above the lesson body, constrained to reading-column width
  • Native fullscreen support
  • If the video URL is broken, the iframe renders its fallback message
  • No video section at all when no URL is set

URL normalisation flow

The system accepts URLs in two forms and normalises them silently on save.

flowchart LR
    A["player.mediadelivery.net\n/play/LIB/ID"] -->|host + path rewrite| B["iframe.mediadelivery.net\n/embed/LIB/ID"]
    C["iframe.mediadelivery.net\n/embed/LIB/ID"] --> B
    B -->|Map.put_new autoplay false| D["iframe.mediadelivery.net\n/embed/LIB/ID?autoplay=false"]
    D --> E[(lesson.video_url)]

Two transformations happen in VideoEmbedLive.to_embed_url/1 before the URL reaches the parent:

  1. Player → embed: player.mediadelivery.net/play/ is rewritten to iframe.mediadelivery.net/embed/. Users naturally copy the URL from the Bunny Stream video page — the system converts it so they never have to think about which URL format to use.
  2. autoplay=false: Map.put_new/3 adds autoplay=false to the query string unless it is already present. Existing params (tokens, etc.) are preserved.

Instructor attach flow

sequenceDiagram
    actor Instructor
    participant LV as LessonLive.Edit
    participant VE as VideoEmbedLive
    participant DB as Repo

    Instructor->>VE: Click video camera button
    VE->>VE: modal_open = true
    Instructor->>VE: Paste URL + click Save
    VE->>VE: to_embed_url(url) → normalise + autoplay=false
    VE->>LV: send {:video_saved, embed_url}
    LV->>LV: assign :video_url, update form changeset
    LV-->>Instructor: Banner appears · icon turns blue · preview placeholder shows
    Instructor->>LV: Click "Save Lesson"
    LV->>LV: form params include lesson[video_url] hidden input
    LV->>DB: update_lesson(lesson, params)
    DB-->>LV: {:ok, lesson}
    LV-->>Instructor: Navigate back to curriculum

The critical detail: video_url is not rendered as an input the user can type into, so it had to be added as a <input type="hidden" name={@form[:video_url].name} value={@form[:video_url].value} /> inside the lesson form. Without it, every “Save Lesson” click would silently clear the field.


Component architecture

VideoEmbedLive is a LiveComponent that owns the modal state and URL normalisation. It communicates upward via send(self(), {:video_saved, url}) — the same pattern as ImageUploadLive.

LessonLive.Edit (parent LiveView)
  ├── <input type="hidden" name="lesson[video_url]" value={@form[:video_url].value} />
  ├── VideoEmbedLive (live_component)
  │     ├── handle_event "open-modal"   → modal_open = true
  │     ├── handle_event "close-modal"  → modal_open = false
  │     └── handle_event "save-video"   → to_embed_url → send {:video_saved, url}
  └── handle_info {:video_saved, url}   → update form + assign :video_url

All event bindings inside the component carry phx-target={@myself} explicitly. Without it, phx-click events inside a live_component bubble to the parent LiveView — causing the parent’s handle_event to crash on unknown events.


Editor UX

Three elements in the lesson editor reflect the video state:

ElementNo videoVideo set
Toolbar buttonGhost, “Add video” titleSlate-blue fill, “Change video” title
BannerHiddenSlate-blue strip with URL and × remove
Preview paneNormalDark 16:9 placeholder above body content

The slate-blue colour (--slate-700 / --slate-100) was chosen specifically because it reads as informational. Moss (--moss-*) is already taken by completed states. Clay (--clay-*) is the brand primary and reads as interactive. Slate is neutral and doesn’t imply success or error.


Domain model

erDiagram
    Lesson {
        int id
        string title
        string body
        string video_url "optional — empty string when not set"
        int position
        int chapter_id
    }
    Chapter ||--o{ Lesson : "contains"

video_url is optional and defaults to "". Validation is a basic URI format check (scheme + host present) with no domain restriction — the field accepts any valid URL. The normalisation from player to embed URL happens at the LiveComponent level, not in the changeset, since it is a UI convenience rather than a domain rule.


Screenshots

Lesson editor — video attached

Lesson editor with video attached

The video camera icon in the toolbar is highlighted in slate-blue. A slate banner below the header shows the Bunny Stream embed URL with a × remove button. The preview pane shows a dark 16:9 placeholder above the markdown content.

Video embed modal

Video embed modal

The modal prompts for a Bunny Stream URL with a hint explaining that the browser address bar URL works. The system converts player URLs to embed format automatically.

Student lesson view — with video

Student lesson view with video

The Bunny Stream player renders above the lesson body in a 16:9 wrapper constrained to the reading-column width. Native fullscreen is available. No video section renders when the field is empty.


Acceptance criteria

  • Click "Add Video" button in the lesson editor toolbar to open a modal
  • Paste a Bunny Stream URL (player or embed format) and save it
  • See the video marked as set in the editor — blue toolbar icon, slate banner, preview placeholder
  • Remove the video via the × button in the banner
  • video_url field added to the Lesson schema with basic URL format validation
  • Player URLs auto-converted to embed URLs; autoplay=false always enforced
  • Render a Bunny Stream iframe above the lesson body in the student view
  • Hide the video section entirely when no URL is set
  • Persist the video URL when "Save Lesson" is submitted

Key decisions

URL normalisation in the component, not the changeset — The player-to-embed conversion and autoplay=false injection are UI conveniences. The domain rule is simply “video_url must be a valid URL”; what the instructor means by pasting a Bunny URL is the domain’s concern. Keeping the normalisation in VideoEmbedLive means the changeset stays simple and the conversion is visible where it’s used.

Hidden input for persistence — The lesson form renders title and body as visible inputs; video_url is managed through the modal and should not be a free-text field. A <input type="hidden"> bound to @form[:video_url].value is the correct LiveView pattern: the form changeset carries the value, the hidden input serialises it on submit, and the whole thing is invisible to the user.

phx-target={@myself} on every event binding — In LiveView, phx-click on an element with no phx-target routes to the root LiveView, not the enclosing component. This caused a hard crash in tests when events were unhandled by the parent. The fix is explicit: every phx-click and phx-submit inside VideoEmbedLive carries its own phx-target={@myself}.

height: 44px over min-height — The pane label bar used min-height to align the toolbar-bearing left pane with the plain “Preview” right pane. This silently failed because .lesson-editor-body carries min-height: 0 (a standard flex overflow fix), which interferes with min-height resolution on nested flex children. height: 44px is absolute and unaffected by parent flex context.

Slate for the video state colour — Clay is already the brand primary (buttons, active nav). Moss is already “completed”. Slate (--slate-700 / --slate-100) is the only semantic colour in the palette not yet assigned a meaning — it reads as informational and neutral, which is exactly right for “this lesson has a video”.


Next up: PBI 0100 — Auto-complete video lessons (marking a Lesson complete automatically when the student finishes watching, using Bunny Stream playback events).