Skip to content

Commit c10c83f

Browse files
fix: align media player playground with current APIs
1 parent 9de748f commit c10c83f

11 files changed

Lines changed: 307 additions & 209 deletions

File tree

.changeset/clean-scenarios-plan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Keep `MachineTest.run` service-free for machines with invoked effects, matching the pure `Machine.planInitial` and `Machine.plan` APIs it uses internally.

examples/playground/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ React-to-machine adapter is left for the exercise.
3535
The media player is a complete browser integration. `schemas.ts` owns its typed
3636
protocol, `service.ts` exposes the audio element and Web Audio graph as an
3737
Effect service, `invocations.ts` defines its state-scoped processes, and the
38-
React page translates DOM events into machine events. Its compound
38+
React adapter registers the audio element through the shared service runtime
39+
before translating playback events into machine events. `view.ts` exhaustively
40+
projects the typed parallel snapshot into the React-facing view model. Its compound
3941
`Ready` state models the loaded playback lifecycle inside a parallel `Player`:
4042
the transport region owns loading, playback, buffering, and failures while the
4143
settings region independently owns `Audible` and `Muted`.

examples/playground/src/examples/media-player/MediaPlayerPage.tsx

Lines changed: 28 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useAtomSet, useAtomValue } from "@effect/atom-react"
2-
import { Option } from "effect"
3-
import { AsyncResult } from "effect/unstable/reactivity"
2+
import { Match } from "effect"
43
import { useCallback, useEffect, useState } from "react"
54
import { ExamplePage } from "../../components/ExamplePage.tsx"
6-
import { mediaPlayerAtom } from "./atoms.ts"
7-
import { initialAudioSettings, initialPlaybackData, MediaPlayerEvent, MediaPlayerStates } from "./schemas.ts"
5+
import { mediaPlayerAtom, mediaPlayerViewAtom, registerMediaPlayerElement } from "./atoms.ts"
6+
import { MediaPlayerEvent } from "./schemas.ts"
87

98
interface AudioSource {
109
readonly name: string
@@ -20,17 +19,18 @@ const formatTime = (seconds: number): string => {
2019
}
2120

2221
export function MediaPlayerPage() {
23-
const snapshotResult = useAtomValue(mediaPlayerAtom.snapshot)
22+
const viewResult = useAtomValue(mediaPlayerViewAtom)
2423
const send = useAtomSet(mediaPlayerAtom.send)
24+
const register = useAtomSet(registerMediaPlayerElement)
2525
const [source, setSource] = useState<AudioSource>()
2626

2727
const registerAudioElement = useCallback(
2828
(audioRef: HTMLAudioElement | null) => {
2929
if (audioRef !== null) {
30-
send(MediaPlayerEvent.cases.AudioElementMounted.make({ audioRef }))
30+
register(audioRef)
3131
}
3232
},
33-
[send]
33+
[register]
3434
)
3535

3636
useEffect(
@@ -46,67 +46,13 @@ export function MediaPlayerPage() {
4646
summary="Coordinate a compound transport lifecycle and independent sound modes inside a parallel Effect statechart."
4747
machineFile="src/examples/media-player/machine.ts"
4848
>
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
49+
{Match.value(viewResult).pipe(
50+
Match.tagsExhaustive({
51+
Initial: () => <div className="media-player-message">Starting the media player machine…</div>,
52+
Failure: () => <div className="media-player-message is-error">The media player machine failed to start.</div>,
53+
Success: ({ value: view }) => {
54+
const { settings, status, transport } = view
55+
const { canPause, canPlay, canRestart, isPlaying, loudness, playback } = transport
11056
const loudnessLevel = Math.min(100, Math.round((loudness?.rms ?? 0) * 220))
11157
const peakLevel = Math.min(100, Math.round((loudness?.peak ?? 0) * 100))
11258

@@ -118,7 +64,7 @@ export function MediaPlayerPage() {
11864
<h2>{source?.name ?? "Choose a local audio track"}</h2>
11965
</div>
12066
<div className="media-player-toolbar-actions">
121-
<span className={`media-state-chip is-${stateName.toLowerCase()}`}>{stateName}</span>
67+
<span className={`media-state-chip is-${transport.name.toLowerCase()}`}>{transport.name}</span>
12268
<label className="media-file-button">
12369
<span>{source === undefined ? "Choose audio" : "Replace audio"}</span>
12470
<input
@@ -163,7 +109,7 @@ export function MediaPlayerPage() {
163109

164110
<div className="media-time-row">
165111
<span>Current time</span>
166-
<strong>{formatTime(playbackData.currentTime)}</strong>
112+
<strong>{formatTime(playback.currentTime)}</strong>
167113
</div>
168114

169115
<div className="media-controls">
@@ -207,7 +153,7 @@ export function MediaPlayerPage() {
207153
<section className="media-settings">
208154
<div className="media-panel-heading">
209155
<span>Audio settings</span>
210-
<strong>{isMuted ? "Muted" : "Audible"} · {Math.round(settings.volume * 100)}%</strong>
156+
<strong>{settings.muted ? "Muted" : "Audible"} · {Math.round(settings.volume * 100)}%</strong>
211157
</div>
212158
<label>
213159
<span>Volume</span>
@@ -227,7 +173,7 @@ export function MediaPlayerPage() {
227173
<span>Muted</span>
228174
<input
229175
type="checkbox"
230-
checked={isMuted}
176+
checked={settings.muted}
231177
onChange={({ currentTarget }) =>
232178
send(
233179
currentTarget.checked
@@ -257,30 +203,31 @@ export function MediaPlayerPage() {
257203
<section className="media-state-preview">
258204
<div className="media-panel-heading">
259205
<span>Machine snapshot</span>
260-
<strong>{runtimeSnapshot.status}</strong>
206+
<strong>{status}</strong>
261207
</div>
262208
<pre>
263209
{JSON.stringify({
264-
status: runtimeSnapshot.status,
210+
status,
265211
states: {
266-
transport: transportPath,
267-
settings: settingsPath
212+
transport: transport.path,
213+
settings: settings.path
268214
},
269215
data: {
270-
transport: playback ?? failed ?? null,
271-
settings
216+
transport: transport.value,
217+
settings: settings.value
272218
}
273219
}, null, 2)}
274220
</pre>
275221
</section>
276222

277-
{failed !== undefined && <p className="media-error-message" role="alert">{failed.message}</p>}
223+
{transport.error !== null && <p className="media-error-message" role="alert">{transport.error}</p>}
278224
</aside>
279225
</div>
280226
</div>
281227
)
282-
})()
283-
)}
228+
}
229+
})
230+
)}
284231
</ExamplePage>
285232
)
286233
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
import { AtomMachine } from "@typeonce/effect-machine/reactivity"
2+
import { Effect } from "effect"
23
import { Atom } from "effect/unstable/reactivity"
34
import { MediaPlayerMachine } from "./machine.ts"
45
import { MediaPlayer } from "./service.ts"
6+
import { toMediaPlayerView } from "./view.ts"
57

68
const mediaPlayerRuntime = Atom.runtime(MediaPlayer.layer)
79

810
export const mediaPlayerAtom = AtomMachine.bind(mediaPlayerRuntime).make(MediaPlayerMachine)
11+
12+
export const mediaPlayerViewAtom = Atom.mapResult(mediaPlayerAtom.snapshot, (snapshot) => ({
13+
status: snapshot.status,
14+
...toMediaPlayerView(snapshot.state)
15+
}))
16+
17+
export const registerMediaPlayerElement = mediaPlayerRuntime.fn((audioRef: HTMLAudioElement) =>
18+
Effect.gen(function*() {
19+
const mediaPlayer = yield* MediaPlayer
20+
yield* mediaPlayer.register(audioRef)
21+
})
22+
)

examples/playground/src/examples/media-player/invocations.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Machine } from "@typeonce/effect-machine"
22
import { Data, Effect, Stream } from "effect"
3-
import { type LoudnessSample, MediaPlayerInternalEvent } from "./schemas.ts"
3+
import { type LoudnessSample, MediaPlayerInternalEvent, type SoundSettings, toAudioSettings } from "./schemas.ts"
44
import { MediaPlayer, MediaPlayerError } from "./service.ts"
55

66
export const loadAudio = (url: string) =>
@@ -44,6 +44,16 @@ export const restartAudio = Machine.invokeEffect({
4444
onFailure: (failure) => MediaPlayerInternalEvent.cases.OperationFailed.make({ message: failure.message })
4545
})
4646

47+
export const applyAudioSettings = (settings: SoundSettings, muted: boolean) =>
48+
Machine.invokeEffect({
49+
id: "apply-audio-settings",
50+
effect: Effect.gen(function*() {
51+
const mediaPlayer = yield* MediaPlayer
52+
yield* mediaPlayer.applySettings(toAudioSettings(settings, muted))
53+
}),
54+
onSuccess: () => undefined
55+
})
56+
4757
type LoudnessProcessState = Data.TaggedEnum<{
4858
Waiting: {}
4959
Measured: { readonly sample: LoudnessSample }
Lines changed: 22 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { assert, describe, it } from "@effect/vitest"
22
import { MachineTest } from "@typeonce/effect-machine/testing"
33
import { Effect, Graph } from "effect"
4-
import { MediaPlayerEvent, MediaPlayerMachine } from "./machine.ts"
4+
import { MediaPlayerMachine } from "./machine.ts"
5+
import { MediaPlayerEvent } from "./schemas.ts"
56

67
const everyPublicEvent = [
78
MediaPlayerEvent.cases.SourceSelected.make({ url: "https://example.com/audio.mp3" }),
89
MediaPlayerEvent.cases.PlayRequested.make({}),
910
MediaPlayerEvent.cases.PauseRequested.make({}),
10-
MediaPlayerEvent.cases.MediaPlaying.make({}),
11-
MediaPlayerEvent.cases.MediaPaused.make({}),
11+
MediaPlayerEvent.cases.RestartRequested.make({}),
1212
MediaPlayerEvent.cases.MediaWaiting.make({}),
1313
MediaPlayerEvent.cases.MediaCanPlay.make({}),
14-
MediaPlayerEvent.cases.MediaEnded.make({}),
14+
MediaPlayerEvent.cases.PlaybackEnded.make({ currentTime: 42 }),
15+
MediaPlayerEvent.cases.TimeUpdated.make({ currentTime: 21 }),
1516
MediaPlayerEvent.cases.MediaFailed.make({ message: "unsupported codec" }),
1617
MediaPlayerEvent.cases.VolumeChanged.make({ volume: 0.4 }),
17-
MediaPlayerEvent.cases.MuteToggled.make({})
18+
MediaPlayerEvent.cases.PlaybackRateChanged.make({ playbackRate: 1.5 }),
19+
MediaPlayerEvent.cases.MuteRequested.make({}),
20+
MediaPlayerEvent.cases.UnmuteRequested.make({})
1821
]
1922

2023
const generated = MachineTest.scenarios(MediaPlayerMachine, {
@@ -32,69 +35,45 @@ describe("media-player statechart model", () => {
3235
Effect.tap((trace) =>
3336
Effect.sync(() => {
3437
assert.strictEqual(trace.final.path, "Player")
35-
assert.strictEqual(trace.final.states.playback.state.path, "Player.playback.Empty")
36-
const volume = trace.final.states.volume.state
37-
if (volume.path !== "Player.volume.Audible") {
38-
throw new Error(`Expected Audible, received ${volume.path}`)
39-
}
40-
assert.strictEqual(volume.value.volume, 1)
38+
assert.strictEqual(trace.final.states.transport.state.path.startsWith("Player.transport."), true)
39+
assert.strictEqual(trace.final.states.settings.state.path.startsWith("Player.settings."), true)
4140
})
4241
),
4342
Effect.asVoid
4443
),
4544
{ fastCheck: { numRuns: 100, seed: 24_061 } }
4645
)
4746

48-
it.effect("makes the current behavior gaps explicit through coverage", () =>
47+
it.effect("covers the public protocol and its reachable states", () =>
4948
Effect.gen(function*() {
5049
const trace = yield* MachineTest.run(MediaPlayerMachine, { events: everyPublicEvent })
5150
yield* MachineTest.verify(MediaPlayerMachine, trace)
5251

5352
const coverage = MachineTest.coverage(MediaPlayerMachine, trace)
5453

55-
assert.strictEqual(coverage.events.missing, 0)
5654
assert.strictEqual(coverage.events.hit, everyPublicEvent.length)
57-
assert.strictEqual(coverage.transitions.total, 0)
58-
assert.strictEqual(coverage.logicalConfigurations.hit, 1)
59-
assert.deepStrictEqual(coverage.states.activation.hits.map(({ path }) => path), [
60-
"Player",
61-
"Player.playback",
62-
"Player.playback.Empty",
63-
"Player.volume",
64-
"Player.volume.Audible"
65-
])
66-
assert.deepStrictEqual(coverage.states.activation.misses.map(({ path }) => path), [
67-
"Player.playback.Loading",
68-
"Player.playback.Paused",
69-
"Player.playback.Playing",
70-
"Player.playback.Buffering",
71-
"Player.playback.Ended",
72-
"Player.playback.Failed",
73-
"Player.volume.Muted"
74-
])
55+
assert.strictEqual(coverage.events.missing, 0)
56+
assert.strictEqual(coverage.transitions.hit > 0, true)
57+
58+
const activated = new Set(coverage.states.activation.hits.map(({ path }) => path))
59+
assert.strictEqual(activated.has("Player.transport.Empty"), true)
60+
assert.strictEqual(activated.has("Player.transport.Loading"), true)
61+
assert.strictEqual(activated.has("Player.transport.Failed"), true)
62+
assert.strictEqual(activated.has("Player.settings.Audible"), true)
63+
assert.strictEqual(activated.has("Player.settings.Muted"), true)
7564
}))
7665

77-
it.effect("records ignored events without inventing new logical configurations", () =>
66+
it.effect("records every planned event in the observed graph", () =>
7867
Effect.gen(function*() {
7968
const trace = yield* MachineTest.run(MediaPlayerMachine, { events: everyPublicEvent })
8069
const observed = yield* MachineTest.observedGraph(MediaPlayerMachine, trace)
8170
const eventEdges = Array.from(Graph.edges(observed.graph), ([, edge]) => edge).filter(
8271
({ data }) => data._tag === "Event"
8372
)
8473

85-
assert.strictEqual(Graph.nodeCount(observed.graph), 1)
74+
assert.strictEqual(Graph.nodeCount(observed.graph) > 1, true)
8675
assert.strictEqual(Graph.edgeCount(observed.graph), everyPublicEvent.length + 1)
8776
assert.strictEqual(eventEdges.length, everyPublicEvent.length)
88-
assert.strictEqual(eventEdges.every(({ source, target }) => source === target), true)
8977
assert.strictEqual(observed.starts.length, 1)
90-
91-
const node = Array.from(observed.graph)[0]![1]
92-
assert.deepStrictEqual(node.configuration, [
93-
"Player",
94-
"Player.playback",
95-
"Player.playback.Empty",
96-
"Player.volume",
97-
"Player.volume.Audible"
98-
])
9978
}))
10079
})

0 commit comments

Comments
 (0)