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/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
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