still-free.openapps.xyz

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.
Owned app · rev 0fa0e84d537b
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.
← All files
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
0fa0e84d53 · 19 hours ago
Bind the agent address in the policy
6f3baa1c72 · 1 day ago
Add favicon.ico so browsers that ask for it stop getting a 404
1a004fe0e3 · 1 day ago
Bounded source sync
afe642fe50 · 2 days ago
Bounded source sync
2c581f55ea · 2 days ago
Bounded source sync
375934c28b · 2 days ago
Bounded source sync
74ff37d51a · 2 days ago