Skip to content

Commit dc1f5aa

Browse files
feat: complete media player playground example
1 parent 8fe6e7b commit dc1f5aa

9 files changed

Lines changed: 1513 additions & 119 deletions

File tree

examples/playground/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ Each starter keeps its machine definition next to its route component:
2828
- `src/examples/media-player`
2929
- `src/examples/worker-tabs`
3030

31-
The first four route components are intentionally light: their domain schemas,
32-
events, and state topology are ready, while the React-to-machine adapter is left
33-
for the exercise.
31+
The turnstile, traffic light, and microwave route components are intentionally
32+
light: their domain schemas, events, and state topology are ready, while the
33+
React-to-machine adapter is left for the exercise.
34+
35+
The media player is a complete browser integration. `schemas.ts` owns its typed
36+
protocol, `service.ts` exposes the audio element and Web Audio graph as an
37+
Effect service, `invocations.ts` defines its state-scoped processes, and the
38+
React page translates DOM events into machine events. Its compound
39+
`Ready` state models the loaded playback lifecycle inside a parallel `Player`:
40+
the transport region owns loading, playback, buffering, and failures while the
41+
settings region independently owns `Audible` and `Muted`.
3442

3543
The workers route additionally contains:
3644

Lines changed: 268 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,286 @@
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+
}
421

522
export function MediaPlayerPage() {
6-
void MediaPlayerMachine
23+
const snapshotResult = useAtomValue(mediaPlayerAtom.snapshot)
24+
const send = useAtomSet(mediaPlayerAtom.send)
25+
const [source, setSource] = useState<AudioSource>()
726

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+
)
935

1036
useEffect(
1137
() => () => {
12-
if (source !== undefined) URL.revokeObjectURL(source)
38+
if (source !== undefined) URL.revokeObjectURL(source.url)
1339
},
1440
[source]
1541
)
1642

1743
return (
1844
<ExamplePage
1945
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."
2147
machineFile="src/examples/media-player/machine.ts"
2248
>
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+
)}
47284
</ExamplePage>
48285
)
49286
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
2+
import { Atom } from "effect/unstable/reactivity"
3+
import { MediaPlayerMachine } from "./machine.ts"
4+
import { MediaPlayer } from "./service.ts"
5+
6+
const mediaPlayerRuntime = Atom.runtime(MediaPlayer.layer)
7+
8+
export const mediaPlayerAtom = AtomMachine.bind(mediaPlayerRuntime).make(MediaPlayerMachine)

0 commit comments

Comments
 (0)