Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone a8c677472ab5691741798a3d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Sleep.tsx
// Sleep: a dark screen with sleep sounds, a sleep timer, and calm stories read aloud slowly by the
// device's voice (or to read on the screen).
import { useCallback, useEffect, useRef, useState } from "react";
import { keepAwake, setMedia } from "./lib/audio";
import { mixParam, pause as pauseMix, play as playMix, setMix, useMix, type Levels } from "./lib/mixer";
import { navigate } from "./lib/router";
import { canSpeak, hush, speak, unlockSpeech, useVoices } from "./lib/speech";
import { settings } from "./lib/store";
import { STORIES, storyMinutes } from "./content/stories";
import type { Story } from "./content/types";
import { finishSession, Clock } from "./Player";
import { SleepTimer } from "./Sounds";
import { VoicePicker } from "./Meditate";
import { A, Icon } from "./ui";
const NIGHT_SOUNDS: { name: string; levels: Levels }[] = [
{ name: "Soft rain", levels: { rain: 0.6 } },
{ name: "Ocean at night", levels: { ocean: 0.65 } },
{ name: "Fireside", levels: { fire: 0.6, wind: 0.12 } },
{ name: "Brown noise", levels: { brown: 0.6 } },
{ name: "Fan", levels: { fan: 0.6 } },
{ name: "Storm", levels: { "heavy-rain": 0.55, wind: 0.3 } },
];
/** A black screen that stays on, for listening in the dark. Tap anywhere to wake it. */
export function Dim({ onClose }: { onClose: () => void }) {
useEffect(() => {
keepAwake(true);
document.body.classList.add("dimmed");
const onKey = () => onClose();
document.addEventListener("keydown", onKey);
return () => {
keepAwake(false);
document.body.classList.remove("dimmed");
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
return (
<button type="button" className="dim" onClick={onClose} aria-label="Wake the screen">
<span>Tap to wake the screen</span>
</button>
);
}
function SoundChoice({ quiet }: { quiet?: boolean }) {
const mix = useMix();
const scale = (l: Levels): Levels => (quiet ? Object.fromEntries(Object.entries(l).map(([k, v]) => [k, (v ?? 0) * 0.6])) : l);
const current = mixParam(mix.levels);
return (
<div className="chips" role="radiogroup" aria-label="Sleep sound">
{quiet && (
<button type="button" role="radio" className="chip-btn" aria-checked={!mix.playing} onClick={() => pauseMix()}>None</button>
)}
{NIGHT_SOUNDS.map((s) => {
const on = mix.playing && current === mixParam(scale(s.levels));
return (
<button key={s.name} type="button" role="radio" className="chip-btn" aria-checked={on} onClick={() => (on ? pauseMix() : setMix(scale(s.levels), true))}>
{s.name}
</button>
);
})}
</div>
);
}
export function SleepHome() {
const mix = useMix();
const [dim, setDim] = useState(false);
const closeDim = useCallback(() => setDim(false), []);
return (
<div className="page wrap">
<header className="page-head">
<p className="eyebrow">Sleep</p>
<h1 className="page-title">Rest easy tonight.</h1>
<p className="page-lede">Pick a sound, set a timer, and let a story read you to sleep. The screen stays dark.</p>
</header>
<div className="sleep-grid">
<section className="card pad" aria-labelledby="sleep-sounds">
<h2 className="card-title" id="sleep-sounds">Sleep sounds</h2>
<SoundChoice />
<div className="row">
{Object.keys(mix.levels).length > 0 && (
<button type="button" className="btn btn-line btn-sm" onClick={() => (mix.playing ? pauseMix() : playMix())}>
<Icon name={mix.playing ? "pause" : "play"} size={16} /> {mix.playing ? "Pause" : "Play"}
</button>
)}
<A className="btn btn-quiet btn-sm" href="/sounds">Make your own mix</A>
</div>
<SleepTimer />
<button type="button" className="btn btn-block" onClick={() => setDim(true)}><Icon name="moon" size={18} /> Dim the screen</button>
</section>
<section aria-labelledby="sleep-stories">
<h2 className="card-title" id="sleep-stories">Sleep stories</h2>
<ul className="card-list">
{STORIES.map((s) => (
<li key={s.slug}>
<A className="item-card" href={`/sleep/${s.slug}`}>
<span className="item-main">
<span className="item-title">{s.title}</span>
<span className="item-meta">{storyMinutes(s)} min</span>
<span className="item-text">{s.summary}</span>
</span>
<span className="item-go" aria-hidden="true"><Icon name="play" size={16} /></span>
</A>
</li>
))}
</ul>
</section>
</div>
<h2 className="section-title">Wind down</h2>
<ul className="card-list two-col">
<li>
<A className="item-card" href="/meditate/falling-asleep">
<span className="item-main"><span className="item-title">Falling asleep</span><span className="item-meta">15 or 25 min</span><span className="item-text">Relax the body and slow the breath in bed, counting down.</span></span>
<span className="item-go" aria-hidden="true"><Icon name="play" size={16} /></span>
</A>
</li>
<li>
<A className="item-card" href="/breathe/4-7-8?min=3">
<span className="item-main"><span className="item-title">4-7-8 breathing</span><span className="item-meta">3 min</span><span className="item-text">A long, slow breath out to help you wind down.</span></span>
<span className="item-go" aria-hidden="true"><Icon name="play" size={16} /></span>
</A>
</li>
</ul>
{dim && <Dim onClose={closeDim} />}
</div>
);
}
const GAP = 1400;
const wait = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
function StoryPage({ story }: { story: Story }) {
const voices = useVoices();
const speakable = canSpeak && voices.length > 0;
const [index, setIndex] = useState(-1);
const [playing, setPlaying] = useState(false);
const [finished, setFinished] = useState(false);
const [dim, setDim] = useState(false);
const closeDim = useCallback(() => setDim(false), []);
const token = useRef(0);
const clock = useRef(new Clock()).current;
const indexRef = useRef(-1);
const saved = useRef(false);
const paras = useRef<(HTMLParagraphElement | null)[]>([]);
const show = (i: number) => {
indexRef.current = i;
setIndex(i);
const el = paras.current[i];
if (el) el.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
};
const stop = useCallback(() => {
token.current++;
hush();
clock.pause();
setPlaying(false);
}, [clock]);
const listen = useCallback(async (from: number) => {
const mine = ++token.current;
const alive = () => mine === token.current;
const { voice, rate } = settings();
const slow = Math.max(0.6, rate - 0.05);
setPlaying(true);
setFinished(false);
clock.start();
if (from <= 0) {
show(-1);
await speak(`${story.title}. A sleep story.`, voice, slow);
if (!alive()) return;
await wait(GAP * 1.5);
}
for (let i = Math.max(0, from); i < story.paragraphs.length; i++) {
if (!alive()) return;
show(i);
await speak(story.paragraphs[i], voice, slow);
if (!alive()) return;
await wait(GAP);
}
if (!alive()) return;
clock.pause();
setPlaying(false);
setFinished(true);
saved.current = true;
finishSession("story", story.title, clock.elapsed, true);
}, [clock, story]);
useEffect(() => {
if (!playing) { setMedia("story", null); return; }
keepAwake(true);
setMedia("story", { title: story.title, album: "Sleep stories", playing, onPlay: () => void listen(indexRef.current), onPause: stop, onStop: stop });
return () => keepAwake(false);
}, [listen, playing, stop, story.title]);
useEffect(() => () => {
token.current++;
hush();
setMedia("story", null);
if (!saved.current) finishSession("story", story.title, clock.elapsed, false);
}, [clock, story.title]);
return (
<div className="page wrap story">
<A className="back-link" href="/sleep"><Icon name="back" size={18} /> Sleep</A>
<header className="page-head">
<p className="eyebrow">Sleep story · {storyMinutes(story)} min</p>
<h1 className="page-title">{story.title}</h1>
<p className="page-lede">{story.summary}</p>
</header>
<div className="story-setup card pad">
{speakable ? (
<button type="button" className="btn btn-lg btn-block" onClick={() => { unlockSpeech(); if (playing) stop(); else void listen(finished ? 0 : indexRef.current); }}>
<Icon name={playing ? "pause" : "play"} size={18} /> {playing ? "Pause" : index > 0 && !finished ? "Keep listening" : "Listen"}
</button>
) : (
<p className="notice">This browser has no voices to read aloud. The story is below to read; dim your screen and take it slowly.</p>
)}
<div className="field"><span>Sound under the story</span><SoundChoice quiet /></div>
<SleepTimer />
{speakable && (
<details className="more">
<summary>Voice and speed</summary>
<VoicePicker />
</details>
)}
</div>
<article className="story-text" aria-label={story.title}>
{story.paragraphs.map((p, i) => (
<p key={i} ref={(el) => { paras.current[i] = el; }} className={playing || index >= 0 ? (i === index ? "now" : "") : ""}>{p}</p>
))}
<p className="story-end">Sleep well.</p>
</article>
{playing && (
<div className="story-bar" role="region" aria-label="Story playing">
<button type="button" className="mini-play" onClick={stop} aria-label="Pause story"><Icon name="pause" size={18} /></button>
<span className="story-bar-text">Reading {story.title}</span>
<button type="button" className="btn btn-quiet btn-sm" onClick={() => setDim(true)}><Icon name="moon" size={16} /> Dim</button>
</div>
)}
{dim && <Dim onClose={closeDim} />}
</div>
);
}
export function SleepRoute({ slug }: { slug?: string }) {
const story = STORIES.find((s) => s.slug === slug);
useEffect(() => {
if (slug && !story) navigate("/sleep", true);
}, [slug, story]);
if (!slug || !story) return <SleepHome />;
return <StoryPage key={slug} story={story} />;
}
Change history
Link to OpenApps pages and add a contribution strip
Bind the agent address in the policy
Add favicon.ico so browsers that ask for it stop getting a 404
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync