Repository: livebook-dev/kino
Pull request #312 (2023-08-07 15:43:24)

Add options to `Kino.Control.keyboard`
Main PR and discussion in Livebook: livebook-dev/livebook#2145


Patch:
diff --git a/lib/assets/audio/main.js b/lib/assets/audio/main.js
new file mode 100644
index 0000000..a422f0a
--- /dev/null
+++ b/lib/assets/audio/main.js
@@ -0,0 +1,23 @@
+export function init(ctx, [{ type, opts }, content]) {
+  ctx.root.innerHTML = `
+    <div class="root">
+      <audio ${opts} src="${createDataUrl(content, type)}" style="height: 150px"/>
+    </div>
+  `;
+}
+
+function bufferToBase64(buffer) {
+  let binaryString = "";
+  const bytes = new Uint8Array(buffer);
+  const length = bytes.byteLength;
+
+  for (let i = 0; i < length; i++) {
+    binaryString += String.fromCharCode(bytes[i]);
+  }
+
+  return btoa(binaryString);
+};
+
+function createDataUrl(content, type){
+  return `data:${type};base64,${bufferToBase64(content)}`
+};
diff --git a/lib/assets/video/main.js b/lib/assets/video/main.js
new file mode 100644
index 0000000..a3412b9
--- /dev/null
+++ b/lib/assets/video/main.js
@@ -0,0 +1,23 @@
+export function init(ctx, [{ type, opts }, content]) {
+  ctx.root.innerHTML = `
+    <div class="root">
+      <video ${opts} src="${createDataUrl(content, type)}" style="max-height: 500px"/>
+    </div>
+  `;
+}
+
+function bufferToBase64(buffer) {
+  let binaryString = "";
+  const bytes = new Uint8Array(buffer);
+  const length = bytes.byteLength;
+
+  for (let i = 0; i < length; i++) {
+    binaryString += String.fromCharCode(bytes[i]);
+  }
+
+  return btoa(binaryString);
+};
+
+function createDataUrl(content, type){
+  return `data:${type};base64,${bufferToBase64(content)}`
+};
diff --git a/lib/kino/audio.ex b/lib/kino/audio.ex
new file mode 100644
index 0000000..b29085b
--- /dev/null
+++ b/lib/kino/audio.ex
@@ -0,0 +1,82 @@
+defmodule Kino.Audio do
+  @moduledoc """
+  A kino for rendering a binary audio.
+
+  ## Examples
+
+      content = File.read!("/path/to/audio.wav")
+      Kino.Audio.new(content, :wav)
+
+      content = File.read!("/path/to/audio.wav")
+      Kino.Audio.new(content, :wav, autoplay: true, loop: true)
+  """
+
+  use Kino.JS, assets_path: "lib/assets/audio"
+  use Kino.JS.Live
+
+  @type t :: Kino.JS.Live.t()
+
+  @type mime_type :: binary()
+  @type common_audio_type :: :wav | :mp3 | :mpeg | :ogg
+
+  @doc """
+  Creates a new kino displaying the given binary audio.
+
+  The given type be either `:wav`, `:mp3`/`:mpeg`, `:ogg`
+  or a string with audio MIME type.
+
+  ## Options
+
+    * `:autoplay` - whether the audio should start playing as soon as
+      it is rendered. Defaults to `false`
+
+    * `:loop` - whether the audio should loop. Defaults to `false`
+
+    * `:muted` - whether the audio should be muted. Defaults to `false`
+
+  """
+  @spec new(binary(), common_audio_type() | mime_type(), keyword()) :: t()
+  def new(content, type, opts \\ []) when is_binary(content) do
+    opts =
+      Keyword.validate!(opts,
+        autoplay: false,
+        loop: false,
+        muted: false
+      )
+
+    Kino.JS.Live.new(__MODULE__, %{
+      content: content,
+      type: mime_type!(type),
+      opts:
+        Enum.reduce(opts, "controls", fn {opt, val}, acc ->
+          if val do
+            "#{acc} #{opt}"
+          else
+            acc
+          end
+        end)
+    })
+  end
+
+  @impl true
+  def init(assigns, ctx) do
+    {:ok, assign(ctx, assigns)}
+  end
+
+  @impl true
+  def handle_connect(%{assigns: %{content: content, type: type, opts: opts}} = ctx) do
+    payload = {:binary, %{type: type, opts: opts}, content}
+    {:ok, payload, ctx}
+  end
+
+  defp mime_type!(:wav), do: "audio/wav"
+  defp mime_type!(:mp3), do: "audio/mpeg"
+  defp mime_type!(:mpeg), do: "audio/mpeg"
+  defp mime_type!(:ogg), do: "audio/ogg"
+  defp mime_type!("audio/" <> _ = mime_type), do: mime_type
+
+  defp mime_type!(other) do
+    raise ArgumentError,
+          "expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: #{inspect(other)}"
+  end
+end
diff --git a/lib/kino/control.ex b/lib/kino/control.ex
index e98a18a..c4cab7b 100644
--- a/lib/kino/control.ex
+++ b/lib/kino/control.ex
@@ -16,14 +16,18 @@ defmodule Kino.Control do
 
       button = Kino.Control.button("Hello")
 
-  Next, to receive events from the control, a process needs to
-  subscribe to it and specify pick a name to distinguish the
-  events.
+  Next, events need to be received from the control. This can
+  be done either by subscribing a process to the control with
+  `subscribe/2` or by creating an event stream using `stream/1`
+  or `tagged_stream/1` and then registering a callback using
+  `Kino.listen/1`.
+
+  Here, we'll subscribe the current process to events:
 
       Kino.Control.subscribe(button, :hello)
 
-  As the user interacts with the button, the subscribed process
-  receives corresponding events.
+  As the user clicks the button, the subscribed process
+  receives events:
 
       IEx.Helpers.flush()
       #=> {:hello, %{origin: "client1"}}
@@ -81,6 +85,27 @@ defmodule Kino.Control do
   This widget is represented as button that toggles interception
   mode, in which the given keyboard events are captured.
 
+  > #### Keyboard shortcut {:.info}
+  >
+  > As of Livebook v0.11, keyboard controls can be toggled by
+  > focusing the cell and pressing `ctrl + k` (or `⌘ + k` on
+  > MacOS).
+
+  ## Options
+
+  Note that these options require Livebook v0.11 or later.
+
+    * `:default_handlers` - controls Livebook's default keyboard
+      shortcut handlers while the keyboard control is enabled.
+      Must be one of:
+
+      * `:off` (default) - all Livebook keyboard shortcuts are disabled
+
+      * `:on` - all Livebook keyboard shortcuts are enabled
+
+      * `:disable_only` - Livebook keyboard shortcuts are off except
+        for the shortcut to toggle (disable) the control
+
   ## Event info
 
   In addition to standard properties, all events include additional
@@ -123,8 +148,11 @@ defmodule Kino.Control do
       #=> {:keyboard, %{key: "o", origin: "client1", type: :keyup}}
       #=> {:keyboard, %{key: "k", origin: "client1", type: :keyup}}
   """
-  @spec keyboard(list(:keyup | :keydown | :status)) :: t()
-  def keyboard(events) when is_list(events) do
+  @spec keyboard(list(:keyup | :keydown | :status), opts) :: t()
+        when opts: [default_handlers: :off | :on | :disable_only]
+  def keyboard(events, opts \\ []) when is_list(events) do
+    opts = Keyword.validate!(opts, default_handlers: :off)
+
     if events == [] do
       raise ArgumentError, "expected at least one event, got: []"
     end
@@ -136,7 +164,12 @@ defmodule Kino.Control do
       end
     end
 
-    new(%{type: :keyboard, events: events})
+    unless opts[:default_handlers] in [:off, :on, :disable_only] do
+      raise ArgumentError,
+            "when passed, :default_handlers must be one of :off, :on or :disable_only, got: #{inspect(opts[:default_handlers])}"
+    end
+
+    new(%{type: :keyboard, events: events, default_handlers: opts[:default_handlers]})
   end
 
   @doc """
diff --git a/lib/kino/output.ex b/lib/kino/output.ex
index 51150a7..2b147b4 100644
--- a/lib/kino/output.ex
+++ b/lib/kino/output.ex
@@ -399,7 +399,8 @@ defmodule Kino.Output do
             type: :keyboard,
             ref: ref(),
             destination: Process.dest(),
-            events: list(:keyup | :keydown | :status)
+            events: list(:keyup | :keydown | :status),
+            default_handlers: :off | :on | :disable_only
           }
           | %{
               type: :button,
diff --git a/lib/kino/shorts.ex b/lib/kino/shorts.ex
index a1b343e..f746301 100644
--- a/lib/kino/shorts.ex
+++ b/lib/kino/shorts.ex
@@ -33,7 +33,7 @@ defmodule Kino.Shorts do
   def data_table(tabular, opts \\ []), do: Kino.DataTable.new(tabular, opts)
 
   @doc """
-  Renders images of any given format.
+  Renders an image of any given format.
 
   It is a wrapper around `Kino.Image.new/2`.
 
@@ -47,6 +47,36 @@ defmodule Kino.Shorts do
   @spec image(binary(), Kino.Image.common_image_type() | Kino.Image.mime_type()) :: Kino.Image.t()
   def image(content, type), do: Kino.Image.new(content, type)
 
+  @doc """
+  Renders an audio of any given format.
+
+  It is a wrapper around `Kino.Audio.new/2`.
+
+  ## Examples
+
+      import Kino.Shorts
+      content = File.read!("/path/to/audio.wav")
+      audio(content, :wav)
+
+  """
+  @spec audio(binary(), Kino.Audio.common_audio_type() | Kino.Audio.mime_type()) :: Kino.Audio.t()
+  def audio(content, type), do: Kino.Audio.new(content, type)
+
+  @doc """
+  Renders a video of any given format.
+
+  It is a wrapper around `Kino.Audio.new/2`.
+
+  ## Examples
+
+      import Kino.Shorts
+      content = File.read!("/path/to/video.mp4")
+      video(content, :mp4)
+
+  """
+  @spec video(binary(), Kino.Video.common_video_type() | Kino.Video.mime_type()) :: Kino.Video.t()
+  def video(content, type), do: Kino.Video.new(content, type)
+
   @doc """
   Renders a file download button.
 
diff --git a/lib/kino/video.ex b/lib/kino/video.ex
new file mode 100644
index 0000000..2575d70
--- /dev/null
+++ b/lib/kino/video.ex
@@ -0,0 +1,84 @@
+defmodule Kino.Video do
+  @moduledoc """
+  A kino for rendering a binary video.
+
+  ## Examples
+
+      content = File.read!("/path/to/video.mp4")
+      Kino.Video.new(content, :mp4)
+
+      content = File.read!("/path/to/video.mp4")
+      Kino.Video.new(content, :mp4, autoplay: true, loop: true)
+
+  """
+
+  use Kino.JS, assets_path: "lib/assets/video"
+  use Kino.JS.Live
+
+  @type t :: Kino.JS.Live.t()
+
+  @type mime_type :: binary()
+  @type common_video_type :: :mp4 | :ogg | :avi | :mwv | :mov
+
+  @doc """
+  Creates a new kino displaying the given binary video.
+
+  The given type be either `:mp4`, `:ogg`, `:avi`, `:wmv`, `:mov`
+  or a string with video MIME type.
+
+  ## Options
+
+    * `:autoplay` - whether the video should start playing as soon as
+      it is rendered. Defaults to `false`
+
+    * `:loop` - whether the video should loop. Defaults to `false`
+
+    * `:muted` - whether the video should be muted. Defaults to `false`
+
+  """
+  @spec new(binary(), common_video_type() | mime_type(), list()) :: t()
+  def new(content, type, opts \\ []) when is_binary(content) do
+    opts =
+      Keyword.validate!(opts,
+        autoplay: false,
+        loop: false,
+        muted: false
+      )
+
+    Kino.JS.Live.new(__MODULE__, %{
+      content: content,
+      type: mime_type!(type),
+      opts:
+        Enum.reduce(opts, "controls", fn {opt, val}, acc ->
+          if val do
+            "#{acc} #{opt}"
+          else
+            acc
+          end
+        end)
+    })
+  end
+
+  @impl true
+  def init(assigns, ctx) do
+    {:ok, assign(ctx, assigns)}
+  end
+
+  @impl true
+  def handle_connect(%{assigns: %{content: content, type: type, opts: opts}} = ctx) do
+    payload = {:binary, %{type: type, opts: opts}, content}
+    {:ok, payload, ctx}
+  end
+
+  defp mime_type!(:mp4), do: "video/mp4"
+  defp mime_type!(:ogg), do: "video/ogg"
+  defp mime_type!(:avi), do: "video/x-msvideo"
+  defp mime_type!(:wmv), do: "video/x-ms-wmv"
+  defp mime_type!(:mov), do: "video/quicktime"
+  defp mime_type!("video/" <> _ = mime_type), do: mime_type
+
+  defp mime_type!(other) do
+    raise ArgumentError,
+          "expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: #{inspect(other)}"
+  end
+end
diff --git a/mix.exs b/mix.exs
index 6cf40ff..fa901c2 100644
--- a/mix.exs
+++ b/mix.exs
@@ -76,7 +76,9 @@ defmodule Kino.MixProject do
           Kino.Markdown,
           Kino.Mermaid,
           Kino.Text,
-          Kino.Tree
+          Kino.Tree,
+          Kino.Audio,
+          Kino.Video
         ]
       ]
     ]


Test patch:
diff --git a/test/kino/audio_test.exs b/test/kino/audio_test.exs
new file mode 100644
index 0000000..927e695
--- /dev/null
+++ b/test/kino/audio_test.exs
@@ -0,0 +1,31 @@
+defmodule Kino.AudioTest do
+  use Kino.LivebookCase, async: true
+
+  describe "new/2" do
+    test "raises an error for a non-image MIME type" do
+      assert_raise ArgumentError,
+                   ~s{expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: "application/json"},
+                   fn ->
+                     Kino.Audio.new(<<>>, "application/json")
+                   end
+    end
+
+    test "raises an error for an invalid type shorthand" do
+      assert_raise ArgumentError,
+                   "expected audio type to be either :wav, :mp3, :mpeg, :ogg, or an audio MIME type string, got: :invalid",
+                   fn ->
+                     Kino.Audio.new(<<>>, :invalid)
+                   end
+    end
+
+    test "mime type shorthand and default opts" do
+      kino = Kino.Audio.new(<<>>, :wav)
+      assert {:binary, %{type: "audio/wav", opts: "controls"}, <<>>} == connect(kino)
+    end
+
+    test "custom mime type and custom opts" do
+      kino = Kino.Audio.new(<<>>, "audio/mp2", loop: true)
+      assert {:binary, %{type: "audio/mp2", opts: "controls loop"}, <<>>} == connect(kino)
+    end
+  end
+end
diff --git a/test/kino/control_test.exs b/test/kino/control_test.exs
index 61eabac..d12637a 100644
--- a/test/kino/control_test.exs
+++ b/test/kino/control_test.exs
@@ -15,6 +15,14 @@ defmodule Kino.ControlTest do
                      Kino.Control.keyboard([:keyword])
                    end
     end
+
+    test "raises an error when invalid options are passed" do
+      assert_raise ArgumentError,
+                   "when passed, :default_handlers must be one of :off, :on or :disable_only, got: :foo",
+                   fn ->
+                     Kino.Control.keyboard([:keydown], default_handlers: :foo)
+                   end
+    end
   end
 
   describe "form/1" do
diff --git a/test/kino/shorts_test.exs b/test/kino/shorts_test.exs
index 672eb95..cb5fab1 100644
--- a/test/kino/shorts_test.exs
+++ b/test/kino/shorts_test.exs
@@ -42,4 +42,12 @@ defmodule Kino.ShortsTest do
   test "image" do
     assert image("foo", "image/jpg") == Kino.Image.new("foo", "image/jpg")
   end
+
+  test "audio" do
+    assert %Kino.JS.Live{} = audio("foo", "audio/wav")
+  end
+
+  test "video" do
+    assert %Kino.JS.Live{} = video("foo", "video/mp4")
+  end
 end
diff --git a/test/kino/video_test.exs b/test/kino/video_test.exs
new file mode 100644
index 0000000..5f3a6a4
--- /dev/null
+++ b/test/kino/video_test.exs
@@ -0,0 +1,31 @@
+defmodule Kino.VideoTest do
+  use Kino.LivebookCase, async: true
+
+  describe "new/2" do
+    test "raises an error for a non-image MIME type" do
+      assert_raise ArgumentError,
+                   ~s{expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: "application/json"},
+                   fn ->
+                     Kino.Video.new(<<>>, "application/json")
+                   end
+    end
+
+    test "raises an error for an invalid type shorthand" do
+      assert_raise ArgumentError,
+                   "expected video type to be either :mp4, :ogg, :avi, :wmv, :mov, or an video MIME type string, got: :invalid",
+                   fn ->
+                     Kino.Video.new(<<>>, :invalid)
+                   end
+    end
+
+    test "mime type shorthand and default opts" do
+      kino = Kino.Video.new(<<>>, :mp4)
+      assert {:binary, %{type: "video/mp4", opts: "controls"}, <<>>} == connect(kino)
+    end
+
+    test "custom mime type and custom opts" do
+      kino = Kino.Video.new(<<>>, "video/h123", loop: true)
+      assert {:binary, %{type: "video/h123", opts: "controls loop"}, <<>>} == connect(kino)
+    end
+  end
+end