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