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/Sounds.tsx
// Sounds: mix rain, waves, fire and noise, each with its own volume, save mixes, and set a sleep
// timer that fades everything out slowly.
import { useEffect, useState } from "react";
import { SOUNDS } from "./lib/noise";
import { MIXES_KEY, PRESETS, fadeSeconds, mixName, mixParam, parseMix, pause, play, setLevel, setMix, setTimer, toggleSound, useMix, type Levels, type SavedMix, type Timer } from "./lib/mixer";
import { navigate } from "./lib/router";
import { useStored } from "./lib/store";
import { SITE_URL } from "../functions/lib/config";
import { A, Icon, share, toast, fmtClock } from "./ui";
const TIMER_OPTIONS = [0, 15, 30, 45, 60, 90, 120];
/** Seconds left on a running or paused sleep timer, updated every second. */
export function useTimerLeft(timer: Timer | null): number | null {
const [, tick] = useState(0);
useEffect(() => {
if (!timer?.endsAt) return;
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [timer?.endsAt]);
if (!timer) return null;
return timer.endsAt ? Math.max(0, (timer.endsAt - Date.now()) / 1000) : timer.left;
}
export function SleepTimer() {
const { timer } = useMix();
const left = useTimerLeft(timer);
const [custom, setCustom] = useState("");
const value = timer?.minutes ?? 0;
return (
<div className="field">
<span>Sleep timer</span>
<div className="chips" role="radiogroup" aria-label="Sleep timer">
{TIMER_OPTIONS.map((m) => (
<button key={m} type="button" role="radio" className="chip-btn" aria-checked={value === m} onClick={() => setTimer(m || null)}>
{m ? (m < 60 ? `${m} min` : `${m / 60} hr`) : "Off"}
</button>
))}
<form className="chip-custom" onSubmit={(e) => { e.preventDefault(); const m = Math.round(Number(custom)); if (m >= 1 && m <= 600) { setTimer(m); setCustom(""); } }}>
<input className="input" inputMode="numeric" placeholder="Other" aria-label="Other length in minutes" value={custom} onChange={(e) => setCustom(e.target.value.replace(/\D/g, "").slice(0, 3))} />
</form>
</div>
<small>{timer && left != null ? `${fmtClock(left)} left${timer.endsAt ? "" : " once sound plays"}. Fades out over the last ${Math.round(fadeSeconds(timer.minutes) / 60) || 1} min.` : "Sound plays until you stop it."}</small>
</div>
);
}
export function Sounds({ query }: { query: URLSearchParams }) {
const mix = useMix();
const [saved, setSaved] = useStored<SavedMix[]>(MIXES_KEY, []);
const linked = parseMix(query.get("mix"));
// A shared link sets the mix (playing needs a tap: browsers only start sound after one).
useEffect(() => {
if (!linked) return;
setMix(linked, false);
const t = Math.round(Number(query.get("timer")));
if (t >= 1 && t <= 600) setTimer(t);
navigate("/sounds", true);
}, []);
const on = SOUNDS.filter((s) => (mix.levels[s.id] ?? 0) > 0);
const save = () => {
const name = window.prompt("Name this mix", mixName(mix.levels))?.trim();
if (!name) return;
setSaved([...saved.filter((m) => m.name !== name), { name: name.slice(0, 40), levels: mix.levels }]);
toast("Mix saved on this device");
};
const isMix = (l: Levels) => mixParam(l) === mixParam(mix.levels);
return (
<div className="page wrap">
<header className="page-head">
<p className="eyebrow">Sounds</p>
<h1 className="page-title">Rain, waves and noise.</h1>
<p className="page-lede">Tap sounds to mix them and set each volume. Made on your device, so they never run out and work offline.</p>
</header>
<div className="now card">
<button type="button" className="play-btn" disabled={!on.length} onClick={() => (mix.playing ? pause() : play())} aria-label={mix.playing ? "Pause sounds" : "Play sounds"}>
<Icon name={mix.playing ? "pause" : "play"} size={28} />
</button>
<div className="now-text">
<span className="now-title">{on.length ? mixName(mix.levels) : "Pick a sound below"}</span>
<span className="now-sub">{mix.playing ? "Playing" : on.length ? "Paused" : "Nothing playing"}</span>
</div>
{on.length > 0 && (
<button type="button" className="round-btn" aria-label="Share this mix" onClick={() => void share(`${SITE_URL}/sounds?mix=${mixParam(mix.levels)}`, `${mixName(mix.levels)} on Still`)}>
<Icon name="share" size={20} />
</button>
)}
</div>
<ul className="sound-grid">
{SOUNDS.map((s) => {
const level = mix.levels[s.id] ?? 0;
return (
<li key={s.id} className={`sound${level > 0 ? " on" : ""}`}>
<button type="button" className="sound-btn" aria-pressed={level > 0} onClick={() => toggleSound(s.id)}>
<span className="sound-name">{s.name}</span>
<span className="sound-hint">{s.hint}</span>
</button>
{level > 0 && (
<input className="range" type="range" min={0.05} max={1} step={0.01} value={level} aria-label={`${s.name} volume`} onChange={(e) => setLevel(s.id, Number(e.target.value))} />
)}
</li>
);
})}
</ul>
<div className="sounds-lower">
<div className="card pad">
<SleepTimer />
</div>
<div className="card pad">
<div className="field">
<span>Mixes</span>
<div className="chips">
{PRESETS.map((p) => (
<button key={p.name} type="button" className="chip-btn" aria-pressed={isMix(p.levels)} onClick={() => setMix(p.levels, true)}>{p.name}</button>
))}
</div>
</div>
<div className="field">
<span className="spread">Your mixes <button type="button" className="btn btn-line btn-sm" disabled={!on.length} onClick={save}>Save this mix</button></span>
{saved.length ? (
<ul className="saved">
{saved.map((m) => (
<li key={m.name}>
<button type="button" className="saved-play" aria-pressed={isMix(m.levels)} onClick={() => setMix(m.levels, true)}>
<strong>{m.name}</strong><span>{mixName(m.levels)}</span>
</button>
<button type="button" className="icon-x" aria-label={`Delete ${m.name}`} onClick={() => setSaved(saved.filter((x) => x.name !== m.name))}><Icon name="close" size={16} /></button>
</li>
))}
</ul>
) : (
<small>Saved mixes stay on this device.</small>
)}
</div>
</div>
</div>
<p className="hint fine">On Android, sounds keep playing with the screen off and show controls on the lock screen. On iPhone, Safari may stop sound when the screen locks; keep Still open, or use the dim screen on the Sleep page. <A href="/sleep">Go to Sleep</A></p>
</div>
);
}
/** A small bar while sounds play elsewhere in the app. */
export function MiniPlayer() {
const mix = useMix();
const left = useTimerLeft(mix.timer);
const on = Object.values(mix.levels).some((v) => (v ?? 0) > 0);
if (!on || (!mix.playing && !mix.timer)) return null;
return (
<div className="mini" role="region" aria-label="Sounds playing">
<button type="button" className="mini-play" onClick={() => (mix.playing ? pause() : play())} aria-label={mix.playing ? "Pause sounds" : "Play sounds"}>
<Icon name={mix.playing ? "pause" : "play"} size={18} />
</button>
<A className="mini-text" href="/sounds">
<strong>{mixName(mix.levels)}</strong>
<span>{left != null ? `${fmtClock(left)} left` : mix.playing ? "Playing" : "Paused"}</span>
</A>
</div>
);
}
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