SleepTimerSheet
Stops playing after a set time, with a fade at the end and an extend while it runs.
Demo
Stopped 0 times, level 1
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.qwemain.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>}Related
MiniPlayerA bar above the tabs with progress and play, expanding to the full player by a swipe up.2 views · 5 downloadsNowPlayingA full player with art, scrub, queue, speed and a sleep timer, tied to the media session.2 views · 5 downloadsScrubBarA seek bar with a large touch area, fine scrubbing by moving a finger away from it, and a time bubble.4 views · 7 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAnchoredPopoverA popover with an arrow tied to its button on a tablet and a sheet on a phone, re-anchored on rotation and when a keyboard opens.3 views · 5 downloads
Thread
Thread 0 posts
On SleepTimerSheet: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.