defmodule PhoenixKit.Modules.Shared.Components.Video do @moduledoc """ Responsive YouTube embed component. Supports both explicit `video_id` and full YouTube URLs. Optional attributes: * `autoplay` - "true" | "false" (default) * `muted` - "true" | "false" (default) * `controls` - "true" | "false" (default) * `loop` - "true" | "false" (default) * `start` - start time in seconds * `ratio` - aspect ratio string (e.g., "16:9", "4:3", "1:1") Component body text is rendered as a caption beneath the player. """ use Phoenix.Component @standard_youtube_hosts [ "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com" ] @short_youtube_hosts [ "youtu.be" ] attr(:attributes, :map, default: %{}) attr(:variant, :string, default: "default") attr(:content, :string, default: nil) def render(assigns) do attrs = assigns.attributes || %{} video_id = attrs |> Map.get("video_id") |> presence() || extract_video_id(Map.get(attrs, "url")) assigns = assigns |> assign(:video_id, video_id) |> assign(:embed_url, build_embed_url(video_id, attrs)) |> assign(:aspect_ratio_class, ratio_class(Map.get(attrs, "ratio", "16:9"))) |> assign(:has_caption?, has_caption?(assigns.content)) |> assign(:caption, assigns.content) ~H""" <%= if @video_id do %>
<%= if @has_caption? do %>

{@caption}

<% end %>
<% else %>
Unable to load video – please supply a valid YouTube `video_id` or `url`.
<% end %> """ end defp build_embed_url(nil, _attrs), do: nil defp build_embed_url(video_id, attrs) do base = "https://www.youtube.com/embed/#{video_id}" params = [ {"autoplay", truthy?(Map.get(attrs, "autoplay"))}, {"mute", truthy?(Map.get(attrs, "muted"))}, {"controls", controls_value(Map.get(attrs, "controls"))}, {"loop", truthy?(Map.get(attrs, "loop"))}, {"start", parse_integer(Map.get(attrs, "start"))} ] |> Enum.reduce([], fn {"loop", true}, acc -> [{"loop", "1"}, {"playlist", video_id} | acc] {"loop", _}, acc -> acc {"autoplay", true}, acc -> [{"autoplay", "1"} | acc] {"autoplay", _}, acc -> acc {"mute", true}, acc -> [{"mute", "1"} | acc] {"mute", _}, acc -> acc {"controls", value}, acc when value in ["0", "1"] -> [{"controls", value} | acc] {"start", nil}, acc -> acc {"start", value}, acc -> [{"start", Integer.to_string(value)} | acc] _, acc -> acc end) |> Enum.reverse() if params == [] do base else base <> "?" <> URI.encode_query(params) end end defp ratio_class(ratio) do case ratio do "4:3" -> "aspect-[4/3]" "1:1" -> "aspect-square" "21:9" -> "aspect-[21/9]" _ -> "aspect-video" end end defp extract_video_id(nil), do: nil defp extract_video_id(url) when is_binary(url) do with trimmed when trimmed != "" <- String.trim(url), {:ok, uri} <- parse_uri(trimmed), {:ok, host_type} <- classify_host(uri.host) do case host_type do :short -> extract_short_id(uri.path) :standard -> extract_standard_id(uri) end else _ -> nil end end defp extract_video_id(_), do: nil defp presence(nil), do: nil defp presence(value) when is_binary(value) do value = String.trim(value) if value == "", do: nil, else: value end defp presence(_), do: nil defp truthy?(true), do: true defp truthy?(false), do: false defp truthy?(value) when is_binary(value) do value = value |> String.trim() |> String.downcase() value in ["true", "1", "yes", "on"] end defp truthy?(value) when is_integer(value), do: value != 0 defp truthy?(_), do: false defp controls_value(value) do if explicitly_false?(value), do: "0", else: "1" end defp explicitly_false?(value) when is_binary(value) do value |> String.trim() |> String.downcase() |> case do "" -> false v -> v in ["false", "0", "no", "off"] end end defp explicitly_false?(false), do: true defp explicitly_false?(0), do: true defp explicitly_false?(_), do: false defp parse_integer(nil), do: nil defp parse_integer(value) when is_binary(value) do value |> String.trim() |> case do "" -> nil number -> case Integer.parse(number) do {int, _} when int >= 0 -> int _ -> nil end end end defp parse_integer(value) when is_integer(value) and value >= 0, do: value defp parse_integer(_), do: nil defp has_caption?(value) when is_binary(value) do String.trim(value) != "" end defp has_caption?(_), do: false defp parse_uri(string) when is_binary(string) do case URI.parse(string) do %URI{scheme: scheme, host: host} = uri when scheme in ["http", "https"] and is_binary(host) -> {:ok, uri} _ -> :error end end defp classify_host(host) when is_binary(host) do host = String.downcase(host) cond do host in @short_youtube_hosts -> {:ok, :short} host in @standard_youtube_hosts -> {:ok, :standard} true -> :error end end defp extract_short_id(nil), do: nil defp extract_short_id(path) do path |> String.trim_leading("/") |> String.split("/", trim: true) |> List.first() end defp extract_standard_id(%URI{path: path} = uri) do if is_binary(path) and String.starts_with?(path, "/embed/") do path |> String.split("/", trim: true) |> List.last() else uri.query |> decode_query() |> Map.get("v") end end defp decode_query(nil), do: %{} defp decode_query(query) do query |> URI.query_decoder() |> Enum.into(%{}) end end