Skip to content

Commit

Permalink
sound effect component (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisgreg authored Sep 11, 2024
2 parents 55ec00b + 39ed8d2 commit 824eace
Show file tree
Hide file tree
Showing 15 changed files with 665 additions and 10 deletions.
70 changes: 70 additions & 0 deletions assets/js/sound_effect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @type {{
* mounted: () => void,
* handleEvent: (event: string, callback: (payload: any) => void) => void
* audioCtx: AudioContext,
* audioCache: Record<string, AudioBuffer>,
* }}
*/
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();
},
};
3 changes: 2 additions & 1 deletion bloom_site/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions bloom_site/assets/js/storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
70 changes: 70 additions & 0 deletions bloom_site/assets/vendor/hooks/sound_effect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @type {{
* mounted: () => void,
* handleEvent: (event: string, callback: (payload: any) => void) => void
* audioCtx: AudioContext,
* audioCache: Record<string, AudioBuffer>,
* }}
*/
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();
},
};
2 changes: 1 addition & 1 deletion bloom_site/lib/bloom_site_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions bloom_site/lib/bloom_site_web/components/sound_effect.ex
Original file line number Diff line number Diff line change
@@ -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"""
<button
id="sound-effect"
phx-hook="soundEffectHook"
phx-click="toggle-sound"
phx-target={@myself}
aria-label={"Turn sound effects #{if(@disabled, do: "on", else: "off")}"}
class=""
>
<.speaker_icon :if={!Map.get(assigns, :inner_block) || @inner_block == []} disabled={@disabled} />
<%= render_slot(@inner_block, %{disabled: @disabled}) %>
</button>
"""
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"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="w-4 h-4">
<rect width="256" height="256" fill="none" />
<path d="M163.52,24.81a8,8,0,0,0-8.43.88L85.25,80H40A16,16,0,0,0,24,96v64a16,16,0,0,0,16,16H85.25l69.84,54.31A7.94,7.94,0,0,0,160,232a8,8,0,0,0,8-8V32A8,8,0,0,0,163.52,24.81Z" />
<path
:if={@disabled}
d="M235.31,128l18.35-18.34a8,8,0,0,0-11.32-11.32L224,116.69,205.66,98.34a8,8,0,0,0-11.32,11.32L212.69,128l-18.35,18.34a8,8,0,0,0,11.32,11.32L224,139.31l18.34,18.35a8,8,0,0,0,11.32-11.32Z"
/>
</svg>
"""
end
end
Binary file added bloom_site/priv/static/audio/pop.mp3
Binary file not shown.
34 changes: 34 additions & 0 deletions bloom_site/storybook/bloom_components/sound_effect.story.exs
Original file line number Diff line number Diff line change
@@ -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|<h1>Custom Icon</h1>|
]
}
]
end
end
Loading

0 comments on commit 824eace

Please sign in to comment.