Mobile

DataSaverToggle

A setting to keep lighter images and off autoplay, following the phone's save-data, with what it changes.

#mobile#interactive#offline#settings#data#tablet

Demo

Preview
Data saver

What it changes

  • PicturesFull size
  • VideoPlays by itself
  • MapsLoaded at once

Nothing reported yet.

The harbour at noon

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/data_saver_toggle.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/data_saver_toggle.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/data_saver_toggle.qwe
let data_saver_toggle_demo_full = "data:image/svg+xml," + url_encode("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 160 90'><rect width='160' height='90' fill='#4b6cb7'/><circle cx='120' cy='30' r='14' fill='#ffd76a'/></svg>")let data_saver_toggle_demo_lite = "data:image/svg+xml," + url_encode("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 9'><rect width='16' height='9' fill='#9aa8c4'/></svg>")component DataSaverToggleDemoLive() {    state told = "Nothing reported yet."    state more = 0    <div.demo-stack>        <DataSaverToggle id="dsave-demo" on_change={(on, mode) => { told = `on_change: ${on} ${mode}.` }}                         changes={[{ label: "Pictures", off: "Full size", on: "Lighter versions" }, { label: "Video", off: "Plays by itself", on: "Waits for a press" }, { label: "Maps", off: "Loaded at once", on: "Loaded on a press" }]}/>        <p.demo-note id="dsave-told">{told}</p>        <div.demo-row>            <img id="dsave-photo" src={data_saver_toggle_demo_full} srcset={`${data_saver_toggle_demo_full} 2x`} data-lite={data_saver_toggle_demo_lite} alt="The harbour at noon" width="160" height="90"/>            {for i in range(more) => <img key={i} class="dsave-added" src={data_saver_toggle_demo_full} data-lite={data_saver_toggle_demo_lite} alt={`Added picture ${i}`} width="160" height="90"/>}            <video id="dsave-clip" autoplay="true" muted="true" playsinline="true" width="160" height="90" aria-label="A clip"></video>        </div>        <div.demo-row><Button size="lg" variant="outline" id="dsave-more" on_click={() => { more += 1 }}>Add a picture</Button></div>    </div>}view DataSaverToggleDemo() = <DataSaverToggleDemoLive/>

Source

ui/data_saver_toggle.qwe
// DataSaverToggle: a setting for using less data: lighter pictures and no video playing by// itself. It can follow the phone's own data saver, or be on or off here, and it shows what// it changes, on this page as well as in general. It does the work itself on what is on the// page, and marks the page so a program's CSS and routes can do more.////   import ui/data_saver_toggle.qwe use DataSaverToggle////   route GET / = <body>//       <DataSaverToggle saved={cookie("ui-data-saver")}/>//       <img src="/photo-1600.jpg" data-lite="/photo-400.jpg" alt="The harbour"/>//       <video src="/clip.mp4" autoplay muted playsinline></video>//   </body>//// saved: the choice, "follow", "on" or "off", which it keeps in a cookie called// ui-data-saver, as cookies last in a phone app, where localStorage comes back empty each// launch. A route can read it, so the first paint is right too: data_saver_toggle_light(mode,// request.headers["save-data"] == "on") says whether to send the light version.// changes: what the setting changes, each { label, off, on }; the default lists pictures and// video. on_change(active, mode): inside one of your components, when either changes. legend:// the heading. id: names its radio buttons, so give each one its own. class.//// While on: an <img data-lite="small.jpg"> shows that picture and gives up its srcset, each// <video autoplay> is paused and loses autoplay, with preload="none", and `data-data-saver="on"`// is on <html>, for rules such as `[data-data-saver="on"] .hero { background-image: none }`.// What is added to the page later is treated too. Off puts it all back.//// What a phone tells: Chrome on Android gives navigator.connection.saveData, and the// Save-Data header. iOS tells nothing of Low Data Mode, to pages or to web views, so there it// says so, and the choice here is what counts.//// data_saver_toggle_active(mode, phone) and data_saver_toggle_status(mode, phone) say whether// it is on, and why. phone is true, false or null, for not known.import ui/theme.qwe use ui_clslet data_saver_toggle_css = css`.ui-dsave { container-type: inline-size; min-width: 0; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-dsave-in { display: grid; gap: 12px; }.ui-dsave-set { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }.ui-dsave-set legend { padding: 0 0 8px; font-size: 16px; font-weight: 600; }.ui-dsave-opts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-dsave-opt { position: relative; display: grid; place-items: center; min-height: 48px; padding: 4px 6px; border-left: 1px solid var(--ui-border); background: var(--ui-surface); text-align: center; font-size: 14px; font-weight: 500; line-height: 1.2; cursor: pointer; touch-action: manipulation; }.ui-dsave-opt:first-child { border-left: 0; }.ui-dsave-opt input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }.ui-dsave-opt:has(input:checked) { background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-dsave-opt:has(input:focus-visible) { box-shadow: inset var(--ui-focus); }.ui-dsave-status { margin: 0; }.ui-dsave-lasts { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-dsave-what { display: grid; gap: 6px; min-width: 0; align-content: start; }.ui-dsave-what h4 { margin: 0; font-size: 14px; color: var(--ui-muted); font-weight: 500; }.ui-dsave-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-dsave-item { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 12px; padding: 8px 10px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-dsave-item b { font-weight: 500; }.ui-dsave-value { color: var(--ui-muted); }.ui-dsave-in[data-active] .ui-dsave-value { color: var(--ui-success); }.ui-dsave-count { margin: 0; color: var(--ui-muted); font-size: 13px; }@container (min-width: 600px) {  .ui-dsave-in { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: start; }}`let data_saver_toggle_changes = [    { label: "Pictures", off: "Full size", on: "Lighter versions" },    { label: "Video", off: "Plays by itself", on: "Waits for a press" },]// A choice as it should be.fn data_saver_toggle_mode(saved) -> str = saved == "on" || saved == "off" ? saved : "follow"// The choice from a Cookie header's text.fn data_saver_toggle_read(cookies) {    if type_of(cookies) != "str" { return null }    let found = split(cookies, ";") | map((c) => trim(c)) | find((c) => starts_with(c, "ui-data-saver="))    found == null ? null : data_saver_toggle_mode(slice(found, len("ui-data-saver="), len(found)))}fn data_saver_toggle_active(mode: str, phone) -> bool = mode == "on" || (mode == "follow" && phone == true)// For a route, before any script: whether to send the light version.fn data_saver_toggle_light(mode, save_data: bool) -> bool = data_saver_toggle_active(data_saver_toggle_mode(mode), save_data)// Whether it is on, and why, in a sentence.fn data_saver_toggle_status(mode: str, phone) -> str {    if mode == "on" { return "Data saver is on." }    if mode == "off" { return "Data saver is off." }    if phone == true { return "Data saver is on, because this device's is." }    if phone == false { return "Data saver is off, because this device's is." }    "Data saver is off. This device does not tell apps about its own, so choose here."}fn data_saver_toggle_count(pictures: int, videos: int) -> str {    let a = pictures == 1 ? "1 picture lighter" : `${pictures} pictures lighter`    let b = videos == 1 ? "1 video stopped" : `${videos} videos stopped`    pictures + videos == 0 ? "Nothing on this page has changed." : `On this page: ${join([pictures > 0 ? a : null, videos > 0 ? b : null] | filter((x) => x != null), ", ")}.`}fn data_saver_toggle_lasts(kept) -> str = match kept {    null => ""    false => "Cookies are blocked here, so this lasts until the page closes."    _ => "This choice stays on this device."}component DataSaverToggle(saved = null, changes: list = data_saver_toggle_changes, on_change = null, legend: str = "Data saver", id: str = "data-saver", class: str? = null) {    style global data_saver_toggle_css    state mode = data_saver_toggle_mode(saved)    state phone = null    state kept = null    state pictures = 0    state videos = 0    state ready = false    state seen = false      // something was added to the page, to look at in a moment    state watcher = null    let active = data_saver_toggle_active(mode, phone)    // Goes over the page: what should be light is, and what should not be is put back.    let sweep = (on: bool) => {        let d = js("document")        let imgs = js("Array.from", d.querySelectorAll("img[data-lite]"))        let moved = 0        for img in imgs {            if on {                if !img.hasAttribute("data-full") {                    img.setAttribute("data-full", img.getAttribute("src") ?? "")                    if img.hasAttribute("srcset") {                        img.setAttribute("data-full-srcset", img.getAttribute("srcset"))                        img.removeAttribute("srcset")                    }                    img.setAttribute("src", img.getAttribute("data-lite"))                }                moved += 1            } else if img.hasAttribute("data-full") {                if img.hasAttribute("data-full-srcset") {                    img.setAttribute("srcset", img.getAttribute("data-full-srcset"))                    img.removeAttribute("data-full-srcset")                }                img.setAttribute("src", img.getAttribute("data-full"))                img.removeAttribute("data-full")            }        }        let clips = js("Array.from", d.querySelectorAll("video[autoplay], video[data-autoplay]"))        let held = 0        for v in clips {            if on {                if v.hasAttribute("autoplay") {                    v.setAttribute("data-autoplay", "")                    v.setAttribute("data-preload", v.getAttribute("preload") ?? "")                    v.removeAttribute("autoplay")                    v.setAttribute("preload", "none")                    v.pause()                }                held += 1            } else if v.hasAttribute("data-autoplay") {                v.removeAttribute("data-autoplay")                let was = v.getAttribute("data-preload")                v.removeAttribute("data-preload")                if was == null || was == "" { v.removeAttribute("preload") } else { v.setAttribute("preload", was) }                v.setAttribute("autoplay", "")                // A browser may refuse, with no press to go on; the video then waits for one.                let p = v.play()                if p != null { p.then((_r = null) => { null }, (_e = null) => { null }) }            }        }        pictures = moved        videos = held    }    let settle = () => {        let on = data_saver_toggle_active(mode, phone)        js("document").documentElement.setAttribute("data-data-saver", on ? "on" : "off")        sweep(on)        // Feeds add pictures as they go, so while on, the page is watched.        if on && watcher == null {            let w = js_new("MutationObserver", (_list) => { seen = true })            w.observe(js("document").body, { childList: true, subtree: true })            watcher = w        } else if !on && watcher != null {            watcher.disconnect()            watcher = null        }    }    if seen {        every(120, () => {            seen = false            if data_saver_toggle_active(mode, phone) { sweep(true) }        })    }    let choose = (next: str) => {        mode = next        let d = js("document")        d.cookie = `ui-data-saver=${next}; path=/; max-age=31536000; samesite=lax`        kept = data_saver_toggle_read(d.cookie) == next        settle()        on_change && on_change(data_saver_toggle_active(next, phone), next)    }    <div class={ui_cls(["ui-dsave", class])} id={id}         @mount={(_el) => {             let d = js("document")             let conn = js("navigator").connection             let told = d.cookie             let before = data_saver_toggle_read(told)             if before != null { mode = before }             phone = conn != null && type_of(conn.saveData) == "bool" ? conn.saveData : null             kept = true             settle()             ready = true             let turned = (_e = null) => {                 let was = data_saver_toggle_active(mode, phone)                 phone = type_of(conn.saveData) == "bool" ? conn.saveData : null                 settle()                 if was != data_saver_toggle_active(mode, phone) { on_change && on_change(data_saver_toggle_active(mode, phone), mode) }             }             if conn != null && conn.addEventListener != null { conn.addEventListener("change", turned) }             () => {                 if conn != null && conn.removeEventListener != null { conn.removeEventListener("change", turned) }                 if watcher != null { watcher.disconnect() }                 d.documentElement.removeAttribute("data-data-saver")             }         }}>        <div.ui-dsave-in data-active={active ? "" : null}>            <fieldset.ui-dsave-set>                <legend>{legend}</legend>                <div.ui-dsave-opts>                    <label.ui-dsave-opt><input type="radio" name={`${id}-mode`} value="follow" checked={mode == "follow"} @change={() => { choose("follow") }}/>Follow device</label>                    <label.ui-dsave-opt><input type="radio" name={`${id}-mode`} value="on" checked={mode == "on"} @change={() => { choose("on") }}/>On</label>                    <label.ui-dsave-opt><input type="radio" name={`${id}-mode`} value="off" checked={mode == "off"} @change={() => { choose("off") }}/>Off</label>                </div>                <p.ui-dsave-status role="status">{ready ? data_saver_toggle_status(mode, phone) : ""}</p>                <p.ui-dsave-lasts>{data_saver_toggle_lasts(kept)}</p>            </fieldset>            <div.ui-dsave-what>                <h4>What it changes</h4>                <ul.ui-dsave-list>                    {for c in changes => <li.ui-dsave-item key={c.label}>                        <b>{c.label}</b>                        <span.ui-dsave-value>{active ? c.on : c.off}</span>                    </li>}                </ul>                {ready && <p.ui-dsave-count>{data_saver_toggle_count(pictures, videos)}</p>}            </div>        </div>    </div>}
Thread
45Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.