-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
15 changed files
with
665 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
bloom_site/lib/bloom_site_web/components/sound_effect.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 not shown.
34 changes: 34 additions & 0 deletions
34
bloom_site/storybook/bloom_components/sound_effect.story.exs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.