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/Breathe.tsx
// Breathe: guided breathing with a slow shape that grows as you breathe in and shrinks as you
// breathe out. Box, 4-7-8, coherent, or a pattern of your own; 1 to 20 minutes.
import { useCallback, useEffect, useRef, useState } from "react";
import { audio, bell, breathTone, buzz, canVibrate, hold, keepAwake, release, setMedia, type Voice } from "./lib/audio";
import { navigate } from "./lib/router";
import { settings, useSettings } from "./lib/store";
import { LENGTHS, PATTERNS, parsePattern, type Pattern } from "./content/patterns";
import { Clock, Controls, Done, PlayerShell, finishSession } from "./Player";
import { A, Chips, Icon, Mood, Toggle, fmtClock, useReducedMotion } from "./ui";
import type { Entry } from "./lib/progress";

type Phases = [number, number, number, number];
type PhaseKind = "in" | "hold-in" | "out" | "hold-out";
const LABELS: Record<PhaseKind, string> = { in: "Breathe in", "hold-in": "Hold", out: "Breathe out", "hold-out": "Hold" };
const KINDS: PhaseKind[] = ["in", "hold-in", "out", "hold-out"];

export function BreatheList() {
  return (
    <div className="page wrap">
      <header className="page-head">
        <p className="eyebrow">Breathe</p>
        <h1 className="page-title">Slow your breathing.</h1>
        <p className="page-lede">Follow the shape: breathe in as it grows, hold when it rests, breathe out as it shrinks.</p>
      </header>
      <ul className="card-list">
        {PATTERNS.map((p) => (
          <li key={p.slug}>
            <A className="item-card" href={`/breathe/${p.slug}`}>
              <span className="item-main">
                <span className="item-title">{p.name}</span>
                <span className="item-meta">{p.short}</span>
                <span className="item-text">{p.about}</span>
              </span>
              <span className="item-go" aria-hidden="true"><Icon name="play" size={16} /></span>
            </A>
          </li>
        ))}
      </ul>
    </div>
  );
}

const fmtSec = (n: number) => (n % 1 ? n.toFixed(1) : String(n));

function Stepper({ label, value, min, onChange }: { label: string; value: number; min: number; onChange: (v: number) => void }) {
  return (
    <div className="stepper">
      <span className="stepper-label">{label}</span>
      <div className="stepper-row">
        <button type="button" className="round-btn" aria-label={`Shorter ${label.toLowerCase()}`} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 0.5))}><Icon name="minus" size={18} /></button>
        <span className="stepper-value" aria-live="polite">{fmtSec(value)}<small>s</small></span>
        <button type="button" className="round-btn" aria-label={`Longer ${label.toLowerCase()}`} disabled={value >= 20} onClick={() => onChange(Math.min(20, value + 0.5))}><Icon name="plus" size={18} /></button>
      </div>
    </div>
  );
}

export function BreatheSetup({ pattern, query }: { pattern: Pattern; query: URLSearchParams }) {
  const [s, setS] = useSettings();
  const qMin = Number(query.get("min"));
  const [minutes, setMinutes] = useState(qMin >= 1 && qMin <= 20 ? Math.round(qMin) : 3);
  const [phases, setPhases] = useState<Phases>(() => (pattern.slug === "custom" && parsePattern(query.get("pattern"))) || pattern.phases);
  const [before, setBefore] = useState<number | undefined>();
  const [run, setRun] = useState(0);
  const custom = pattern.slug === "custom";
  const cycle = phases.reduce((a, b) => a + b, 0);
  const title = custom ? `Breathing ${phases.map(fmtSec).join("-")}` : pattern.name;
  const lengths = LENGTHS.includes(minutes) ? LENGTHS : [...LENGTHS, minutes].sort((a, b) => a - b);

  return (
    <div className="page wrap narrow">
      <A className="back-link" href="/breathe"><Icon name="back" size={18} /> Breathe</A>
      <header className="page-head">
        <h1 className="page-title">{pattern.name}</h1>
        <p className="page-lede">{pattern.about}</p>
      </header>
      <div className="setup card">
        {custom && (
          <div className="steppers">
            {(["Breathe in", "Hold", "Breathe out", "Hold"] as const).map((label, i) => (
              <Stepper key={i} label={label} value={phases[i]} min={i % 2 === 0 ? 1 : 0} onChange={(v) => setPhases(phases.map((p, j) => (j === i ? v : p)) as Phases)} />
            ))}
          </div>
        )}
        <div className="field">
          <span>Length</span>
          <Chips label="Length in minutes" options={lengths} value={minutes} onChange={setMinutes} format={(m) => `${m} min`} />
          <small>{Math.max(1, Math.round((minutes * 60) / cycle))} breaths of {fmtSec(cycle)} seconds each.</small>
        </div>
        <Toggle label="Soft tones" hint="A rising tone as you breathe in, falling as you breathe out." checked={s.tones} onChange={(v) => setS({ tones: v })} />
        {canVibrate && <Toggle label="Vibration" hint="A short buzz when each part begins." checked={s.vibrate} onChange={(v) => setS({ vibrate: v })} />}
        <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 && (
        <BreathePlayer
          key={run}
          title={title}
          phases={phases}
          minutes={minutes}
          before={before}
          onClose={() => setRun(0)}
          onAgain={() => setRun((r) => r + 1)}
        />
      )}
    </div>
  );
}

/** The phase at a moment of the session, and how far through it is (0 to 1). */
function phaseAt(phases: Phases, t: number) {
  const cycle = phases.reduce((a, b) => a + b, 0);
  let x = t % cycle;
  for (let i = 0; i < 4; i++) {
    if (phases[i] > 0 && x < phases[i]) return { kind: KINDS[i], progress: x / phases[i], left: phases[i] - x };
    x -= phases[i];
  }
  return { kind: "in" as PhaseKind, progress: 0, left: phases[0] };
}

const ease = (p: number) => 0.5 - Math.cos(Math.PI * p) / 2;
const SMALL = 0.42;

function scaleFor(kind: PhaseKind, p: number) {
  if (kind === "in") return SMALL + (1 - SMALL) * ease(p);
  if (kind === "out") return 1 - (1 - SMALL) * ease(p);
  return kind === "hold-in" ? 1 : SMALL;
}

function BreathePlayer({ title, phases, minutes, before, onClose, onAgain }: { title: string; phases: Phases; minutes: number; before?: number; onClose: () => void; onAgain: () => void }) {
  const reduced = useReducedMotion();
  const cycle = phases.reduce((a, b) => a + b, 0);
  const total = Math.max(1, Math.round((minutes * 60) / cycle)) * cycle;
  const clock = useRef(new Clock()).current;
  const disc = useRef<HTMLDivElement>(null);
  const ring = useRef<SVGCircleElement>(null);
  const tones = useRef<Voice[]>([]);
  const anchor = useRef({ audio: 0, session: 0, until: 0 });
  const [view, setView] = useState({ kind: "in" as PhaseKind, count: Math.ceil(phases[0]), left: total });
  const [running, setRunning] = useState(true);
  const [done, setDone] = useState<{ entry: Entry | null; sec: number } | null>(null);
  const opts = useRef(settings()).current;

  /** Schedules breathing tones on the audio clock up to two minutes ahead, so they keep time even when the screen is off. */
  const topUp = useCallback(() => {
    if (!opts.tones || !clock.running) return;
    const now = clock.elapsed;
    const a = anchor.current;
    const horizon = Math.min(total, now + 120);
    let start = Math.floor(a.until / cycle) * cycle;
    while (start < horizon) {
      let t = start;
      for (let i = 0; i < 4; i++) {
        const kind = KINDS[i];
        if (phases[i] > 0 && (kind === "in" || kind === "out") && t >= a.until && t < horizon && t + 0.05 >= now) {
          tones.current.push(breathTone(kind, a.audio + (t - a.session), phases[i]));
        }
        t += phases[i];
      }
      start += cycle;
    }
    a.until = Math.max(a.until, horizon);
  }, [clock, cycle, opts.tones, phases, total]);

  const stopTones = () => { tones.current.forEach((v) => v.stop()); tones.current = []; };

  const begin = useCallback(() => {
    if (clock.running) return;
    const { ctx } = audio();
    clock.start();
    const now = clock.elapsed;
    anchor.current = { audio: ctx.currentTime + 0.08, session: now, until: now };
    topUp();
    setRunning(true);
  }, [clock, topUp]);

  const pause = useCallback(() => {
    clock.pause();
    stopTones();
    setRunning(false);
  }, [clock]);

  const finish = useCallback((completed: boolean) => {
    clock.pause();
    stopTones();
    const sec = Math.min(total, clock.elapsed);
    if (completed) bell(opts.bell, undefined, 0.7);
    setDone({ entry: finishSession("breathe", title, sec, completed, before), sec });
  }, [before, clock, opts.bell, title, total]);

  useEffect(() => {
    hold("breathe");
    keepAwake(true);
    begin();
    return () => {
      stopTones();
      release("breathe");
      keepAwake(false);
      setMedia("breathe", null);
    };
  }, [begin]);

  useEffect(() => {
    if (done) { setMedia("breathe", null); return; }
    setMedia("breathe", { title, album: "Breathe", playing: running, onPlay: begin, onPause: pause, onStop: onClose });
  }, [begin, done, onClose, pause, running, title]);

  useEffect(() => {
    if (done) return;
    let raf = 0;
    let last: PhaseKind | null = null;
    const frame = () => {
      const t = clock.elapsed;
      if (t >= total) return finish(true);
      const ph = phaseAt(phases, t);
      if (disc.current) disc.current.style.transform = `scale(${reduced ? 0.8 : scaleFor(ph.kind, ph.progress)})`;
      if (ring.current) ring.current.style.strokeDashoffset = String(1 - ph.progress);
      const count = Math.ceil(ph.left);
      setView((v) => (v.kind === ph.kind && v.count === count && Math.ceil(v.left) === Math.ceil(total - t) ? v : { kind: ph.kind, count, left: total - t }));
      if (ph.kind !== last) {
        if (last && clock.running && opts.vibrate) buzz(ph.kind === "in" ? 60 : ph.kind === "out" ? [30, 70, 30] : 20);
        last = ph.kind;
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    const top = window.setInterval(topUp, 5000);
    return () => { cancelAnimationFrame(raf); window.clearInterval(top); };
  }, [clock, done, finish, opts.vibrate, phases, reduced, topUp, total]);

  return (
    <PlayerShell title={title} onClose={onClose} phase={done ? "done" : view.kind}>
      {done ? (
        <Done entry={done.entry} seconds={done.sec} what={title.toLowerCase().startsWith("breathing") ? "breathing" : title.toLowerCase()} onClose={onClose} onAgain={onAgain} />
      ) : (
        <>
          <div className="breath-stage">
            <div className="breath-guide" aria-hidden="true" />
            <div className="breath-disc" ref={disc} aria-hidden="true" />
            {reduced && (
              <svg className="breath-ring" viewBox="0 0 100 100" aria-hidden="true">
                <circle ref={ring} cx="50" cy="50" r="46" pathLength={1} strokeDasharray="1" />
              </svg>
            )}
            <div className="breath-words" aria-live="polite">
              <span className="breath-label">{running ? LABELS[view.kind] : "Paused"}</span>
              {running && <span className="breath-count">{view.count}</span>}
            </div>
          </div>
          <p className="player-time">{fmtClock(view.left)} left</p>
          <Controls running={running} onToggle={() => (running ? pause() : begin())} label="breathing" />
          <button type="button" className="btn btn-quiet end-btn" onClick={() => finish(false)}>End now</button>
        </>
      )}
    </PlayerShell>
  );
}

export function BreatheRoute({ slug, query }: { slug?: string; query: URLSearchParams }) {
  const pattern = PATTERNS.find((p) => p.slug === slug);
  useEffect(() => { if (slug && !pattern) navigate("/breathe", true); }, [pattern, slug]);
  if (!slug || !pattern) return <BreatheList />;
  return <BreatheSetup key={slug} pattern={pattern} 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