empowered.house ↗

media/video-player.ex

Video player

A video player frame with a play button, a title, the time and a progress bar, for demos and walkthroughs.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.media_video_player animated />
</div>
default
isometric
grid scene · custom copy
not started · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Month-end close, walkthrough" Up to 36 characters.
meta string "Recorded by Marta Nowak · Finance"
elapsed string "02:14"
duration string "06:20"
progress float 0.35 The watched share of the video, 0.0 to 1.0.
scene "hills" | "peak" | "sun" | "grid" | "blocks" "hills" The placeholder picture in the frame.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/media/video-player.ex
video-player.ex
defmodule EHVisuals.Media.VideoPlayer do
  @moduledoc """
  A video player: the frame with a placeholder picture and a play button, the
  title, the time and a progress bar.
  """

  use EHVisuals.Composition,
    name: "media/video-player",
    title: "Video player",
    description:
      "A video player frame with a play button, a title, the time and a progress bar, for demos and walkthroughs."

  attr(:title, :string, default: "Month-end close, walkthrough", doc: "Up to 36 characters.")
  attr(:meta, :string, default: "Recorded by Marta Nowak · Finance")
  attr(:elapsed, :string, default: "02:14")
  attr(:duration, :string, default: "06:20")

  attr(:progress, :float,
    default: 0.35,
    doc: "The watched share of the video, 0.0 to 1.0."
  )

  attr(:scene, :string,
    default: "hills",
    values: ~w(hills peak sun grid blocks),
    doc: "The placeholder picture in the frame."
  )

  attr(:label, :string,
    default: "A video player with a play button and a progress bar a third of the way"
  )

  frame_attrs()

  @spec media_video_player(map()) :: Phoenix.LiveView.Rendered.t()
  def media_video_player(assigns) do
    progress = min(max(assigns.progress, 0.0), 1.0)

    assigns =
      assigns
      |> assign(:picture, scene(assigns.scene, 10, 10, 380, 208))
      |> assign(:played_w, Float.round(progress * 360, 1))
      |> assign(:knob_x, Float.round(20 + progress * 360, 1))

    ~H"""
    <.frame name="media/video-player" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 400 292">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height="291" rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <rect class="ehv-well" x="10.5" y="10.5" width="379" height="207" rx="8" />
            <circle
              :if={@picture.sun}
              class="ehv-fill-4 ehv-a-pop ehv-i-5"
              cx={elem(@picture.sun, 0)}
              cy={elem(@picture.sun, 1)}
              r={elem(@picture.sun, 2)}
            />
            <path
              class="ehv-media-video-player__scene ehv-round ehv-a-draw ehv-i-2"
              d={@picture.lines}
              pathLength="1"
            />
          </g>
          <g class="ehv-a-pop ehv-i-4">
            <g class="ehv-media-video-player__play">
              <circle class="ehv-fill-canvas" cx="200" cy="114" r="36" />
              <circle class="ehv-fill-lime" cx="200" cy="114" r="27" />
              <path class="ehv-media-video-player__glyph" d="M193 102.5 l18 11.5 l-18 11.5 z" />
            </g>
          </g>
          <g class="ehv-a-fade ehv-i-5">
            <text class="ehv-t-strong" x="20" y="244" font-size="13.5">{@title}</text>
            <text class="ehv-mono" x="20" y="259" font-size="8.5">{String.upcase(@meta)}</text>
            <text class="ehv-mono" x="380" y="244" font-size="9" text-anchor="end">
              <tspan class="ehv-mono ehv-t-ink">{@elapsed}</tspan>
              / {@duration}
            </text>
          </g>
          <g class="ehv-a-fade ehv-i-6">
            <rect class="ehv-fill-soft" x="20" y="272" width="360" height="4" rx="2" />
            <rect
              class="ehv-fill-2 ehv-a-grow-x ehv-i-7"
              x="20"
              y="272"
              width={@played_w}
              height="4"
              rx="2"
            />
            <g class="ehv-a-pop ehv-i-10">
              <circle class="ehv-fill-ink ehv-media-video-player__knob" cx={@knob_x} cy="274" r="5.5" />
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # A placeholder picture in the box at {x, y} of size {w, h}: hairlines for a
  # horizon and a landscape, and an optional sun as {cx, cy, r}.
  defp scene(kind, x, y, w, h) do
    px = fn f -> Float.round(x + f * w, 1) end
    py = fn f -> Float.round(y + f * h, 1) end
    r = Float.round(min(w, h) * 0.08, 1)

    case kind do
      "peak" ->
        %{
          lines:
            "M#{px.(0.06)} #{py.(0.8)} H#{px.(0.94)} " <>
              "M#{px.(0.1)} #{py.(0.8)} L#{px.(0.25)} #{py.(0.34)} L#{px.(0.32)} #{py.(0.54)} " <>
              "L#{px.(0.36)} #{py.(0.47)} L#{px.(0.45)} #{py.(0.8)} " <>
              "M#{px.(0.57)} #{py.(0.8)} L#{px.(0.73)} #{py.(0.4)} L#{px.(0.9)} #{py.(0.8)}",
          sun: {px.(0.84), py.(0.2), r}
        }

      "sun" ->
        %{
          lines:
            "M#{px.(0.06)} #{py.(0.74)} H#{px.(0.94)} " <>
              "M#{px.(0.66)} #{py.(0.74)} A#{Float.round(0.1 * w, 1)} #{Float.round(0.1 * w, 1)} 0 0 1 #{px.(0.86)} #{py.(0.74)} " <>
              "M#{px.(0.69)} #{py.(0.82)} H#{px.(0.83)} M#{px.(0.73)} #{py.(0.89)} H#{px.(0.79)} " <>
              "M#{px.(0.1)} #{py.(0.74)} C#{px.(0.17)} #{py.(0.48)} #{px.(0.29)} #{py.(0.48)} #{px.(0.38)} #{py.(0.74)}",
          sun: nil
        }

      "grid" ->
        rays =
          Enum.map_join([0.06, 0.2, 0.35, 0.5, 0.65, 0.8, 0.94], " ", fn f ->
            "M#{px.(0.5)} #{py.(0.73)} L#{px.(f)} #{py.(0.92)}"
          end)

        rungs =
          Enum.map_join([0.34, 0.64, 1.0], " ", fn t ->
            "M#{px.(0.5 - 0.44 * t)} #{py.(0.73 + 0.19 * t)} H#{px.(0.5 + 0.44 * t)}"
          end)

        %{
          lines: "M#{px.(0.06)} #{py.(0.73)} H#{px.(0.94)} #{rays} #{rungs}",
          sun: {px.(0.84), py.(0.2), r}
        }

      "blocks" ->
        blocks =
          Enum.map_join(
            [
              {0.1, 0.19, 0.56},
              {0.22, 0.34, 0.34},
              {0.4, 0.48, 0.72},
              {0.52, 0.6, 0.75},
              {0.66, 0.78, 0.3},
              {0.81, 0.9, 0.58}
            ],
            " ",
            fn {left, right, top} ->
              "M#{px.(left)} #{py.(0.8)} V#{py.(top)} H#{px.(right)} V#{py.(0.8)}"
            end
          )

        %{lines: "M#{px.(0.06)} #{py.(0.8)} H#{px.(0.94)} #{blocks}", sun: nil}

      _hills ->
        %{
          lines:
            "M#{px.(0.06)} #{py.(0.8)} H#{px.(0.94)} " <>
              "M#{px.(0.08)} #{py.(0.8)} C#{px.(0.16)} #{py.(0.4)} #{px.(0.3)} #{py.(0.4)} #{px.(0.4)} #{py.(0.8)} " <>
              "M#{px.(0.6)} #{py.(0.8)} C#{px.(0.68)} #{py.(0.34)} #{px.(0.84)} #{py.(0.38)} #{px.(0.93)} #{py.(0.8)} " <>
              "M#{px.(0.34)} #{py.(0.8)} C#{px.(0.42)} #{py.(0.7)} #{px.(0.58)} #{py.(0.7)} #{px.(0.66)} #{py.(0.8)}",
          sun: {px.(0.84), py.(0.2), r}
        }
    end
  end
end
assets/css/media/video-player.css
video-player.css
/* media/video-player */
.ehv-media-video-player { --ehv-max-w: 440px; }

.ehv-media-video-player__scene { fill: none; stroke: var(--ehv-ink-3); stroke-width: 1.25; }
.ehv-media-video-player__glyph { fill: var(--ehv-on-lime); }

.ehv-media-video-player__play { transform-box: fill-box; transform-origin: center; }
.ehv--hover .ehv-media-video-player__play { transition: transform 220ms var(--ehv-ease); }
.ehv--hover:hover .ehv-media-video-player__play { transform: scale(1.08); }
.ehv--hover .ehv-media-video-player__knob { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-media-video-player__knob { r: 7; }