|
1 | | -import { useEffect, useState } from "react" |
2 | | -import { ExamplePage, StarterPanel } from "../../components/ExamplePage.tsx" |
3 | | -import { MediaPlayerMachine } from "./machine.ts" |
| 1 | +import { useAtomSet, useAtomValue } from "@effect/atom-react" |
| 2 | +import { Option } from "effect" |
| 3 | +import { AsyncResult } from "effect/unstable/reactivity" |
| 4 | +import { useCallback, useEffect, useState } from "react" |
| 5 | +import { ExamplePage } from "../../components/ExamplePage.tsx" |
| 6 | +import { mediaPlayerAtom } from "./atoms.ts" |
| 7 | +import { initialAudioSettings, initialPlaybackData, MediaPlayerEvent, MediaPlayerStates } from "./schemas.ts" |
| 8 | + |
| 9 | +interface AudioSource { |
| 10 | + readonly name: string |
| 11 | + readonly url: string |
| 12 | +} |
| 13 | + |
| 14 | +const formatTime = (seconds: number): string => { |
| 15 | + if (!Number.isFinite(seconds)) return "0:00" |
| 16 | + |
| 17 | + const minutes = Math.floor(seconds / 60) |
| 18 | + const remainingSeconds = Math.floor(seconds % 60) |
| 19 | + return `${minutes}:${String(remainingSeconds).padStart(2, "0")}` |
| 20 | +} |
4 | 21 |
|
5 | 22 | export function MediaPlayerPage() { |
6 | | - void MediaPlayerMachine |
| 23 | + const snapshotResult = useAtomValue(mediaPlayerAtom.snapshot) |
| 24 | + const send = useAtomSet(mediaPlayerAtom.send) |
| 25 | + const [source, setSource] = useState<AudioSource>() |
7 | 26 |
|
8 | | - const [source, setSource] = useState<string>() |
| 27 | + const registerAudioElement = useCallback( |
| 28 | + (audioRef: HTMLAudioElement | null) => { |
| 29 | + if (audioRef !== null) { |
| 30 | + send(MediaPlayerEvent.cases.AudioElementMounted.make({ audioRef })) |
| 31 | + } |
| 32 | + }, |
| 33 | + [send] |
| 34 | + ) |
9 | 35 |
|
10 | 36 | useEffect( |
11 | 37 | () => () => { |
12 | | - if (source !== undefined) URL.revokeObjectURL(source) |
| 38 | + if (source !== undefined) URL.revokeObjectURL(source.url) |
13 | 39 | }, |
14 | 40 | [source] |
15 | 41 | ) |
16 | 42 |
|
17 | 43 | return ( |
18 | 44 | <ExamplePage |
19 | 45 | title="Media player" |
20 | | - summary="Use a real audio element as an external system and translate its lifecycle events into a typed machine protocol." |
| 46 | + summary="Coordinate a compound transport lifecycle and independent sound modes inside a parallel Effect statechart." |
21 | 47 | machineFile="src/examples/media-player/machine.ts" |
22 | 48 | > |
23 | | - <StarterPanel> |
24 | | - <label className="file-picker"> |
25 | | - <span>Choose an audio file</span> |
26 | | - <input |
27 | | - type="file" |
28 | | - accept="audio/*" |
29 | | - onChange={(event) => { |
30 | | - const file = event.currentTarget.files?.[0] |
31 | | - if (file === undefined) return |
32 | | - if (source !== undefined) URL.revokeObjectURL(source) |
33 | | - setSource(URL.createObjectURL(file)) |
34 | | - }} |
35 | | - /> |
36 | | - </label> |
37 | | - <audio className="audio-player" src={source} controls preload="metadata"> |
38 | | - Your browser does not support audio playback. |
39 | | - </audio> |
40 | | - <h2>Translate DOM events</h2> |
41 | | - <p> |
42 | | - The statechart separates playback and volume. Wire <code>playing</code>, <code>pause</code>,{" "} |
43 | | - <code>waiting</code>,{` `} |
44 | | - <code>canplay</code>, <code>ended</code>, and <code>error</code> into the prepared events. |
45 | | - </p> |
46 | | - </StarterPanel> |
| 49 | + {AsyncResult.isInitial(snapshotResult) ? |
| 50 | + <div className="media-player-message">Starting the media player machine…</div> : |
| 51 | + AsyncResult.isFailure(snapshotResult) ? |
| 52 | + <div className="media-player-message is-error">The media player machine failed to start.</div> : |
| 53 | + ( |
| 54 | + (() => { |
| 55 | + const runtimeSnapshot = snapshotResult.value |
| 56 | + const snapshot = runtimeSnapshot.state |
| 57 | + const paused = Option.getOrUndefined( |
| 58 | + MediaPlayerStates.get(snapshot, "Player.transport.Ready.Paused") |
| 59 | + ) |
| 60 | + const playing = Option.getOrUndefined( |
| 61 | + MediaPlayerStates.get(snapshot, "Player.transport.Ready.Playing") |
| 62 | + ) |
| 63 | + const buffering = Option.getOrUndefined( |
| 64 | + MediaPlayerStates.get(snapshot, "Player.transport.Ready.Buffering") |
| 65 | + ) |
| 66 | + const restarting = Option.getOrUndefined( |
| 67 | + MediaPlayerStates.get(snapshot, "Player.transport.Ready.Restarting") |
| 68 | + ) |
| 69 | + const ended = Option.getOrUndefined( |
| 70 | + MediaPlayerStates.get(snapshot, "Player.transport.Ready.Ended") |
| 71 | + ) |
| 72 | + const failed = Option.getOrUndefined( |
| 73 | + MediaPlayerStates.get(snapshot, "Player.transport.Failed") |
| 74 | + ) |
| 75 | + const audible = Option.getOrUndefined( |
| 76 | + MediaPlayerStates.get(snapshot, "Player.settings.Audible") |
| 77 | + ) |
| 78 | + const muted = Option.getOrUndefined( |
| 79 | + MediaPlayerStates.get(snapshot, "Player.settings.Muted") |
| 80 | + ) |
| 81 | + const playback = paused ?? playing ?? buffering ?? restarting ?? ended |
| 82 | + const playbackData = playback ?? initialPlaybackData |
| 83 | + const settings = audible ?? muted ?? initialAudioSettings |
| 84 | + const isMuted = muted !== undefined |
| 85 | + const isPaused = paused !== undefined |
| 86 | + const isPlaying = playing !== undefined |
| 87 | + const isBuffering = buffering !== undefined |
| 88 | + const isEnded = ended !== undefined |
| 89 | + const canPlay = isPaused || isEnded |
| 90 | + const canPause = isPlaying || isBuffering |
| 91 | + const canRestart = canPlay || canPause |
| 92 | + const transportPath = isPlaying ? |
| 93 | + "Player.transport.Ready.Playing" |
| 94 | + : isBuffering ? |
| 95 | + "Player.transport.Ready.Buffering" |
| 96 | + : restarting !== undefined ? |
| 97 | + "Player.transport.Ready.Restarting" |
| 98 | + : isEnded ? |
| 99 | + "Player.transport.Ready.Ended" |
| 100 | + : isPaused ? |
| 101 | + "Player.transport.Ready.Paused" |
| 102 | + : failed !== undefined ? |
| 103 | + "Player.transport.Failed" |
| 104 | + : MediaPlayerStates.matches(snapshot, "Player.transport.Loading") ? |
| 105 | + "Player.transport.Loading" |
| 106 | + : "Player.transport.Empty" |
| 107 | + const stateName = transportPath.slice(transportPath.lastIndexOf(".") + 1) |
| 108 | + const settingsPath = isMuted ? "Player.settings.Muted" : "Player.settings.Audible" |
| 109 | + const loudness = playing?.loudness ?? null |
| 110 | + const loudnessLevel = Math.min(100, Math.round((loudness?.rms ?? 0) * 220)) |
| 111 | + const peakLevel = Math.min(100, Math.round((loudness?.peak ?? 0) * 100)) |
| 112 | + |
| 113 | + return ( |
| 114 | + <div className="media-player-console"> |
| 115 | + <header className="media-player-toolbar"> |
| 116 | + <div> |
| 117 | + <p className="media-player-kicker">Effect audio session</p> |
| 118 | + <h2>{source?.name ?? "Choose a local audio track"}</h2> |
| 119 | + </div> |
| 120 | + <div className="media-player-toolbar-actions"> |
| 121 | + <span className={`media-state-chip is-${stateName.toLowerCase()}`}>{stateName}</span> |
| 122 | + <label className="media-file-button"> |
| 123 | + <span>{source === undefined ? "Choose audio" : "Replace audio"}</span> |
| 124 | + <input |
| 125 | + type="file" |
| 126 | + accept="audio/*" |
| 127 | + onChange={(event) => { |
| 128 | + const file = event.currentTarget.files?.[0] |
| 129 | + if (file === undefined) return |
| 130 | + const next = { name: file.name, url: URL.createObjectURL(file) } |
| 131 | + setSource(next) |
| 132 | + send(MediaPlayerEvent.cases.SourceSelected.make({ url: next.url })) |
| 133 | + }} |
| 134 | + /> |
| 135 | + </label> |
| 136 | + </div> |
| 137 | + </header> |
| 138 | + |
| 139 | + <audio |
| 140 | + ref={registerAudioElement} |
| 141 | + className="media-audio-element" |
| 142 | + preload="auto" |
| 143 | + onWaiting={() => send(MediaPlayerEvent.cases.MediaWaiting.make({}))} |
| 144 | + onCanPlay={() => send(MediaPlayerEvent.cases.MediaCanPlay.make({}))} |
| 145 | + onError={({ currentTarget }) => |
| 146 | + send(MediaPlayerEvent.cases.MediaFailed.make({ |
| 147 | + message: currentTarget.error?.message ?? "The selected audio file could not be loaded" |
| 148 | + }))} |
| 149 | + onTimeUpdate={({ currentTarget }) => |
| 150 | + send(MediaPlayerEvent.cases.TimeUpdated.make({ currentTime: currentTarget.currentTime }))} |
| 151 | + onEnded={({ currentTarget }) => |
| 152 | + send(MediaPlayerEvent.cases.PlaybackEnded.make({ currentTime: currentTarget.currentTime }))} |
| 153 | + /> |
| 154 | + |
| 155 | + <div className="media-player-layout"> |
| 156 | + <section className="media-now-playing" aria-label="Playback controls"> |
| 157 | + <div className={`media-artwork${isPlaying ? " is-playing" : ""}`} aria-hidden="true"> |
| 158 | + <div className="media-record"> |
| 159 | + <span /> |
| 160 | + </div> |
| 161 | + <div className="media-tonearm" /> |
| 162 | + </div> |
| 163 | + |
| 164 | + <div className="media-time-row"> |
| 165 | + <span>Current time</span> |
| 166 | + <strong>{formatTime(playbackData.currentTime)}</strong> |
| 167 | + </div> |
| 168 | + |
| 169 | + <div className="media-controls"> |
| 170 | + <button |
| 171 | + type="button" |
| 172 | + disabled={!canPlay || source === undefined} |
| 173 | + onClick={() => send(MediaPlayerEvent.cases.PlayRequested.make({}))} |
| 174 | + > |
| 175 | + Play |
| 176 | + </button> |
| 177 | + <button |
| 178 | + type="button" |
| 179 | + disabled={!canPause} |
| 180 | + onClick={() => send(MediaPlayerEvent.cases.PauseRequested.make({}))} |
| 181 | + > |
| 182 | + Pause |
| 183 | + </button> |
| 184 | + <button |
| 185 | + type="button" |
| 186 | + disabled={!canRestart} |
| 187 | + onClick={() => send(MediaPlayerEvent.cases.RestartRequested.make({}))} |
| 188 | + > |
| 189 | + Restart |
| 190 | + </button> |
| 191 | + </div> |
| 192 | + |
| 193 | + <section className="media-meter" aria-label="Live loudness"> |
| 194 | + <div className="media-panel-heading"> |
| 195 | + <span>Loudness</span> |
| 196 | + <strong>{loudness?.decibels.toFixed(1) ?? "-80.0"} dB</strong> |
| 197 | + </div> |
| 198 | + <div className="media-meter-track"> |
| 199 | + <span className="media-meter-fill" style={{ width: `${loudnessLevel}%` }} /> |
| 200 | + <span className="media-meter-peak" style={{ left: `${peakLevel}%` }} /> |
| 201 | + </div> |
| 202 | + <p>RMS {loudnessLevel}% · Peak {peakLevel}%</p> |
| 203 | + </section> |
| 204 | + </section> |
| 205 | + |
| 206 | + <aside className="media-player-inspector"> |
| 207 | + <section className="media-settings"> |
| 208 | + <div className="media-panel-heading"> |
| 209 | + <span>Audio settings</span> |
| 210 | + <strong>{isMuted ? "Muted" : "Audible"} · {Math.round(settings.volume * 100)}%</strong> |
| 211 | + </div> |
| 212 | + <label> |
| 213 | + <span>Volume</span> |
| 214 | + <input |
| 215 | + type="range" |
| 216 | + min="0" |
| 217 | + max="1" |
| 218 | + step="0.01" |
| 219 | + value={settings.volume} |
| 220 | + onChange={({ currentTarget }) => |
| 221 | + send(MediaPlayerEvent.cases.VolumeChanged.make({ |
| 222 | + volume: currentTarget.valueAsNumber |
| 223 | + }))} |
| 224 | + /> |
| 225 | + </label> |
| 226 | + <label> |
| 227 | + <span>Muted</span> |
| 228 | + <input |
| 229 | + type="checkbox" |
| 230 | + checked={isMuted} |
| 231 | + onChange={({ currentTarget }) => |
| 232 | + send( |
| 233 | + currentTarget.checked |
| 234 | + ? MediaPlayerEvent.cases.MuteRequested.make({}) |
| 235 | + : MediaPlayerEvent.cases.UnmuteRequested.make({}) |
| 236 | + )} |
| 237 | + /> |
| 238 | + </label> |
| 239 | + <label> |
| 240 | + <span>Speed</span> |
| 241 | + <select |
| 242 | + value={settings.playbackRate} |
| 243 | + onChange={({ currentTarget }) => |
| 244 | + send(MediaPlayerEvent.cases.PlaybackRateChanged.make({ |
| 245 | + playbackRate: Number(currentTarget.value) |
| 246 | + }))} |
| 247 | + > |
| 248 | + <option value={0.75}>0.75x</option> |
| 249 | + <option value={1}>1x</option> |
| 250 | + <option value={1.25}>1.25x</option> |
| 251 | + <option value={1.5}>1.5x</option> |
| 252 | + <option value={2}>2x</option> |
| 253 | + </select> |
| 254 | + </label> |
| 255 | + </section> |
| 256 | + |
| 257 | + <section className="media-state-preview"> |
| 258 | + <div className="media-panel-heading"> |
| 259 | + <span>Machine snapshot</span> |
| 260 | + <strong>{runtimeSnapshot.status}</strong> |
| 261 | + </div> |
| 262 | + <pre> |
| 263 | + {JSON.stringify({ |
| 264 | + status: runtimeSnapshot.status, |
| 265 | + states: { |
| 266 | + transport: transportPath, |
| 267 | + settings: settingsPath |
| 268 | + }, |
| 269 | + data: { |
| 270 | + transport: playback ?? failed ?? null, |
| 271 | + settings |
| 272 | + } |
| 273 | + }, null, 2)} |
| 274 | + </pre> |
| 275 | + </section> |
| 276 | + |
| 277 | + {failed !== undefined && <p className="media-error-message" role="alert">{failed.message}</p>} |
| 278 | + </aside> |
| 279 | + </div> |
| 280 | + </div> |
| 281 | + ) |
| 282 | + })() |
| 283 | + )} |
47 | 284 | </ExamplePage> |
48 | 285 | ) |
49 | 286 | } |
0 commit comments