Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sound effect component #7

Merged
merged 21 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
232b8db
implemented the js hook using the webaudio api
C-Sinclair May 2, 2024
313203d
added livecomponent to translate events from pubsub to js hook
C-Sinclair May 2, 2024
0a3cb1e
updated template generation to swap out pubsub
C-Sinclair May 2, 2024
5e27ad2
added cache of audio files to avoid overfetching
C-Sinclair May 2, 2024
2935f17
fixed compile time errors
C-Sinclair May 2, 2024
d4f7334
generated templates
C-Sinclair May 2, 2024
79cef1d
installed sound effect into storybook
C-Sinclair May 2, 2024
09cf83c
corrected story and component to accept a slot
C-Sinclair May 3, 2024
0103202
added sound_effect to shell help options
C-Sinclair May 3, 2024
0a1c0ca
check for and resume audio context before triggering sounds
C-Sinclair May 5, 2024
32e9756
updated sound effect component to pop when unmuting
C-Sinclair May 5, 2024
b88ea88
generated templates and added pop mp3 sound
C-Sinclair May 5, 2024
089bc0c
installed sound effect into bloom site
C-Sinclair May 5, 2024
68a8755
updated module documentation
C-Sinclair May 5, 2024
2ca3721
Merge remote-tracking branch 'upstream/main' into sound-effect
C-Sinclair May 5, 2024
330273d
Merge remote-tracking branch 'upstream/main' into sound-effect
C-Sinclair May 5, 2024
8dc1e41
generate templates
C-Sinclair May 5, 2024
2bc1f8a
fixed install help tests
C-Sinclair May 5, 2024
ae443d7
added class attr for changing root button
C-Sinclair May 5, 2024
d98cb19
mix format
C-Sinclair May 5, 2024
39ed8d2
Merge branch 'main' into sound-effect
C-Sinclair May 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading