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 23e397d8a5d5bf7756362640
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/lib/timer.ts
// The rest timer's sound and vibration, and keeping the screen on during a workout.
// The end-of-rest beeps are scheduled on the audio clock as soon as rest starts, so they still play
// when the browser slows down timers in the background (on iPhone, a locked screen stops sound).
import { getActive, getState, updateActive } from "./store";
let ctx: AudioContext | null = null;
let beeps: OscillatorNode[] = [];
let buzzTimer: number | undefined;
/** Creates or wakes the audio engine. Browsers only allow this during a tap, so taps call it. */
export function unlockAudio() {
if (!ctx) {
const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
// Short sounds that mix with the music a person is listening to.
const session = (navigator as Navigator & { audioSession?: { type: string } }).audioSession;
if (session) { try { session.type = "transient"; } catch { /* older Safari */ } }
ctx = new AC();
ctx.addEventListener("statechange", arm);
}
if (ctx.state !== "running") void ctx.resume().catch(() => null);
}
function beep(c: AudioContext, at: number, freq: number, length: number) {
const o = c.createOscillator();
const g = c.createGain();
o.type = "sine";
o.frequency.value = freq;
g.gain.setValueAtTime(0, at);
g.gain.linearRampToValueAtTime(0.32, at + 0.01);
g.gain.setValueAtTime(0.32, at + length - 0.04);
g.gain.linearRampToValueAtTime(0, at + length);
o.connect(g).connect(c.destination);
o.start(at);
o.stop(at + length + 0.02);
beeps.push(o);
}
function cancel() {
for (const o of beeps) { try { o.stop(); } catch { /* already done */ } }
beeps = [];
window.clearTimeout(buzzTimer);
}
/** Schedules (again) the sound and vibration for the rest timer as it stands. */
export function arm() {
cancel();
const rest = getActive().rest;
if (!rest) return;
const left = (rest.end - Date.now()) / 1000;
if (left <= 0) return;
const { sound, vibrate } = getState().settings;
if (sound && ctx?.state === "running") {
const t = ctx.currentTime + left;
if (left > 2.2) beep(ctx, t - 2, 660, 0.12);
if (left > 1.2) beep(ctx, t - 1, 660, 0.12);
beep(ctx, t, 990, 0.42);
}
if (vibrate && canVibrate) buzzTimer = window.setTimeout(() => buzz([300, 120, 300]), left * 1000);
}
/** Starts the rest timer. */
export function startRest(seconds: number) {
if (seconds <= 0) return;
unlockAudio();
updateActive((a) => { a.rest = { end: Date.now() + seconds * 1000, total: seconds }; });
arm();
}
export function adjustRest(delta: number) {
updateActive((a) => {
if (!a.rest) return;
a.rest.end += delta * 1000;
a.rest.total = Math.max(1, a.rest.total + delta);
if (a.rest.end <= Date.now()) a.rest = null;
});
arm();
}
export function stopRest() {
updateActive((a) => { a.rest = null; });
cancel();
}
/** A short test of the end-of-rest sound, from settings. */
export function testSound() {
unlockAudio();
if (!ctx) return;
const c = ctx;
const go = () => { const t = c.currentTime + 0.05; beep(c, t, 660, 0.12); beep(c, t + 0.4, 990, 0.3); };
if (c.state === "running") go(); else void c.resume().then(go).catch(() => null);
}
/** Phone vibration (Android; iPhones do not let web pages vibrate). */
export const canVibrate = typeof navigator !== "undefined" && "vibrate" in navigator;
export function buzz(pattern: number | number[]) {
if (canVibrate && document.visibilityState === "visible") { try { navigator.vibrate(pattern); } catch { /* not allowed */ } }
}
// Keeps the screen on during a workout where the browser allows it. Browsers drop the lock when the
// page is hidden, so it is asked for again when the page comes back.
type Sentinel = { release(): Promise<void>; addEventListener(t: "release", f: () => void): void };
let lock: Sentinel | null = null;
let wanted = false;
async function acquire() {
const wl = (navigator as Navigator & { wakeLock?: { request(t: "screen"): Promise<Sentinel> } }).wakeLock;
if (!wl || lock || !wanted || document.visibilityState !== "visible") return;
lock = await wl.request("screen").catch(() => null);
lock?.addEventListener("release", () => { lock = null; });
}
export function keepAwake(on: boolean) {
wanted = on;
if (on) void acquire();
else if (lock) { void lock.release().catch(() => null); lock = null; }
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => { void acquire(); if (document.visibilityState === "visible") arm(); });
}
Change history
Point OpenApps store links to openapps.free
Add favicon.ico, local share image and openapi.json for links and guides
Bind agent address in policy constants
Add OpenApp contribution strip and OpenApps source links
Bounded source sync
Bounded source sync
Bounded source sync