Mobile

SleepTimerSheet

Stops playing after a set time, with a fade at the end and an extend while it runs.

#mobile#interactive#media#timer#sheet#audio

Demo

Preview

Sleep timer

20 min

Stopped 0 times, level 1

Sleep timer

20 min

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/sleep_timer_sheet.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/sleep_timer_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/sleep_timer_sheet.qwe
// Fourteen silent seconds, small enough to sit in the page.let sleep_timer_sheet_demo_src = "data:audio/mp4;base64,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"component SleepTimerSheetDemoLive() {    state stops = 0    state level = 1    state turn = 0    <div.demo-stack>        <audio id="sleep-demo-audio" src={sleep_timer_sheet_demo_src} preload="auto" loop></audio>        <div.demo-row>            <Button id="sleep-demo-play" size="lg" on_click={() => { js("document.getElementById", "sleep-demo-audio").play() }}>Play</Button>            <SleepTimerSheet key={turn} media="sleep-demo-audio" extend_by={10} on_stop={() => { stops += 1 }} on_volume={(l) => { level = l }}/>        </div>        <p.demo-note id="sleep-demo-note">Stopped {stops} times, level {round(level, 3)}</p>        <div id="sleep-demo-bad"><SleepTimerSheet trigger="Odd props" choices={["x", 0, 5, 5, 1000]} fade="none" start_minutes="soon"/></div>        <Button id="sleep-demo-again" variant="ghost" size="lg" on_click={() => { turn += 1 }}>Make it again</Button>    </div>}view SleepTimerSheetDemo() = <SleepTimerSheetDemoLive/>

Source

ui/sleep_timer_sheet.qwe
// SleepTimerSheet: "stop playing in 30 minutes", from a sheet. Pick a time, or set your own;// the last seconds fade out; while it runs the sheet shows what is left, a button to// add more, and one to cancel. It counts from the clock, not from ticks, so a phone that// slowed the page, or put it to sleep, does not make it late by more than the wait// for the page to run again: on the way back it stops at once if the time has gone.////   import ui/sleep_timer_sheet.qwe use SleepTimerSheet////   <SleepTimerSheet media="player"/>                 // pauses <audio id="player"> by itself//   <SleepTimerSheet open={asking} trigger="" on_close={() => { asking = false }} on_stop={() => { stop_all() }}/>//// media: the id of the <audio> or <video> to fade and pause. Leave it out and use on_stop// and on_volume for a player of your own. trigger: the text of a button that opens it,// which shows the time left while it runs ("Sleep timer"); "" has no button: open it from// your own state with open and on_close. trigger_class: a class for that button.// choices: the minutes offered ([5, 10, 15, 30, 45, 60, 90]). start_minutes: where the// custom time begins (20). fade: seconds of fading (30); 0 offers none. extend_by: minutes// the extend button adds (5). on_start(minutes), on_extend(minutes), on_cancel(), on_stop():// inside one of your components, as those happen. on_volume(level): 1 down to 0 during a fade,// and 1 when it ends or is called off. on_close(): the sheet closed; the timer goes on.//// The sheet closes by its Close button, Escape, a tap outside it, or a pull down on its top.//// iOS does not let a page set a media element's volume. This is found out by trying it,// and the sheet then says so and stops without a fade.import ui/theme.qwe use ui_clslet sleep_timer_sheet_css = css`.ui-sleept-open {  display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 46px; min-height: 46px; padding: 0 14px; border: 1px solid var(--ui-border);  border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 600 14px/1.2 var(--ui-font); font-variant-numeric: tabular-nums;  cursor: pointer; touch-action: manipulation;}.ui-sleept-open:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sleept-on { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-sleept-sheet {  box-sizing: border-box; position: fixed; inset: auto 0 0 0; width: 100%; max-width: none; max-height: min(88dvh, 620px); margin: 0; padding: 0 16px calc(16px + env(safe-area-inset-bottom, 0px));  overflow: auto; overscroll-behavior: contain; border: 0; border-radius: 18px 18px 0 0; background: var(--ui-surface); color: var(--ui-fg); font: 15px/1.4 var(--ui-font); box-shadow: var(--ui-shadow-lg);}.ui-sleept-sheet[open] { display: grid; gap: 12px; animation: ui-sleept-up .24s cubic-bezier(.2, .8, .2, 1); }.ui-sleept-sheet::backdrop { background: var(--ui-overlay); }@keyframes ui-sleept-up { from { transform: translateY(100%); } }.ui-sleept-grip { position: sticky; top: 0; display: grid; place-items: center; height: 26px; margin: 0 -16px; background: var(--ui-surface); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; }.ui-sleept-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-sleept-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }.ui-sleept-head h2 { margin: 0; font-size: 18px; font-weight: 650; }.ui-sleept-close, .ui-sleept-step {  display: grid; place-items: center; min-width: 46px; min-height: 46px; padding: 0 8px; border: 0; border-radius: 12px; background: var(--ui-subtle); color: var(--ui-fg);  font: 600 16px var(--ui-font); cursor: pointer; touch-action: manipulation;}.ui-sleept-close svg { width: 20px; height: 20px; }.ui-sleept-choices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }.ui-sleept-choice, .ui-sleept-go, .ui-sleept-more, .ui-sleept-stop {  min-height: 52px; padding: 6px 10px; border: 1px solid var(--ui-border); border-radius: 14px; background: var(--ui-surface); color: var(--ui-fg);  font: 600 15px/1.2 var(--ui-font); cursor: pointer; touch-action: manipulation;}.ui-sleept-go, .ui-sleept-more { border-color: transparent; background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-sleept-own { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 8px; }.ui-sleept-own output { text-align: center; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }.ui-sleept-fade { display: flex; align-items: center; gap: 12px; min-height: 48px; cursor: pointer; }.ui-sleept-fade input { flex: none; width: 24px; height: 24px; margin: 0; accent-color: var(--ui-accent); }.ui-sleept-left { margin: 8px 0 2px; text-align: center; font-size: 46px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.1; }.ui-sleept-sub { margin: 0; text-align: center; color: var(--ui-muted); font-size: 13px; }.ui-sleept-acts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }.ui-sleept-pick:not([hidden]) { display: grid; gap: 12px; }.ui-sleept-note { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-sleept-sheet :focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) {  .ui-sleept-choice:hover, .ui-sleept-stop:hover, .ui-sleept-open:hover { background: var(--ui-subtle); }}/* A whole window's width: a dialog in the middle, with room to lay the choices out, and no sheet to pull. */@media (min-width: 640px) {  .ui-sleept-sheet { inset: 0; width: 540px; height: fit-content; max-height: 90dvh; margin: auto; padding: 16px 20px 20px; border-radius: 18px; }  .ui-sleept-sheet[open] { animation: none; }  .ui-sleept-grip { display: none; }  .ui-sleept-choices { grid-template-columns: repeat(4, 1fr); }}@media (prefers-reduced-motion: reduce) { .ui-sleept-sheet[open] { animation: none; } }`// Minutes as a person says them.fn sleep_timer_sheet_label(minutes) -> str {    let m = floor(minutes)    let h = floor(m / 60)    let rest = m % 60    h == 0 ? `${m} min` : rest == 0 ? `${h} h` : `${h} h ${rest} min`}// Seconds left as "m:ss", or "h:mm:ss" from an hour.fn sleep_timer_sheet_clock(s) -> str {    let t = max([0, ceil(s)])    let h = floor(t / 3600)    let m = floor((t % 3600) / 60)    let sec = t % 60    let two = (n) => n < 10 ? `0${n}` : `${n}`    h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`}// A number of minutes that can be used, 1 to 12 hours; anything else is `fallback`.fn sleep_timer_sheet_minutes(v, fallback) {    let t = type_of(v)    if (t != "int" && t != "float") || !math.isfinite(v) { return fallback }    max([1, min([floor(v), 720])])}// The choices that can be used, in order, each once.fn sleep_timer_sheet_clean(list_in) -> list {    if type_of(list_in) != "list" { return [] }    list_in | filter((x) => type_of(x) == "int" && x >= 1 && x <= 720) | unique() | sort()}// The volume at a time left in a fade: the square of the share left, since hearing is// not linear, and a straight line sounds as if nothing happens until the very end.fn sleep_timer_sheet_level(left, fade) {    if fade <= 0 || left >= fade { return 1 }    if left <= 0 { return 0 }    let share = left / fade    share * share}component SleepTimerSheet(media: str = "", trigger: str = "Sleep timer", trigger_class: str? = null, open: bool = false,                          choices = [5, 10, 15, 30, 45, 60, 90], start_minutes = 20, fade = 30, extend_by: int = 5,                          on_start = null, on_extend = null, on_cancel = null, on_stop = null, on_volume = null, on_close = null) {    style global sleep_timer_sheet_css    let offered = sleep_timer_sheet_clean(choices)    let fade_secs = type_of(fade) == "int" && fade > 0 ? fade : 0    state dlg = null    state doc = null    state ends = null         // the clock's time at which it stops, in ms; null when not running    state left = 0    state fading = false    state keep_vol = 1        // the volume to go back to    state own = sleep_timer_sheet_minutes(start_minutes, 20)    state fade_on = fade_secs > 0    state can_fade = true    state said = ""    state pull = null         // a pull on the top of the sheet, while it lasts    if open && dlg != null && !dlg.open { dlg.showModal() }    if !open && dlg != null && dlg.open && trigger == "" { dlg.close() }    let element = () => media == "" || doc == null ? null : doc.getElementById(media)    let set_volume = (level) => {        let m = element()        if m != null { m.volume = keep_vol * level }        on_volume && on_volume(level)    }    let unfade = () => {        if fading {            fading = false            set_volume(1)        }    }    // Writing and reading back is the only way to learn whether the volume can be set.    let test_fade = () => {        let m = element()        if m != null {            let v = m.volume            m.volume = v > 0.5 ? v - 0.25 : v + 0.25            can_fade = m.volume != v            m.volume = v        }    }    let begin = (minutes) => {        test_fade()        let clock = js("Date.now")        ends = clock + minutes * 60000        left = minutes * 60        said = ""        on_start && on_start(minutes)    }    let finish = () => {        let m = element()        ends = null        left = 0        if m != null { m.pause() }        unfade()        said = "Stopped."        on_stop && on_stop()    }    let tick = () => {        if ends != null {            let clock = js("Date.now")            if ends != null {                let ms = ends - clock                left = ceil(ms / 1000)                if ms <= 0 {                    finish()                } else if fade_on && can_fade && fade_secs > 0 && ms < fade_secs * 1000 {                    if !fading {                        fading = true                        let m = element()                        if m != null { keep_vol = m.volume }                    }                    set_volume(sleep_timer_sheet_level(ms / 1000, fade_secs))                } else {                    unfade()                }            }        }    }    if ends != null { every(fading ? 200 : 1000, tick) }    <div.ui-sleept-host style="display: contents">        {trigger != "" && <button class={ui_cls(["ui-sleept-open", ends != null && "ui-sleept-on", trigger_class])} type="button" aria-haspopup="dialog"                aria-label={ends != null ? `${trigger}: ${sleep_timer_sheet_clock(left)} left` : trigger}                @click={() => { dlg.showModal() }}>{ends != null ? sleep_timer_sheet_clock(left) : trigger}</button>}        <dialog.ui-sleept-sheet aria-label="Sleep timer"                @mount={(el) => {                    dlg = el                    doc = js("document")                    if open { el.showModal() }                    // A page's clock is slowed or stopped when it is out of sight. Media goes on                    // sending timeupdate, and coming back says so too.                    let heard = (e) => { if e.target.id == media { tick() } }                    let back = (_e) => { if doc.visibilityState == "visible" { tick() } }                    doc.addEventListener("timeupdate", heard, true)                    doc.addEventListener("visibilitychange", back)                    () => {                        doc.removeEventListener("timeupdate", heard, true)                        doc.removeEventListener("visibilitychange", back)                    }                }}                @close={() => { on_close && on_close() }}                @click={(e) => { if e.target.tagName == "DIALOG" { dlg.close() } }}>            <div.ui-sleept-grip aria-hidden="true"                 @pointerdown={(e) => {                     e.target.setPointerCapture(e.pointerId)                     pull = { y: e.clientY, dy: 0, a: { y: e.clientY, t: e.timeStamp }, b: { y: e.clientY, t: e.timeStamp } }                 }}                 @pointermove={(e) => {                     if pull != null {                         let p = pull                         let dy = max([0, e.clientY - p.y])                         // Two points kept, moving on every 50 ms: the last move alone may be a moment old and say nothing of the speed.                         let old = e.timeStamp - p.b.t > 50                         pull = { y: p.y, dy: dy, a: old ? p.b : p.a, b: old ? { y: e.clientY, t: e.timeStamp } : p.b }                         dlg.style.transform = `translateY(${dy}px)`                     }                 }}                 @pointerup={(e) => {                     if pull != null {                         let p = pull                         let speed = (e.clientY - p.a.y) / max([1, e.timeStamp - p.a.t])                         pull = null                         dlg.style.transform = ""                         if p.dy > 110 || (p.dy > 24 && speed > 0.5) { dlg.close() }                     }                 }}                 @pointercancel={() => {                     pull = null                     dlg.style.transform = ""                 }}><span></span></div>            <div.ui-sleept-head>                <h2>Sleep timer</h2>                <button.ui-sleept-close type="button" aria-label="Close" @click={() => { dlg.close() }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>                </button>            </div>            <div.ui-sleept-running hidden={ends == null}>                <p.ui-sleept-left role="timer" aria-label={`${sleep_timer_sheet_clock(left)} left`}>{sleep_timer_sheet_clock(left)}</p>                <p.ui-sleept-sub>{fading ? "Fading out" : "until it stops"}</p>                <div.ui-sleept-acts>                    <button.ui-sleept-stop type="button" @click={() => {                        ends = null                        left = 0                        unfade()                        said = "Timer cancelled."                        on_cancel && on_cancel()                    }}>Cancel timer</button>                    <button.ui-sleept-more type="button" @click={() => {                        ends = ends + extend_by * 60000                        left = left + extend_by * 60                        unfade()                        on_extend && on_extend(extend_by)                    }}>Add {sleep_timer_sheet_label(extend_by)}</button>                </div>            </div>            <div.ui-sleept-pick hidden={ends != null}>                <div.ui-sleept-choices role="group" aria-label="Stop after">                    {for m in offered => <button.ui-sleept-choice key={m} type="button" @click={() => { begin(m) }}>{sleep_timer_sheet_label(m)}</button>}                </div>                <div.ui-sleept-own>                    <button.ui-sleept-step type="button" aria-label="Five minutes less" @click={() => { own = sleep_timer_sheet_minutes(own - 5, 1) }}>-</button>                    <output aria-live="polite">{sleep_timer_sheet_label(own)}</output>                    <button.ui-sleept-step type="button" aria-label="Five minutes more" @click={() => { own = sleep_timer_sheet_minutes(own + 5, 1) }}>+</button>                    <button.ui-sleept-go type="button" @click={() => { begin(own) }}>Start</button>                </div>                {fade_secs > 0 && can_fade && <label.ui-sleept-fade>                    <input type="checkbox" checked={fade_on} @change={(e) => { fade_on = e.checked }}/>                    <span>Fade out over the last {fade_secs} seconds</span>                </label>}            </div>            {!can_fade && <p.ui-sleept-note>This device does not let a page change the volume, so the sound stops without a fade.</p>}            <p.ui-sleept-note role="status">{said}</p>        </dialog>    </div>}
Thread
38Useful? Like it, or add to its thread below.

Thread 0 posts

On SleepTimerSheet: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.