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/Meditate.tsx
// Meditate: a timer with soft bells, and guided sessions spoken by the device's own voice
// (or shown as words to read).
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { audio, bell, hold, keepAwake, release, setMedia, type Voice } from "./lib/audio";
import { navigate } from "./lib/router";
import { canSpeak, hush, pickVoice, speak, speechSeconds, unlockSpeech, useVoices } from "./lib/speech";
import { settings, useSettings, type Settings } from "./lib/store";
import { SESSIONS } from "./content/sessions";
import type { Session } from "./content/types";
import type { Entry } from "./lib/progress";
import { Clock, Controls, Done, PlayerShell, finishSession } from "./Player";
import { A, Chips, Icon, Mood, fmtClock, fmtMinutes } from "./ui";
/** Sessions meant for bed: shown in the night look, and they end without a bell. */
export const NIGHT_SESSIONS = new Set(["falling-asleep"]);
export function MeditateList() {
return (
<div className="page wrap">
<header className="page-head">
<p className="eyebrow">Meditate</p>
<h1 className="page-title">Sit for a while.</h1>
<p className="page-lede">Twelve guided sessions read aloud by your device, or a quiet timer with bells.</p>
</header>
<A className="item-card timer-card" href="/meditate/timer">
<span className="item-main">
<span className="item-title">Meditation timer</span>
<span className="item-text">Silence with a soft bell to start and end, and bells along the way if you like.</span>
</span>
<span className="item-go" aria-hidden="true"><Icon name="play" size={16} /></span>
</A>
<h2 className="section-title">Guided sessions</h2>
<ul className="card-list two-col">
{SESSIONS.map((s) => (
<li key={s.slug}>
<A className="item-card" href={`/meditate/${s.slug}`}>
<span className="item-main">
<span className="item-title">{s.title}</span>
<span className="item-meta">{s.lengths.join(", ")} 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>
</div>
);
}
export function VoicePicker() {
const voices = useVoices();
const [s, setS] = useSettings();
if (!canSpeak || !voices.length) {
return <p className="notice">This browser has no voices to read aloud, so sessions and stories show the words to read instead.</p>;
}
const current = pickVoice(s.voice);
return (
<div className="voice">
<label className="field">
<span>Voice</span>
<select className="select" value={current?.name ?? ""} onChange={(e) => setS({ voice: e.target.value })}>
{voices.map((v) => <option key={v.voiceURI + v.name} value={v.name}>{v.name.replace(/^(Microsoft|Google) /, "")}{v.localService ? "" : " (online)"}</option>)}
</select>
</label>
<label className="field">
<span>Speed</span>
<input className="range" type="range" min={0.6} max={1.1} step={0.05} value={s.rate} onChange={(e) => setS({ rate: Number(e.target.value) })} aria-valuetext={`${Math.round(s.rate * 100)} percent`} />
</label>
<button type="button" className="btn btn-line btn-sm" onClick={() => { unlockSpeech(); void speak("Take a slow breath in. And let it go.", current?.name ?? "", s.rate); }}>
Hear this voice
</button>
</div>
);
}
const TIMER_LENGTHS = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60];
const INTERVALS = [0, 1, 2, 5, 10, 15];
const BELLS: Settings["bell"][] = ["bowl", "bell", "chime"];
const BELL_NAMES: Record<Settings["bell"], string> = { bowl: "Singing bowl", bell: "Temple bell", chime: "Chime" };
export function TimerSetup({ query }: { query: URLSearchParams }) {
const [s, setS] = useSettings();
const q = Math.round(Number(query.get("min")));
const [minutes, setMinutes] = useState(q >= 1 && q <= 180 ? q : 10);
const qi = Math.round(Number(query.get("interval")));
const [interval, setEvery] = useState(INTERVALS.includes(qi) ? qi : 0);
const [before, setBefore] = useState<number | undefined>();
const [run, setRun] = useState(0);
const lengths = TIMER_LENGTHS.includes(minutes) ? TIMER_LENGTHS : [...TIMER_LENGTHS, minutes].sort((a, b) => a - b);
const intervals = INTERVALS.filter((i) => i < minutes);
return (
<div className="page wrap narrow">
<A className="back-link" href="/meditate"><Icon name="back" size={18} /> Meditate</A>
<header className="page-head">
<h1 className="page-title">Meditation timer</h1>
<p className="page-lede">Sit in silence. A bell starts the time and a bell ends it.</p>
</header>
<div className="setup card">
<div className="field">
<span>Length</span>
<Chips label="Length in minutes" options={lengths} value={minutes} onChange={(m) => { setMinutes(m); if (interval >= m) setEvery(0); }} format={(m) => `${m} min`} />
</div>
<div className="field">
<span>Interval bells</span>
<Chips label="Interval bells" options={intervals} value={intervals.includes(interval) ? interval : 0} onChange={setEvery} format={(i) => (i ? `Every ${i} min` : "Off")} />
</div>
<div className="field">
<span>Bell</span>
<Chips label="Bell sound" options={BELLS} value={s.bell} onChange={(b) => { setS({ bell: b }); bell(b); }} format={(b) => BELL_NAMES[b]} />
<small>Tap a bell to hear it.</small>
</div>
<Mood label="How do you feel right now? (optional)" value={before} onChange={setBefore} />
<button type="button" className="btn btn-lg btn-block" onClick={() => setRun((r) => r + 1)}>Begin</button>
</div>
{run > 0 && <TimerPlayer key={run} minutes={minutes} interval={intervals.includes(interval) ? interval : 0} before={before} onClose={() => setRun(0)} onAgain={() => setRun((r) => r + 1)} />}
</div>
);
}
function TimerPlayer({ minutes, interval, before, onClose, onAgain }: { minutes: number; interval: number; before?: number; onClose: () => void; onAgain: () => void }) {
const total = minutes * 60;
const clock = useRef(new Clock()).current;
const bells = useRef<Voice[]>([]);
const kind = useRef(settings().bell).current;
const [, tick] = useState(0);
const [running, setRunning] = useState(true);
const [done, setDone] = useState<{ entry: Entry | null; sec: number } | null>(null);
const title = `${minutes} minute timer`;
/** Rings now (at the start) and schedules every later bell on the audio clock, so bells keep time with the screen off. */
const begin = useCallback(() => {
if (clock.running) return;
const { ctx } = audio();
const now = clock.elapsed;
clock.start();
const base = ctx.currentTime + 0.05 - now;
if (now === 0) bells.current.push(bell(kind));
if (interval) for (let t = interval * 60; t < total - 1; t += interval * 60) if (t > now) bells.current.push(bell(kind, base + t, 0.6));
bells.current.push(bell(kind, base + total));
setRunning(true);
}, [clock, interval, kind, total]);
const pause = useCallback(() => {
clock.pause();
bells.current.forEach((b) => b.stop());
bells.current = [];
setRunning(false);
}, [clock]);
const end = useCallback((completed: boolean) => {
clock.pause();
if (!completed) { bells.current.forEach((b) => b.stop()); bells.current = []; }
const sec = Math.min(total, clock.elapsed);
setDone({ entry: finishSession("timer", "Meditation timer", sec, completed, before), sec });
}, [before, clock, total]);
useEffect(() => {
hold("timer");
keepAwake(true);
begin();
return () => {
release("timer");
keepAwake(false);
setMedia("timer", null);
};
}, [begin]);
useEffect(() => () => { if (clock.elapsed < total) bells.current.forEach((b) => b.stop()); }, [clock, total]);
useEffect(() => {
if (done) { setMedia("timer", null); return; }
setMedia("timer", { title, album: "Meditate", playing: running, onPlay: begin, onPause: pause, onStop: onClose });
const id = window.setInterval(() => {
if (clock.elapsed >= total) end(true);
else tick((n) => n + 1);
}, 250);
return () => window.clearInterval(id);
}, [begin, clock, done, end, onClose, pause, running, title, total]);
const left = total - clock.elapsed;
return (
<PlayerShell title={title} onClose={onClose} phase={done ? "done" : running ? "running" : "paused"}>
{done ? (
<Done entry={done.entry} seconds={done.sec} what="meditation" onClose={onClose} onAgain={onAgain} />
) : (
<>
<div className="timer-stage">
<svg className="timer-ring" viewBox="0 0 100 100" aria-hidden="true">
<circle className="timer-track" cx="50" cy="50" r="46" />
<circle className="timer-fill" cx="50" cy="50" r="46" pathLength={1} strokeDasharray="1" style={{ strokeDashoffset: 1 - Math.min(1, clock.elapsed / total) }} />
</svg>
<div className="timer-words">
<span className="timer-clock">{fmtClock(left)}</span>
<span className="timer-note">{running ? (interval ? `Bell every ${interval} min` : "Silent until the bell") : "Paused"}</span>
</div>
</div>
<Controls running={running} onToggle={() => (running ? pause() : begin())} label="timer" />
<button type="button" className="btn btn-quiet end-btn" onClick={() => end(false)}>End now</button>
</>
)}
</PlayerShell>
);
}
export function GuidedSetup({ session, query }: { session: Session; query: URLSearchParams }) {
const voices = useVoices();
const q = Math.round(Number(query.get("min")));
const [minutes, setMinutes] = useState(session.lengths.includes(q) ? q : session.lengths[0]);
const [mode, setMode] = useState<"voice" | "read">(query.get("mode") === "read" ? "read" : "voice");
const [before, setBefore] = useState<number | undefined>();
const [run, setRun] = useState(0);
const speakable = canSpeak && voices.length > 0;
const useMode = speakable ? mode : "read";
return (
<div className="page wrap narrow">
<A className="back-link" href={NIGHT_SESSIONS.has(session.slug) ? "/sleep" : "/meditate"}><Icon name="back" size={18} /> {NIGHT_SESSIONS.has(session.slug) ? "Sleep" : "Meditate"}</A>
<header className="page-head">
<h1 className="page-title">{session.title}</h1>
<p className="page-lede">{session.summary}</p>
</header>
<div className="setup card">
{session.lengths.length > 1 && (
<div className="field">
<span>Length</span>
<Chips label="Length in minutes" options={session.lengths} value={minutes} onChange={setMinutes} format={(m) => `${m} min`} />
</div>
)}
<div className="field">
<span>How to follow it</span>
<Chips label="Listen or read" options={["voice", "read"] as ("voice" | "read")[]} value={useMode} onChange={(m) => speakable && setMode(m)} format={(m) => (m === "voice" ? "Listen" : "Read")} />
<small>{useMode === "voice" ? "Your device reads it aloud, with quiet pauses between lines." : "The words appear one part at a time, with quiet pauses between them."}</small>
</div>
{(useMode === "voice" || !speakable) && <VoicePicker />}
<Mood label="How do you feel right now? (optional)" value={before} onChange={setBefore} />
<button type="button" className="btn btn-lg btn-block" onClick={() => { if (useMode === "voice") unlockSpeech(); setRun((r) => r + 1); }}>Begin</button>
</div>
{run > 0 && <GuidedPlayer key={run} session={session} minutes={minutes} mode={useMode} before={before} onClose={() => setRun(0)} onAgain={() => setRun((r) => r + 1)} />}
</div>
);
}
const LEAD = 4;
const wait = (ms: number) => new Promise((r) => window.setTimeout(r, Math.max(0, ms)));
function GuidedPlayer({ session, minutes, mode, before, onClose, onAgain }: { session: Session; minutes: number; mode: "voice" | "read"; before?: number; onClose: () => void; onAgain: () => void }) {
const opts = useRef(settings()).current;
const [s, setS] = useSettings();
const night = NIGHT_SESSIONS.has(session.slug);
const plan = useMemo(() => {
const est = session.steps.map((st) => speechSeconds(st.say, opts.rate));
const speech = est.reduce((a, b) => a + b, 0);
const pauses = session.steps.reduce((a, st) => a + st.pause, 0);
const factor = Math.max(0.4, (minutes * 60 - speech - LEAD) / pauses);
return session.steps.map((st, i) => ({ say: st.say, est: est[i], pause: Math.max(2, st.pause * factor) }));
}, [minutes, opts.rate, session]);
const total = LEAD + plan.reduce((a, p) => a + p.est + p.pause, 0);
const clock = useRef(new Clock()).current;
const part = useRef(new Clock());
const token = useRef(0);
const [pos, setPos] = useState<{ index: number; part: "lead" | "speak" | "pause" }>({ index: -1, part: "lead" });
const posRef = useRef(pos);
const [running, setRunning] = useState(true);
const [done, setDone] = useState<{ entry: Entry | null; sec: number } | null>(null);
const [, tick] = useState(0);
const go = (p: typeof pos) => { posRef.current = p; setPos(p); part.current = new Clock(); part.current.start(); };
const finish = useCallback((completed: boolean) => {
token.current++;
hush();
clock.pause();
if (completed && !night) bell(opts.bell, undefined, 0.8);
const sec = Math.min(total, clock.elapsed);
setDone({ entry: finishSession("meditate", session.title, sec, completed, before), sec });
}, [before, clock, night, opts.bell, session.title, total]);
/** Runs the script from a step: speak (or show) it, stay quiet for its pause, then the next. */
const runFrom = useCallback(async (index: number, at: "lead" | "speak" | "pause", offset = 0) => {
const mine = ++token.current;
const alive = () => mine === token.current;
if (at === "lead") {
go({ index: -1, part: "lead" });
await wait((LEAD - offset) * 1000);
if (!alive()) return;
at = "speak";
offset = 0;
}
for (let i = index; i < plan.length; i++) {
if (at === "speak" || i > index) {
go({ index: i, part: "speak" });
if (mode === "voice") await speak(plan[i].say, opts.voice, opts.rate);
else await wait(plan[i].est * 1000);
if (!alive()) return;
}
const skip = at === "pause" && i === index ? offset : 0;
go({ index: i, part: "pause" });
await wait((plan[i].pause - skip) * 1000);
if (!alive()) return;
}
finish(true);
}, [finish, mode, opts.rate, opts.voice, plan]);
const begin = useCallback(() => {
if (clock.running) return;
clock.start();
const p = posRef.current;
setRunning(true);
if (p.part === "lead" && clock.elapsed < 0.1) bell(opts.bell, undefined, 0.6);
void runFrom(Math.max(0, p.index), p.part, p.part === "speak" ? 0 : part.current.elapsed);
}, [clock, opts.bell, runFrom]);
const pause = useCallback(() => {
if (!clock.running) return;
clock.pause();
part.current.pause();
token.current++;
hush();
setRunning(false);
}, [clock]);
const jump = (delta: number) => {
const next = Math.min(plan.length - 1, Math.max(0, posRef.current.index + delta));
token.current++;
hush();
clock.start();
setRunning(true);
void runFrom(next, "speak");
};
useEffect(() => {
hold("guided");
keepAwake(true);
begin();
return () => {
token.current++;
hush();
release("guided");
keepAwake(false);
setMedia("guided", null);
};
}, [begin]);
useEffect(() => {
if (done) { setMedia("guided", null); return; }
setMedia("guided", { title: session.title, album: "Meditate", playing: running, onPlay: begin, onPause: pause, onStop: onClose });
const id = window.setInterval(() => tick((n) => n + 1), 500);
return () => window.clearInterval(id);
}, [begin, done, onClose, pause, running, session.title]);
const before_ = pos.index < 0 ? 0 : LEAD + plan.slice(0, pos.index).reduce((a, p) => a + p.est + p.pause, 0);
const step = plan[pos.index];
const inPart = part.current.elapsed;
const planned = pos.part === "lead" ? Math.min(LEAD, inPart) : before_ + (pos.part === "speak" ? Math.min(step.est, inPart) : step.est + Math.min(step.pause, inPart));
const showWords = mode === "read" || s.words;
return (
<PlayerShell title={session.title} onClose={onClose} phase={done ? "done" : `${pos.part}-${pos.index}`}>
{done ? (
<Done entry={done.entry} seconds={done.sec} what={session.title.toLowerCase()} onClose={onClose} onAgain={onAgain} note={night ? <p className="hint">Rest well.</p> : undefined} />
) : (
<>
<div className="guided-stage">
<div className={`glow${running ? "" : " still"}`} aria-hidden="true" />
<p className="guided-words" aria-live="polite">
{pos.part === "lead" ? "Settle in." : showWords ? step.say : ""}
</p>
</div>
<div className="progress" aria-hidden="true"><span style={{ width: `${Math.min(100, (planned / total) * 100)}%` }} /></div>
<p className="player-time">{running ? `${fmtMinutes(total - planned, true)} left` : "Paused"} · part {Math.max(1, pos.index + 1)} of {plan.length}</p>
<Controls running={running} onToggle={() => (running ? pause() : begin())} onPrev={() => jump(-1)} onNext={() => jump(1)} label="session" />
<div className="player-extra">
{mode === "voice" && <button type="button" className="btn btn-quiet btn-sm" onClick={() => setS({ words: !s.words })}>{s.words ? "Hide words" : "Show words"}</button>}
<button type="button" className="btn btn-quiet btn-sm" onClick={() => finish(false)}>End now</button>
</div>
</>
)}
</PlayerShell>
);
}
export function MeditateRoute({ slug, query }: { slug?: string; query: URLSearchParams }) {
const session = SESSIONS.find((s) => s.slug === slug);
useEffect(() => { if (slug && slug !== "timer" && !session) navigate("/meditate", true); }, [session, slug]);
if (slug === "timer") return <TimerSetup query={query} />;
if (!slug || !session) return <MeditateList />;
return <GuidedSetup key={slug} session={session} query={query} />;
}
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