diff --git a/assets/js/sound_effect.js b/assets/js/sound_effect.js new file mode 100644 index 0000000..f57f6d5 --- /dev/null +++ b/assets/js/sound_effect.js @@ -0,0 +1,70 @@ +/** + * @type {{ + * mounted: () => void, + * handleEvent: (event: string, callback: (payload: any) => void) => void + * audioCtx: AudioContext, + * audioCache: Record, + * }} + */ +export const soundEffectHook = { + mounted() { + // Initialize Audio Context + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + + // Try to resume AudioContext in case it's suspended + this.resumeAudioContext(); + + // Cache for storing fetched sounds + this.audioCache = {}; + + this.handleEvent("play_sound", ({ sound }) => { + console.info("playing sound", sound); + this.playSound(sound); + }); + }, + + /** + * @param {string} url + */ + async playSound(url) { + try { + // Ensure the AudioContext is running + await this.resumeAudioContext(); + + // Use cached sound if available, otherwise fetch, decode, and cache it + if (!this.audioCache[url]) { + // Fetch sound file + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + // Decode audio data to be used by the AudioContext + const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer); + // Store the decoded buffer in cache + this.audioCache[url] = audioBuffer; + } + // Play the sound from the cache + this.playAudioBuffer(this.audioCache[url]); + } catch (err) { + console.error("Error playing sound:", err); + } + }, + + playAudioBuffer(audioBuffer) { + // Create a buffer source node + const source = this.audioCtx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(this.audioCtx.destination); // Connect to the output (speakers) + source.start(0); // Play immediately + }, + + /** + * Checks for a suspended AudioContext and attempts to resume it + */ + async resumeAudioContext() { + if (this.audioCtx.state === "suspended") { + // Attempt to resume the AudioContext + return this.audioCtx.resume(); + } + // Return a resolved promise for consistency in asynchronous behavior + return Promise.resolve(); + }, +}; diff --git a/bloom_site/assets/js/app.js b/bloom_site/assets/js/app.js index c065f51..304ec60 100644 --- a/bloom_site/assets/js/app.js +++ b/bloom_site/assets/js/app.js @@ -21,12 +21,13 @@ import "phoenix_html"; import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; import topbar from "../vendor/topbar"; +import { soundEffectHook } from "../vendor/hooks/sound_effect"; let csrfToken = document .querySelector("meta[name='csrf-token']") .getAttribute("content"); let liveSocket = new LiveSocket("/live", Socket, { - params: { _csrf_token: csrfToken }, + params: { _csrf_token: csrfToken, hooks: { soundEffectHook } }, }); // Show progress bar on live navigation and form submits diff --git a/bloom_site/assets/js/storybook.js b/bloom_site/assets/js/storybook.js index 9ad9805..80c0ed6 100644 --- a/bloom_site/assets/js/storybook.js +++ b/bloom_site/assets/js/storybook.js @@ -6,14 +6,15 @@ // import * as Params from "./params"; // import * as Uploaders from "./uploaders"; -// (function () { -// window.storybook = { Hooks, Params, Uploaders }; -// })(); +import { soundEffectHook } from "../vendor/hooks/sound_effect"; +(function () { + window.storybook = { Hooks: { soundEffectHook } }; +})(); // If your components require alpinejs, you'll need to start // alpine after the DOM is loaded and pass in an onBeforeElUpdated -// +// // import Alpine from 'alpinejs' // window.Alpine = Alpine // document.addEventListener('DOMContentLoaded', () => { diff --git a/bloom_site/assets/vendor/hooks/sound_effect.js b/bloom_site/assets/vendor/hooks/sound_effect.js new file mode 100644 index 0000000..f57f6d5 --- /dev/null +++ b/bloom_site/assets/vendor/hooks/sound_effect.js @@ -0,0 +1,70 @@ +/** + * @type {{ + * mounted: () => void, + * handleEvent: (event: string, callback: (payload: any) => void) => void + * audioCtx: AudioContext, + * audioCache: Record, + * }} + */ +export const soundEffectHook = { + mounted() { + // Initialize Audio Context + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + + // Try to resume AudioContext in case it's suspended + this.resumeAudioContext(); + + // Cache for storing fetched sounds + this.audioCache = {}; + + this.handleEvent("play_sound", ({ sound }) => { + console.info("playing sound", sound); + this.playSound(sound); + }); + }, + + /** + * @param {string} url + */ + async playSound(url) { + try { + // Ensure the AudioContext is running + await this.resumeAudioContext(); + + // Use cached sound if available, otherwise fetch, decode, and cache it + if (!this.audioCache[url]) { + // Fetch sound file + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + // Decode audio data to be used by the AudioContext + const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer); + // Store the decoded buffer in cache + this.audioCache[url] = audioBuffer; + } + // Play the sound from the cache + this.playAudioBuffer(this.audioCache[url]); + } catch (err) { + console.error("Error playing sound:", err); + } + }, + + playAudioBuffer(audioBuffer) { + // Create a buffer source node + const source = this.audioCtx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(this.audioCtx.destination); // Connect to the output (speakers) + source.start(0); // Play immediately + }, + + /** + * Checks for a suspended AudioContext and attempts to resume it + */ + async resumeAudioContext() { + if (this.audioCtx.state === "suspended") { + // Attempt to resume the AudioContext + return this.audioCtx.resume(); + } + // Return a resolved promise for consistency in asynchronous behavior + return Promise.resolve(); + }, +}; diff --git a/bloom_site/lib/bloom_site_web.ex b/bloom_site/lib/bloom_site_web.ex index 4718100..ee1c6a1 100644 --- a/bloom_site/lib/bloom_site_web.ex +++ b/bloom_site/lib/bloom_site_web.ex @@ -17,7 +17,7 @@ defmodule BloomSiteWeb do those modules here. """ - def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt audio) def router do quote do diff --git a/bloom_site/lib/bloom_site_web/components/sound_effect.ex b/bloom_site/lib/bloom_site_web/components/sound_effect.ex new file mode 100644 index 0000000..327eb2b --- /dev/null +++ b/bloom_site/lib/bloom_site_web/components/sound_effect.ex @@ -0,0 +1,132 @@ +defmodule BloomSiteWeb.Components.SoundEffect do + @moduledoc """ + A component to play sound effects in the user's browser + triggered by Elixir events from the backend + + The component itself renders a button for disabling sound effects. + For accessibility, the user needs to be able to toggle sound effects on and off according to their preference. + + Triggering a sound effect is done using the `play_sound` function in the Sound Effect module. + + ```elixir + SoundEffect.play_sound("/audio/pop.mp3") + ``` + + The first argument needs to be the path to the sound file in your assets directory. + + Audio assets need to be served by your own backend server. A convenient way to do this is to place them in the `assets/static` directory of your Phoenix application, and add `audio` to the `static_paths` function in your `_web` module. + """ + use Phoenix.LiveComponent + + # The singleton id of the sound effect component + @id "sound_effect" + + # The sound effect to play when toggling sound effects on + @activate_sound "/audio/pop.mp3" + + # minimum time in milliseconds between sound effects + @debounce_time 400 + + def mount(params, _session, socket) do + {:ok, assign(socket, disabled: Map.get(params, :disabled, false))} + end + + @impl true + def update(assigns, socket) do + # Check if user sound effects are currently disabled, or a new assign is setting disabled + is_disabled? = Map.get(socket.assigns, :disabled) || Map.get(assigns, :disabled, false) + + currently_playing? = Map.get(socket.assigns, :playing, false) + + socket = + socket + |> assign(assigns) + |> then(fn socket -> + should_play_sound? = not is_nil(Map.get(assigns, :play_sound)) + + if should_play_sound? and not currently_playing? and not is_disabled? do + # unset the playing lock after the debounce time + send_update_after(__MODULE__, [id: @id, playing: false], @debounce_time) + + socket + |> push_event("play_sound", %{sound: assigns[:play_sound]}) + |> assign(playing: true) + else + socket + end + end) + + {:ok, socket} + end + + attr(:disabled, :boolean, default: false) + slot(:inner_block, default: []) + + @impl true + def render(assigns) do + ~H""" + + """ + end + + attr(:disabled, :boolean, default: false) + slot(:inner_block) + + @doc """ + The sound effect component for playing sounds in the user's browser + """ + def sound_effect(assigns) do + assigns = assign(assigns, id: @id) + + ~H""" + <.live_component id={@id} module={__MODULE__} disabled={@disabled} inner_block={@inner_block} /> + """ + end + + @impl true + def handle_event("toggle-sound", _params, socket = %{assigns: %{disabled: true}}) do + socket = + socket + |> assign(disabled: false) + |> push_event("play_sound", %{sound: @activate_sound}) + + {:noreply, socket} + end + + @impl true + def handle_event("toggle-sound", _params, socket) do + {:noreply, assign(socket, disabled: true)} + end + + @doc """ + Trigger a sound effect to be played + """ + def play_sound(sound) do + send_update(__MODULE__, id: @id, play_sound: sound) + end + + attr(:disabled, :boolean, default: false) + + defp speaker_icon(assigns) do + ~H""" + + + + + + """ + end +end diff --git a/bloom_site/priv/static/audio/pop.mp3 b/bloom_site/priv/static/audio/pop.mp3 new file mode 100644 index 0000000..5d0b241 Binary files /dev/null and b/bloom_site/priv/static/audio/pop.mp3 differ diff --git a/bloom_site/storybook/bloom_components/sound_effect.story.exs b/bloom_site/storybook/bloom_components/sound_effect.story.exs new file mode 100644 index 0000000..0cb7633 --- /dev/null +++ b/bloom_site/storybook/bloom_components/sound_effect.story.exs @@ -0,0 +1,34 @@ +defmodule BloomSite.Storybook.BloomComponents.SoundEffect do + use PhoenixStorybook.Story, :live_component + + def component, do: BloomSiteWeb.Components.SoundEffect + + def container, do: :iframe + + def attributes, do: [%Attr{id: :disabled, type: :boolean, default: false}] + + def slots, do: [%Slot{id: :inner_block}] + + def variations do + [ + %Variation{ + id: :default, + attributes: %{ + disabled: false + }, + }, + %Variation{ + id: :disabled, + attributes: %{ + disabled: true + }, + }, + %Variation{ + id: :custom_icon, + slots: [ + ~s|

Custom Icon

| + ] + } + ] + end +end diff --git a/lib/bloom/components/sound_effect.ex b/lib/bloom/components/sound_effect.ex new file mode 100644 index 0000000..f3609fa --- /dev/null +++ b/lib/bloom/components/sound_effect.ex @@ -0,0 +1,144 @@ +defmodule Bloom.Components.SoundEffect do + @moduledoc """ + A component to play sound effects in the user's browser + triggered by Elixir events from the backend + + The component itself renders a button for disabling sound effects. + For accessibility, the user needs to be able to toggle sound effects on and off according to their preference. + + Triggering a sound effect is done using the `play_sound` function in the Sound Effect module. + + ```elixir + SoundEffect.play_sound("/audio/pop.mp3") + ``` + + The first argument needs to be the path to the sound file in your assets directory. + + Audio assets need to be served by your own backend server. A convenient way to do this is to place them in the `assets/static` directory of your Phoenix application, and add `audio` to the `static_paths` function in your `_web` module. + """ + use Phoenix.LiveComponent + + # The singleton id of the sound effect component + @id "sound_effect" + + # The sound effect to play when toggling sound effects on + @activate_sound "/audio/pop.mp3" + + # minimum time in milliseconds between sound effects + @debounce_time 400 + + def mount(params, _session, socket) do + {:ok, assign(socket, disabled: Map.get(params, :disabled, false))} + end + + @impl true + def update(assigns, socket) do + # Check if user sound effects are currently disabled, or a new assign is setting disabled + is_disabled? = Map.get(socket.assigns, :disabled) || Map.get(assigns, :disabled, false) + + currently_playing? = Map.get(socket.assigns, :playing, false) + + socket = + socket + |> assign(assigns) + |> then(fn socket -> + should_play_sound? = not is_nil(Map.get(assigns, :play_sound)) + + if should_play_sound? and not currently_playing? and not is_disabled? do + # unset the playing lock after the debounce time + send_update_after(__MODULE__, [id: @id, playing: false], @debounce_time) + + socket + |> push_event("play_sound", %{sound: assigns[:play_sound]}) + |> assign(playing: true) + else + socket + end + end) + + {:ok, socket} + end + + attr(:disabled, :boolean, default: false) + attr(:class, :string, default: "", doc: "CSS class for root button") + slot(:inner_block, default: []) + + @impl true + def render(assigns) do + ~H""" + + """ + end + + attr(:disabled, :boolean, default: false) + attr(:class, :string, default: "", doc: "CSS class for root button") + slot(:inner_block) + + @doc """ + The sound effect component for playing sounds in the user's browser + """ + def sound_effect(assigns) do + assigns = assign(assigns, id: @id) + + ~H""" + <.live_component + id={@id} + module={__MODULE__} + disabled={@disabled} + inner_block={@inner_block} + class={@class} + /> + """ + end + + @impl true + def handle_event("toggle-sound", _params, socket = %{assigns: %{disabled: true}}) do + socket = + socket + |> assign(disabled: false) + |> push_event("play_sound", %{sound: @activate_sound}) + + {:noreply, socket} + end + + @impl true + def handle_event("toggle-sound", _params, socket) do + {:noreply, assign(socket, disabled: true)} + end + + @doc """ + Trigger a sound effect to be played + """ + def play_sound(sound) do + send_update(__MODULE__, id: @id, play_sound: sound) + end + + attr(:disabled, :boolean, default: false) + + defp speaker_icon(assigns) do + ~H""" + + + + + + """ + end +end diff --git a/lib/tasks/generate_templates.ex b/lib/tasks/generate_templates.ex index 8aa34f0..3911ffb 100644 --- a/lib/tasks/generate_templates.ex +++ b/lib/tasks/generate_templates.ex @@ -30,7 +30,8 @@ defmodule Mix.Tasks.Bloom.GenerateTemplates do defp convert_to_template(content) do content = content |> escape_eex_expressions() - Regex.replace(~r/Bloom.Components/, content, "<%= @module_name %>Web.Components") + content = Regex.replace(~r/Bloom.Components/, content, "<%= @module_name %>Web.Components") + Regex.replace(~r/Bloom.PubSub/, content, "<%= @module_name %>.PubSub") end defp escape_eex_expressions(content) do diff --git a/lib/tasks/install.ex b/lib/tasks/install.ex index 5ec454a..62ecdd4 100644 --- a/lib/tasks/install.ex +++ b/lib/tasks/install.ex @@ -81,7 +81,7 @@ defmodule Mix.Tasks.Bloom.Install do Mix.shell().info("Usage: mix bloom.install [component_name]") Mix.shell().info( - "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee" + "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee | sound_effect" ) end diff --git a/priv/audio/pop.mp3 b/priv/audio/pop.mp3 new file mode 100644 index 0000000..5d0b241 Binary files /dev/null and b/priv/audio/pop.mp3 differ diff --git a/priv/templates/sound_effect.ex b/priv/templates/sound_effect.ex new file mode 100644 index 0000000..82eb29a --- /dev/null +++ b/priv/templates/sound_effect.ex @@ -0,0 +1,132 @@ +defmodule <%= @module_name %>Web.Components.SoundEffect do + @moduledoc """ + A component to play sound effects in the user's browser + triggered by Elixir events from the backend + + The component itself renders a button for disabling sound effects. + For accessibility, the user needs to be able to toggle sound effects on and off according to their preference. + + Triggering a sound effect is done using the `play_sound` function in the Sound Effect module. + + ```elixir + SoundEffect.play_sound("/audio/pop.mp3") + ``` + + The first argument needs to be the path to the sound file in your assets directory. + + Audio assets need to be served by your own backend server. A convenient way to do this is to place them in the `assets/static` directory of your Phoenix application, and add `audio` to the `static_paths` function in your `_web` module. + """ + use Phoenix.LiveComponent + + # The singleton id of the sound effect component + @id "sound_effect" + + # The sound effect to play when toggling sound effects on + @activate_sound "/audio/pop.mp3" + + # minimum time in milliseconds between sound effects + @debounce_time 400 + + def mount(params, _session, socket) do + {:ok, assign(socket, disabled: Map.get(params, :disabled, false))} + end + + @impl true + def update(assigns, socket) do + # Check if user sound effects are currently disabled, or a new assign is setting disabled + is_disabled? = Map.get(socket.assigns, :disabled) || Map.get(assigns, :disabled, false) + + currently_playing? = Map.get(socket.assigns, :playing, false) + + socket = + socket + |> assign(assigns) + |> then(fn socket -> + should_play_sound? = not is_nil(Map.get(assigns, :play_sound)) + + if should_play_sound? and not currently_playing? and not is_disabled? do + # unset the playing lock after the debounce time + send_update_after(__MODULE__, [id: @id, playing: false], @debounce_time) + + socket + |> push_event("play_sound", %{sound: assigns[:play_sound]}) + |> assign(playing: true) + else + socket + end + end) + + {:ok, socket} + end + + attr(:disabled, :boolean, default: false) + slot(:inner_block, default: []) + + @impl true + def render(assigns) do + ~H""" + + """ + end + + attr(:disabled, :boolean, default: false) + slot(:inner_block) + + @doc """ + The sound effect component for playing sounds in the user's browser + """ + def sound_effect(assigns) do + assigns = assign(assigns, id: @id) + + ~H""" + <.live_component id={@id} module={__MODULE__} disabled={@disabled} inner_block={@inner_block} /> + """ + end + + @impl true + def handle_event("toggle-sound", _params, socket = %{assigns: %{disabled: true}}) do + socket = + socket + |> assign(disabled: false) + |> push_event("play_sound", %{sound: @activate_sound}) + + {:noreply, socket} + end + + @impl true + def handle_event("toggle-sound", _params, socket) do + {:noreply, assign(socket, disabled: true)} + end + + @doc """ + Trigger a sound effect to be played + """ + def play_sound(sound) do + send_update(__MODULE__, id: @id, play_sound: sound) + end + + attr(:disabled, :boolean, default: false) + + defp speaker_icon(assigns) do + ~H""" + + + + + + """ + end +end diff --git a/priv/templates/sound_effect.js b/priv/templates/sound_effect.js new file mode 100644 index 0000000..f57f6d5 --- /dev/null +++ b/priv/templates/sound_effect.js @@ -0,0 +1,70 @@ +/** + * @type {{ + * mounted: () => void, + * handleEvent: (event: string, callback: (payload: any) => void) => void + * audioCtx: AudioContext, + * audioCache: Record, + * }} + */ +export const soundEffectHook = { + mounted() { + // Initialize Audio Context + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + + // Try to resume AudioContext in case it's suspended + this.resumeAudioContext(); + + // Cache for storing fetched sounds + this.audioCache = {}; + + this.handleEvent("play_sound", ({ sound }) => { + console.info("playing sound", sound); + this.playSound(sound); + }); + }, + + /** + * @param {string} url + */ + async playSound(url) { + try { + // Ensure the AudioContext is running + await this.resumeAudioContext(); + + // Use cached sound if available, otherwise fetch, decode, and cache it + if (!this.audioCache[url]) { + // Fetch sound file + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + // Decode audio data to be used by the AudioContext + const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer); + // Store the decoded buffer in cache + this.audioCache[url] = audioBuffer; + } + // Play the sound from the cache + this.playAudioBuffer(this.audioCache[url]); + } catch (err) { + console.error("Error playing sound:", err); + } + }, + + playAudioBuffer(audioBuffer) { + // Create a buffer source node + const source = this.audioCtx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(this.audioCtx.destination); // Connect to the output (speakers) + source.start(0); // Play immediately + }, + + /** + * Checks for a suspended AudioContext and attempts to resume it + */ + async resumeAudioContext() { + if (this.audioCtx.state === "suspended") { + // Attempt to resume the AudioContext + return this.audioCtx.resume(); + } + // Return a resolved promise for consistency in asynchronous behavior + return Promise.resolve(); + }, +}; diff --git a/test/tasks/install_test.exs b/test/tasks/install_test.exs index 56a5a78..0b369ca 100644 --- a/test/tasks/install_test.exs +++ b/test/tasks/install_test.exs @@ -39,7 +39,7 @@ defmodule Mix.Tasks.Bloom.InstallTest do expect(ShellMock, :info, fn msg -> assert msg == - "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee" + "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee | sound_effect" end) Mix.Tasks.Bloom.Install.run(["nonexistent_component"]) @@ -52,7 +52,7 @@ defmodule Mix.Tasks.Bloom.InstallTest do expect(ShellMock, :info, fn msg -> assert msg == - "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee" + "Available components: avatar | glow_button | code_snippet | hero | gradient_text | bento_grid | card | marquee | sound_effect" end) Mix.Tasks.Bloom.Install.run([])