Skip to content

Commit 824eace

Browse files
authored
sound effect component (#7)
2 parents 55ec00b + 39ed8d2 commit 824eace

File tree

15 files changed

+665
-10
lines changed

15 files changed

+665
-10
lines changed

assets/js/sound_effect.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @type {{
3+
* mounted: () => void,
4+
* handleEvent: (event: string, callback: (payload: any) => void) => void
5+
* audioCtx: AudioContext,
6+
* audioCache: Record<string, AudioBuffer>,
7+
* }}
8+
*/
9+
export const soundEffectHook = {
10+
mounted() {
11+
// Initialize Audio Context
12+
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
13+
14+
// Try to resume AudioContext in case it's suspended
15+
this.resumeAudioContext();
16+
17+
// Cache for storing fetched sounds
18+
this.audioCache = {};
19+
20+
this.handleEvent("play_sound", ({ sound }) => {
21+
console.info("playing sound", sound);
22+
this.playSound(sound);
23+
});
24+
},
25+
26+
/**
27+
* @param {string} url
28+
*/
29+
async playSound(url) {
30+
try {
31+
// Ensure the AudioContext is running
32+
await this.resumeAudioContext();
33+
34+
// Use cached sound if available, otherwise fetch, decode, and cache it
35+
if (!this.audioCache[url]) {
36+
// Fetch sound file
37+
const response = await fetch(url);
38+
const arrayBuffer = await response.arrayBuffer();
39+
// Decode audio data to be used by the AudioContext
40+
const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
41+
// Store the decoded buffer in cache
42+
this.audioCache[url] = audioBuffer;
43+
}
44+
// Play the sound from the cache
45+
this.playAudioBuffer(this.audioCache[url]);
46+
} catch (err) {
47+
console.error("Error playing sound:", err);
48+
}
49+
},
50+
51+
playAudioBuffer(audioBuffer) {
52+
// Create a buffer source node
53+
const source = this.audioCtx.createBufferSource();
54+
source.buffer = audioBuffer;
55+
source.connect(this.audioCtx.destination); // Connect to the output (speakers)
56+
source.start(0); // Play immediately
57+
},
58+
59+
/**
60+
* Checks for a suspended AudioContext and attempts to resume it
61+
*/
62+
async resumeAudioContext() {
63+
if (this.audioCtx.state === "suspended") {
64+
// Attempt to resume the AudioContext
65+
return this.audioCtx.resume();
66+
}
67+
// Return a resolved promise for consistency in asynchronous behavior
68+
return Promise.resolve();
69+
},
70+
};

bloom_site/assets/js/app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import "phoenix_html";
2121
import { Socket } from "phoenix";
2222
import { LiveSocket } from "phoenix_live_view";
2323
import topbar from "../vendor/topbar";
24+
import { soundEffectHook } from "../vendor/hooks/sound_effect";
2425

2526
let csrfToken = document
2627
.querySelector("meta[name='csrf-token']")
2728
.getAttribute("content");
2829
let liveSocket = new LiveSocket("/live", Socket, {
29-
params: { _csrf_token: csrfToken },
30+
params: { _csrf_token: csrfToken, hooks: { soundEffectHook } },
3031
});
3132

3233
// Show progress bar on live navigation and form submits

bloom_site/assets/js/storybook.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
// import * as Params from "./params";
77
// import * as Uploaders from "./uploaders";
88

9-
// (function () {
10-
// window.storybook = { Hooks, Params, Uploaders };
11-
// })();
9+
import { soundEffectHook } from "../vendor/hooks/sound_effect";
1210

11+
(function () {
12+
window.storybook = { Hooks: { soundEffectHook } };
13+
})();
1314

1415
// If your components require alpinejs, you'll need to start
1516
// alpine after the DOM is loaded and pass in an onBeforeElUpdated
16-
//
17+
//
1718
// import Alpine from 'alpinejs'
1819
// window.Alpine = Alpine
1920
// document.addEventListener('DOMContentLoaded', () => {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @type {{
3+
* mounted: () => void,
4+
* handleEvent: (event: string, callback: (payload: any) => void) => void
5+
* audioCtx: AudioContext,
6+
* audioCache: Record<string, AudioBuffer>,
7+
* }}
8+
*/
9+
export const soundEffectHook = {
10+
mounted() {
11+
// Initialize Audio Context
12+
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
13+
14+
// Try to resume AudioContext in case it's suspended
15+
this.resumeAudioContext();
16+
17+
// Cache for storing fetched sounds
18+
this.audioCache = {};
19+
20+
this.handleEvent("play_sound", ({ sound }) => {
21+
console.info("playing sound", sound);
22+
this.playSound(sound);
23+
});
24+
},
25+
26+
/**
27+
* @param {string} url
28+
*/
29+
async playSound(url) {
30+
try {
31+
// Ensure the AudioContext is running
32+
await this.resumeAudioContext();
33+
34+
// Use cached sound if available, otherwise fetch, decode, and cache it
35+
if (!this.audioCache[url]) {
36+
// Fetch sound file
37+
const response = await fetch(url);
38+
const arrayBuffer = await response.arrayBuffer();
39+
// Decode audio data to be used by the AudioContext
40+
const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
41+
// Store the decoded buffer in cache
42+
this.audioCache[url] = audioBuffer;
43+
}
44+
// Play the sound from the cache
45+
this.playAudioBuffer(this.audioCache[url]);
46+
} catch (err) {
47+
console.error("Error playing sound:", err);
48+
}
49+
},
50+
51+
playAudioBuffer(audioBuffer) {
52+
// Create a buffer source node
53+
const source = this.audioCtx.createBufferSource();
54+
source.buffer = audioBuffer;
55+
source.connect(this.audioCtx.destination); // Connect to the output (speakers)
56+
source.start(0); // Play immediately
57+
},
58+
59+
/**
60+
* Checks for a suspended AudioContext and attempts to resume it
61+
*/
62+
async resumeAudioContext() {
63+
if (this.audioCtx.state === "suspended") {
64+
// Attempt to resume the AudioContext
65+
return this.audioCtx.resume();
66+
}
67+
// Return a resolved promise for consistency in asynchronous behavior
68+
return Promise.resolve();
69+
},
70+
};

bloom_site/lib/bloom_site_web.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule BloomSiteWeb do
1717
those modules here.
1818
"""
1919

20-
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
20+
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt audio)
2121

2222
def router do
2323
quote do
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
defmodule BloomSiteWeb.Components.SoundEffect do
2+
@moduledoc """
3+
A component to play sound effects in the user's browser
4+
triggered by Elixir events from the backend
5+
6+
The component itself renders a button for disabling sound effects.
7+
For accessibility, the user needs to be able to toggle sound effects on and off according to their preference.
8+
9+
Triggering a sound effect is done using the `play_sound` function in the Sound Effect module.
10+
11+
```elixir
12+
SoundEffect.play_sound("/audio/pop.mp3")
13+
```
14+
15+
The first argument needs to be the path to the sound file in your assets directory.
16+
17+
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.
18+
"""
19+
use Phoenix.LiveComponent
20+
21+
# The singleton id of the sound effect component
22+
@id "sound_effect"
23+
24+
# The sound effect to play when toggling sound effects on
25+
@activate_sound "/audio/pop.mp3"
26+
27+
# minimum time in milliseconds between sound effects
28+
@debounce_time 400
29+
30+
def mount(params, _session, socket) do
31+
{:ok, assign(socket, disabled: Map.get(params, :disabled, false))}
32+
end
33+
34+
@impl true
35+
def update(assigns, socket) do
36+
# Check if user sound effects are currently disabled, or a new assign is setting disabled
37+
is_disabled? = Map.get(socket.assigns, :disabled) || Map.get(assigns, :disabled, false)
38+
39+
currently_playing? = Map.get(socket.assigns, :playing, false)
40+
41+
socket =
42+
socket
43+
|> assign(assigns)
44+
|> then(fn socket ->
45+
should_play_sound? = not is_nil(Map.get(assigns, :play_sound))
46+
47+
if should_play_sound? and not currently_playing? and not is_disabled? do
48+
# unset the playing lock after the debounce time
49+
send_update_after(__MODULE__, [id: @id, playing: false], @debounce_time)
50+
51+
socket
52+
|> push_event("play_sound", %{sound: assigns[:play_sound]})
53+
|> assign(playing: true)
54+
else
55+
socket
56+
end
57+
end)
58+
59+
{:ok, socket}
60+
end
61+
62+
attr(:disabled, :boolean, default: false)
63+
slot(:inner_block, default: [])
64+
65+
@impl true
66+
def render(assigns) do
67+
~H"""
68+
<button
69+
id="sound-effect"
70+
phx-hook="soundEffectHook"
71+
phx-click="toggle-sound"
72+
phx-target={@myself}
73+
aria-label={"Turn sound effects #{if(@disabled, do: "on", else: "off")}"}
74+
class=""
75+
>
76+
<.speaker_icon :if={!Map.get(assigns, :inner_block) || @inner_block == []} disabled={@disabled} />
77+
<%= render_slot(@inner_block, %{disabled: @disabled}) %>
78+
</button>
79+
"""
80+
end
81+
82+
attr(:disabled, :boolean, default: false)
83+
slot(:inner_block)
84+
85+
@doc """
86+
The sound effect component for playing sounds in the user's browser
87+
"""
88+
def sound_effect(assigns) do
89+
assigns = assign(assigns, id: @id)
90+
91+
~H"""
92+
<.live_component id={@id} module={__MODULE__} disabled={@disabled} inner_block={@inner_block} />
93+
"""
94+
end
95+
96+
@impl true
97+
def handle_event("toggle-sound", _params, socket = %{assigns: %{disabled: true}}) do
98+
socket =
99+
socket
100+
|> assign(disabled: false)
101+
|> push_event("play_sound", %{sound: @activate_sound})
102+
103+
{:noreply, socket}
104+
end
105+
106+
@impl true
107+
def handle_event("toggle-sound", _params, socket) do
108+
{:noreply, assign(socket, disabled: true)}
109+
end
110+
111+
@doc """
112+
Trigger a sound effect to be played
113+
"""
114+
def play_sound(sound) do
115+
send_update(__MODULE__, id: @id, play_sound: sound)
116+
end
117+
118+
attr(:disabled, :boolean, default: false)
119+
120+
defp speaker_icon(assigns) do
121+
~H"""
122+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="w-4 h-4">
123+
<rect width="256" height="256" fill="none" />
124+
<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" />
125+
<path
126+
:if={@disabled}
127+
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"
128+
/>
129+
</svg>
130+
"""
131+
end
132+
end

bloom_site/priv/static/audio/pop.mp3

74.5 KB
Binary file not shown.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule BloomSite.Storybook.BloomComponents.SoundEffect do
2+
use PhoenixStorybook.Story, :live_component
3+
4+
def component, do: BloomSiteWeb.Components.SoundEffect
5+
6+
def container, do: :iframe
7+
8+
def attributes, do: [%Attr{id: :disabled, type: :boolean, default: false}]
9+
10+
def slots, do: [%Slot{id: :inner_block}]
11+
12+
def variations do
13+
[
14+
%Variation{
15+
id: :default,
16+
attributes: %{
17+
disabled: false
18+
},
19+
},
20+
%Variation{
21+
id: :disabled,
22+
attributes: %{
23+
disabled: true
24+
},
25+
},
26+
%Variation{
27+
id: :custom_icon,
28+
slots: [
29+
~s|<h1>Custom Icon</h1>|
30+
]
31+
}
32+
]
33+
end
34+
end

0 commit comments

Comments
 (0)