Examples
Media Player Example
This comprehensive example demonstrates how to build a fully-featured media player using StateFlow. The implementation showcases state consistency guarantees, signal handling with comprehensive feedback, and clear state visibility throughout the application lifecycle.
Overview
The media player implementation demonstrates several key StateFlow concepts working together in a real-world scenario. The player manages multiple interconnected states including playback control, volume management, buffering status, and error handling. Each state transition is carefully validated, side effects are properly managed, and the entire system maintains consistency even during complex operations like seeking or handling network interruptions.
State Architecture
The media player consists of four primary state definitions that work together to manage the complete player functionality.
Playback State
The playback state manages the core player lifecycle, tracking position, duration, and playback status. Each variant represents a distinct phase in the media lifecycle, from initial loading through active playback to completion.
const playbackState = defineState<{
position: number; // Current playback position in seconds
duration: number; // Total media duration in seconds
playbackRate: number; // Playback speed multiplier
mediaUrl?: string; // Currently loaded media URL
lastError?: Error; // Most recent error if any
}>()
.name("playback")
.signals(mediaSignals)
.variant("idle", true)
.variant("loading")
.variant("ready")
.variant("playing")
.variant("paused")
.variant("ended")
.variant("error")
.stringRepr(state => {
const base = `${state.position.toFixed(1)}/${state.duration.toFixed(1)}s`;
if (state.playbackRate !== 1) {
return `${base} @${state.playbackRate}x`;
}
if (state.lastError) {
return `${base} - Error: ${state.lastError.message}`;
}
return base;
})
.build();
Volume State
Volume management includes mute functionality with memory of previous volume levels, enabling smooth user experience when toggling mute status.
const volumeState = defineState<{
level: number; // Volume level 0.0 to 1.0
muted: boolean; // Current mute status
previousLevel: number; // Remembered level for unmute
}>()
.name("volume")
.signals(mediaSignals)
.variant("audible", true)
.variant("muted")
.stringRepr(state =>
state.muted ? "muted" : `${Math.round(state.level * 100)}%`
)
.build();
Buffer State
Buffer state tracks media loading progress and readiness, providing visibility into network operations and enabling responsive UI updates.
const bufferState = defineState<{
bufferedRanges: Array<{ start: number; end: number }>;
isBuffering: boolean;
bufferHealth: number; // 0.0 to 1.0 indicating buffer sufficiency
}>()
.name("buffer")
.signals(mediaSignals)
.variant("empty", true)
.variant("buffering")
.variant("sufficient")
.variant("starving")
.stringRepr(state => {
const total = state.bufferedRanges.reduce(
(sum, range) => sum + (range.end - range.start), 0
);
return `${total.toFixed(1)}s buffered (health: ${state.bufferHealth.toFixed(2)})`;
})
.build();
UI State
UI state manages the player interface visibility and interaction modes, ensuring consistent user experience across different player states.
const uiState = defineState<{
controlsVisible: boolean;
seekPreview?: number; // Preview position during seek
volumeSliderVisible: boolean;
lastInteraction: number; // Timestamp of last user interaction
}>()
.name("ui")
.signals(mediaSignals)
.variant("hidden", true)
.variant("visible")
.variant("seeking")
.variant("adjustingVolume")
.stringRepr(state =>
state.controlsVisible ? "controls visible" : "controls hidden"
)
.build();
Signal Definitions
Signals provide the controlled interface for all state modifications. Each signal is carefully designed to carry the necessary information while maintaining type safety.
const mediaSignals = {
// Media lifecycle signals
load: defineSignal<{
url: string;
autoplay?: boolean;
}>("load"),
play: defineSignal("play"),
pause: defineSignal("pause"),
stop: defineSignal("stop"),
// Playback control signals
seek: defineSignal<{
position: number;
preview?: boolean; // True for preview during drag
}>("seek"),
setPlaybackRate: defineSignal<{
rate: number;
}>("setPlaybackRate"),
// Volume control signals
setVolume: defineSignal<{
level: number;
}>("setVolume"),
mute: defineSignal("mute"),
unmute: defineSignal("unmute"),
toggleMute: defineSignal("toggleMute"),
// Buffer management signals
bufferUpdate: defineSignal<{
ranges: Array<{ start: number; end: number }>;
health: number;
}>("bufferUpdate"),
// UI interaction signals
showControls: defineSignal("showControls"),
hideControls: defineSignal("hideControls"),
startSeeking: defineSignal("startSeeking"),
endSeeking: defineSignal("endSeeking"),
// Error handling signals
handleError: defineSignal<{
error: Error;
recoverable: boolean;
}>("handleError"),
retry: defineSignal("retry"),
// Progress update signal
timeUpdate: defineSignal<{
position: number;
buffered: Array<{ start: number; end: number }>;
}>("timeUpdate")
};
State Flows
State flows define the valid transitions and business logic for each state variant. These flows ensure that the player behaves correctly in all situations.
Playback State Flows
defineFlow(playbackState.idle, {
load: (state, signal) => playbackState.loading({
...state,
mediaUrl: signal.url,
position: 0,
duration: 0,
lastError: undefined
}),
play: () => Result.reject("No media loaded"),
seek: () => Result.reject("No media loaded")
});
defineFlow(playbackState.loading, {
handleError: (state, signal) => playbackState.error({
...state,
lastError: signal.error
}),
bufferUpdate: (state, signal) => {
// Transition to ready when we have initial buffer
if (signal.health > 0.2 && signal.ranges.length > 0) {
const duration = Math.max(...signal.ranges.map(r => r.end));
return playbackState.ready({
...state,
duration,
position: 0
});
}
return Result.ignore("Insufficient buffer");
}
});
defineFlow(playbackState.ready, {
play: (state) => playbackState.playing(state),
seek: (state, signal) => {
if (signal.position < 0 || signal.position > state.duration) {
return Result.reject(`Invalid seek position: ${signal.position}`);
}
return { ...state, position: signal.position };
}
});
defineFlow(playbackState.playing, {
pause: (state) => playbackState.paused(state),
stop: () => playbackState.idle({
position: 0,
duration: 0,
playbackRate: 1,
mediaUrl: undefined
}),
timeUpdate: (state, signal) => {
const newState = { ...state, position: signal.position };
// Check if playback has ended
if (signal.position >= state.duration - 0.1) {
return playbackState.ended(newState);
}
return newState;
},
seek: (state, signal) => {
if (signal.position < 0 || signal.position > state.duration) {
return Result.reject("Seek position out of range");
}
return { ...state, position: signal.position };
},
setPlaybackRate: (state, signal) => {
if (signal.rate < 0.25 || signal.rate > 4.0) {
return Result.reject("Playback rate must be between 0.25 and 4.0");
}
return { ...state, playbackRate: signal.rate };
}
});
defineFlow(playbackState.error, {
retry: (state) => {
if (!state.mediaUrl) {
return Result.reject("No media URL to retry");
}
return playbackState.loading({
...state,
lastError: undefined
});
},
load: (state, signal) => playbackState.loading({
...state,
mediaUrl: signal.url,
lastError: undefined
})
});
Volume State Flows
defineFlow(volumeState.audible, {
setVolume: (state, signal) => {
if (signal.level < 0 || signal.level > 1) {
return Result.reject("Volume must be between 0 and 1");
}
return {
...state,
level: signal.level,
previousLevel: state.level > 0 ? state.level : state.previousLevel
};
},
mute: (state) => volumeState.muted({
...state,
muted: true,
previousLevel: state.level
}),
toggleMute: (state) => volumeState.muted({
...state,
muted: true,
previousLevel: state.level
})
});
defineFlow(volumeState.muted, {
unmute: (state) => volumeState.audible({
...state,
muted: false,
level: state.previousLevel
}),
toggleMute: (state) => volumeState.audible({
...state,
muted: false,
level: state.previousLevel
}),
setVolume: (state, signal) => {
// Setting volume while muted unmutes and sets new volume
if (signal.level < 0 || signal.level > 1) {
return Result.reject("Volume must be between 0 and 1");
}
return volumeState.audible({
...state,
muted: false,
level: signal.level
});
}
});
Application Integration
The media player integrates with the DOM through StateFlow's handler system, managing side effects and ensuring proper cleanup.
interface MediaPlayerApp {
playback: Infer<typeof playbackState>;
volume: Infer<typeof volumeState>;
buffer: Infer<typeof bufferState>;
ui: Infer<typeof uiState>;
// Non-state properties
element: HTMLVideoElement;
updateInterval?: number;
}
const player: MediaPlayerApp = {
playback: {
position: 0,
duration: 0,
playbackRate: 1
},
volume: {
level: 0.7,
muted: false,
previousLevel: 0.7
},
buffer: {
bufferedRanges: [],
isBuffering: false,
bufferHealth: 0
},
ui: {
controlsVisible: false,
volumeSliderVisible: false,
lastInteraction: Date.now()
},
element: document.querySelector('#video-player') as HTMLVideoElement
};
applyFlow(
player,
[playbackState, volumeState, bufferState, uiState],
(sm) => {
// Playback state handlers
sm.addEnterHandler(playbackState.loading, (state) => {
return Result.transition(async () => {
try {
player.element.src = state.mediaUrl!;
await player.element.load();
// Start monitoring buffer
startBufferMonitoring();
return Result.ok();
} catch (error) {
return dispatch(player, mediaSignals.handleError({
error: error as Error,
recoverable: true
}));
}
}, 30000); // 30 second timeout for loading
});
sm.addEnterHandler(playbackState.playing, async (state) => {
try {
await player.element.play();
player.element.playbackRate = state.playbackRate;
startProgressTracking();
return Result.ok();
} catch (error) {
return Result.error(error);
}
});
sm.addExitHandler(playbackState.playing, () => {
player.element.pause();
stopProgressTracking();
return Result.ok();
});
sm.addUpdateHandler(playbackState.playing, (state) => {
// Handle seek while playing
if (Math.abs(player.element.currentTime - state.position) > 0.5) {
player.element.currentTime = state.position;
}
// Update playback rate if changed
if (player.element.playbackRate !== state.playbackRate) {
player.element.playbackRate = state.playbackRate;
}
return Result.ok();
});
// Volume state handlers
sm.addUpdateHandler(volumeState.audible, (state) => {
player.element.volume = state.level;
player.element.muted = false;
updateVolumeDisplay(state.level);
return Result.ok();
});
sm.addEnterHandler(volumeState.muted, () => {
player.element.muted = true;
updateVolumeDisplay(0);
return Result.ok();
});
// Buffer monitoring
sm.addEnterHandler(bufferState.starving, () => {
// Pause playback if buffer is critically low
if (player.playback.playing) {
dispatch(player, mediaSignals.pause());
showBufferingIndicator();
}
return Result.ok();
});
sm.addExitHandler(bufferState.starving, () => {
hideBufferingIndicator();
// Auto-resume if we were playing
if (player.playback.paused) {
dispatch(player, mediaSignals.play());
}
return Result.ok();
});
},
{
logHandlers: [createMediaPlayerLogger()]
}
);
Helper Functions
Supporting functions manage the media element interaction and UI updates.
function startProgressTracking() {
player.updateInterval = window.setInterval(() => {
const element = player.element;
// Build buffered ranges
const buffered: Array<{ start: number; end: number }> = [];
for (let i = 0; i < element.buffered.length; i++) {
buffered.push({
start: element.buffered.start(i),
end: element.buffered.end(i)
});
}
// Dispatch time update
dispatch(player, mediaSignals.timeUpdate({
position: element.currentTime,
buffered
}), true); // Mute to avoid log spam
// Update buffer health
const bufferAhead = calculateBufferAhead(
element.currentTime,
buffered
);
const health = Math.min(bufferAhead / 10, 1); // 10 seconds = full health
dispatch(player, mediaSignals.bufferUpdate({
ranges: buffered,
health
}), true);
}, 250); // Update 4 times per second
}
function stopProgressTracking() {
if (player.updateInterval) {
clearInterval(player.updateInterval);
player.updateInterval = undefined;
}
}
function calculateBufferAhead(
position: number,
ranges: Array<{ start: number; end: number }>
): number {
for (const range of ranges) {
if (position >= range.start && position <= range.end) {
return range.end - position;
}
}
return 0;
}
function createMediaPlayerLogger(): StateFlowLogHandler {
return (entry) => {
// Custom formatting for media player events
const icon = entry.finalResult.includes("Error") ? "❌" :
entry.finalResult.includes("Rejected") ? "⚠️" :
entry.signal.includes("play") ? "▶️" :
entry.signal.includes("pause") ? "⏸️" :
entry.signal.includes("volume") ? "🔊" : "🎬";
console.log(
`${icon} ${entry.signal} → ${entry.finalResult}`,
entry.stateChanges.length > 0 ?
entry.stateChanges.map(c => `${c.stateName}: ${String(c.newState)}`) :
"No state changes"
);
};
}
Usage Examples
The media player provides a clean API for common operations while maintaining state consistency.
// Load and play media
async function loadAndPlay(url: string) {
await sync(player);
const loadResult = dispatch(player, mediaSignals.load({
url,
autoplay: true
}));
if (loadResult.kind === ResultKind.InTransition) {
const final = await loadResult.done();
if (final.kind === ResultKind.OK) {
dispatch(player, mediaSignals.play());
}
}
}
// Seek with preview
function handleSeekStart() {
dispatch(player, mediaSignals.startSeeking());
}
function handleSeekDrag(position: number) {
dispatch(player, mediaSignals.seek({
position,
preview: true
}));
}
function handleSeekEnd(position: number) {
dispatch(player, mediaSignals.seek({ position }));
dispatch(player, mediaSignals.endSeeking());
}
// Volume control with keyboard
function handleVolumeKeys(event: KeyboardEvent) {
const current = player.volume.level;
switch (event.key) {
case "ArrowUp":
dispatch(player, mediaSignals.setVolume({
level: Math.min(current + 0.1, 1)
}));
break;
case "ArrowDown":
dispatch(player, mediaSignals.setVolume({
level: Math.max(current - 0.1, 0)
}));
break;
case "m":
dispatch(player, mediaSignals.toggleMute());
break;
}
}
// Error recovery
function setupErrorRecovery() {
observe(
player,
[playbackState.error],
async (state) => {
console.error("Playback error:", state.lastError);
// Auto-retry after 3 seconds for network errors
if (state.lastError?.message.includes("network")) {
await new Promise(resolve => setTimeout(resolve, 3000));
dispatch(player, mediaSignals.retry());
}
}
);
}
Testing the Media Player
StateFlow's predictable architecture makes testing straightforward.
describe("Media Player", () => {
let player: MediaPlayerApp;
let mockElement: HTMLVideoElement;
beforeEach(() => {
mockElement = createMockVideoElement();
player = createMediaPlayer(mockElement);
});
it("should handle play/pause cycle", async () => {
// Load media
const loadResult = dispatch(player, mediaSignals.load({
url: "test.mp4"
}));
await loadResult.done();
// Verify ready state
expect(player.playback[Symbol.toStringTag]).toBe("ready");
// Play
const playResult = dispatch(player, mediaSignals.play());
expect(playResult.kind).toBe(ResultKind.OK);
expect(player.playback[Symbol.toStringTag]).toBe("playing");
// Pause
const pauseResult = dispatch(player, mediaSignals.pause());
expect(pauseResult.kind).toBe(ResultKind.OK);
expect(player.playback[Symbol.toStringTag]).toBe("paused");
});
it("should enforce volume constraints", () => {
// Valid volume
const validResult = dispatch(player, mediaSignals.setVolume({
level: 0.5
}));
expect(validResult.kind).toBe(ResultKind.OK);
expect(player.volume.level).toBe(0.5);
// Invalid volume
const invalidResult = dispatch(player, mediaSignals.setVolume({
level: 1.5
}));
expect(invalidResult.kind).toBe(ResultKind.Rejected);
expect(player.volume.level).toBe(0.5); // Unchanged
});
it("should handle seek validation", async () => {
// Load media with known duration
await dispatch(player, mediaSignals.load({
url: "test.mp4"
})).done();
// Mock duration
player.playback.duration = 120;
// Valid seek
const validSeek = dispatch(player, mediaSignals.seek({
position: 60
}));
expect(validSeek.kind).toBe(ResultKind.OK);
// Invalid seek
const invalidSeek = dispatch(player, mediaSignals.seek({
position: 150
}));
expect(invalidSeek.kind).toBe(ResultKind.Rejected);
});
});
This media player example demonstrates how StateFlow's architecture provides a robust foundation for complex stateful applications. The combination of immutable states, controlled transitions through signals, and comprehensive feedback ensures that the player behaves predictably while maintaining consistency throughout its lifecycle.