Mobile

StoryCapture

A stories camera: hold to record, tap for a photo, with a progress ring, a switch of camera, and a screen for a refused permission.

#mobile#interactive#camera#video#photo#stories

Demo

Preview

Nothing used yet.

Two seconds in this example. On a wide screen the shutter moves to the side.

Get it

It needs theme.qwe, as every component does, and button.qwe; 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/button.qwe$ curl -O https://quillolang.org/components/ui/story_capture.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/story_capture.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/story_capture.qwe
component StoryCaptureDemoLive() {    state got = null    state shown = true    state at_once = false    <div.demo-stack>        <div.demo-tablet>            {shown && <form style="height: 100%" method="post" action="/" enctype="multipart/form-data">                <StoryCapture name="story" limit={2} auto={at_once} on_capture={(s) => { got = s }}/>            </form>}        </div>        <p.demo-note id="story-capture-demo-note">{got == null ? "Nothing used yet." : `Used: ${got.kind}, ${got.name}, ${got.type}${got.width != null ? `, ${got.width} by ${got.height}` : ""}.`}</p>        <Button size="lg" variant="ghost" tone="neutral" on_click={() => { at_once = false; shown = !shown }}>{shown ? "Leave the page" : "Come back"}</Button>        {!shown && <Button size="lg" variant="ghost" tone="neutral" on_click={() => { at_once = true; shown = true }}>Come back with the camera open</Button>}    </div>}view StoryCaptureDemo() =    <div.demo-stack>        <StoryCaptureDemoLive/>        <p.demo-note>Two seconds in this example. On a wide screen the shutter moves to the side.</p>    </div>

Source

ui/story_capture.qwe
// StoryCapture: the camera screen of a stories app. A tap on the shutter takes a photo, and// holding it records, with a ring round it that fills to the limit. There is a button to// switch cameras where the phone has two, a look at what was taken before it is used, and// a screen of its own for a camera that was refused, with the way to Settings in the app.// What is used goes into a real file field, so a plain multipart <form> sends it.////   <div style="height: 100dvh">//       <StoryCapture limit={15} on_capture={(s) => { story = s }}/>//   </div>//// It fills the box it is put in, and stays clear of the notch and the home bar.// name: the file field's name ("story"). limit: the most seconds a recording may run (15; 1// to 60). facing: the camera it starts on, "user" (the front, default) or "environment".// sound: records the microphone too (true; refused, it records without). max_side: the// longest side of a photo in pixels (1600). quality: a photo's JPEG quality, 0 to 1 (0.9).// auto: opens the camera as soon as it is on the page (false: a person presses a button,// which browsers prefer). on_capture(story): inside one of your components, given { kind,// name, size, type, width, height, seconds } when something is used ("photo" or "video";// seconds null for a photo, width and height null for a video), or null when it is taken back.//// The hold is a gesture, so the Record button does the same without one, and Enter or Space// on the shutter takes a photo. A hold the system takes back throws the recording away; an// app that leaves the screen ends it and keeps what it has. The front camera shows mirrored,// as people expect, and the photo is not.//// Needs Button: `import ui/button.qwe use Button`.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet story_capture_css = css`.ui-story-capture {  container-type: inline-size; position: relative; display: grid; width: 100%; height: 100%; min-height: min(100dvh, 520px); overflow: hidden;  background: #000; color: #fff; font: 14px/1.4 var(--ui-font); overscroll-behavior: none;}.ui-story-capture-view { position: absolute; inset: 0; overflow: hidden; }.ui-story-capture-view video, .ui-story-capture-view img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-story-capture-view video[hidden] { display: none; }.ui-story-capture-mirror video { transform: scaleX(-1); }.ui-story-capture-shot { object-fit: contain !important; }.ui-story-capture-top {  position: absolute; z-index: 3; left: 0; right: 0; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px;  padding: calc(env(safe-area-inset-top, 0px) + 8px) calc(env(safe-area-inset-right, 0px) + 8px) 8px calc(env(safe-area-inset-left, 0px) + 8px);}.ui-story-capture-clock { padding: 3px 10px; border-radius: 999px; background: var(--ui-danger); color: #fff; font: 600 14px/1.4 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-story-capture-round {  display: grid; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .5); color: #fff;  font: 500 12px/1 var(--ui-font); cursor: pointer; -webkit-tap-highlight-color: transparent;}.ui-story-capture-round svg { width: 24px; height: 24px; }.ui-story-capture-round:focus-visible, .ui-story-capture-shutter:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }.ui-story-capture-round:disabled { opacity: .4; cursor: default; }.ui-story-capture-bottom {  position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; display: grid; justify-items: center; gap: 8px;  padding: 8px calc(env(safe-area-inset-right, 0px) + 12px) calc(env(safe-area-inset-bottom, 0px) + 14px) calc(env(safe-area-inset-left, 0px) + 12px);}.ui-story-capture-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; justify-items: center; width: 100%; }.ui-story-capture-go { position: relative; width: 88px; height: 88px; }.ui-story-capture-ring { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); pointer-events: none; }.ui-story-capture-ring circle { fill: none; stroke-width: 6; }.ui-story-capture-ring-back { stroke: rgba(255, 255, 255, .55); }.ui-story-capture-ring-on { stroke: var(--ui-danger); }.ui-story-capture-shutter {  position: absolute; inset: 10px; padding: 0; border: 0; border-radius: 50%; background: #fff; cursor: pointer; touch-action: none; transition: transform .12s;  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;}.ui-story-capture-holding .ui-story-capture-shutter { background: var(--ui-danger); transform: scale(.72); }.ui-story-capture-hint { margin: 0; color: #fff; font-size: 13px; text-shadow: 0 1px 2px rgba(0, 0, 0, .7); }.ui-story-capture-review { display: flex; flex-wrap: wrap; gap: 8px; width: 100%; max-width: 420px; }.ui-story-capture-review > * { flex: 1 1 auto; }.ui-story-capture-wait { pointer-events: none; }.ui-story-capture-screen {  position: relative; z-index: 1; display: grid; align-content: center; justify-items: center; gap: 12px; padding: 24px; text-align: center;  padding-top: calc(env(safe-area-inset-top, 0px) + 24px); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 24px);}.ui-story-capture-screen svg { width: 44px; height: 44px; }.ui-story-capture-screen h3 { margin: 0; font: 600 18px/1.3 var(--ui-font); color: #fff; }.ui-story-capture-screen p { margin: 0; max-width: 300px; color: #ddd; }.ui-story-capture-why { margin: 0; color: #ffb4b4; font-size: 13px; text-shadow: 0 1px 2px rgba(0, 0, 0, .7); }.ui-story-capture input[type=file] { display: none; }/* A tablet held either way: the shutter at the side, under the thumb, as in the system's camera. */@container (min-width: 440px) {  .ui-story-capture-bottom { left: auto; top: 0; width: 132px; align-content: center; padding-top: calc(env(safe-area-inset-top, 0px) + 60px); }  .ui-story-capture-row { grid-template-columns: 1fr; grid-template-rows: auto auto; gap: 14px; }  .ui-story-capture-hint { text-align: center; }  .ui-story-capture-review { flex-direction: column; }  .ui-story-capture-review > * { flex: none; }}@media (prefers-reduced-motion: reduce) { .ui-story-capture-shutter { transition: none; } }`// What to record in, best first. avc3 leads because turning the phone changes the picture's// size in mid-recording: avc1, which plain "video/mp4" gives on Chrome, cannot carry that, and// avc3 can. Plain mp4 comes next for Safari, which names no codec, then WebM.let story_capture_kinds = ["video/mp4;codecs=avc3.42E01E,mp4a.40.2", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm"]let story_capture_ring_len = 251.33       // 2 pi r, for the ring's r of 40let story_capture_hold_ms = 280           // down longer than this is a hold, not a tapfn story_capture_limit(s: num) -> num = s <= 0 ? 15 : s < 1 ? 1 : s > 60 ? 60 : s// How much of the ring's stroke is hidden: all at the start, none at the limit.fn story_capture_ring(t: num, limit: num) -> num {    let part = limit <= 0 ? 1 : t / limit    round(story_capture_ring_len * (1 - (part < 0 ? 0 : part > 1 ? 1 : part)), 2)}// Seconds as m:ss, rounded down.fn story_capture_clock(s: num) -> str {    let t = s < 0 ? 0 : floor(s)    let sec = t % 60    `${floor(t / 60)}:${sec < 10 ? "0" : ""}${sec}`}// A picture of w by h with its longer side at most limit.fn story_capture_fit(w: num, h: num, limit: num) -> map {    if w <= 0 || h <= 0 || limit <= 0 { return { w: 0, h: 0 } }    let longest = w > h ? w : h    let k = longest > limit ? limit / longest : 1    { w: round(w * k), h: round(h * k) }}fn story_capture_ext(mime: str) -> str = split(mime, ";")[0] == "video/mp4" ? "mp4" : "webm"fn story_capture_why(name) -> str = name == "NotAllowedError" || name == "SecurityError" ? "refused" : "none"component StoryCapture(name: str = "story", limit: num = 15, facing: str = "user", sound: bool = true, max_side: int = 1600, quality: num = 0.9,                       auto: bool = false, on_capture = null, class: str? = null) {    style global story_capture_css    state status = "idle"       // idle, starting, live, recording, finishing, review, used, refused, none    state side = facing    state stream = null    state recorder = null    state parts = []    state began = 0    state spent = 0    state tossed = false        // the recording under way is to be thrown away    state shot = null           // { kind, url, blob, name, type, width, height, seconds }    state press = false         // a finger is on the shutter, not yet a hold    state held = false          // the recording was started by that finger    state finger = false        // that finger is still down, or has only just left    state lifted = false    state touched = 0           // when a finger last left the shutter    state screen = null    state input = null    state said = ""    state settings = false    state cameras = 1    state silent = false    state can_record = true    let most = story_capture_limit(limit)    let live = status == "live" || status == "recording"    let release = () => {        if stream != null {            for t in js("Array.from", stream.getTracks()) { t.stop() }            stream = null        }        if screen != null { screen.srcObject = null }    }    let free = () => {        if shot != null {            js("URL.revokeObjectURL", shot.url)            shot = null        }    }    let cannot = (kind: str, words: str) => {        release()        settings = kind == "refused" && js("window").quilloNative != null        said = words        status = kind    }    let ask = (wanted) => js("Promise.resolve", js_new("Promise", (done) => {        done(js("navigator.mediaDevices.getUserMedia", wanted).then((s) => { stream: s, why: null }, (e) => { stream: null, why: e.name }))    }))    let stop_recording = (keep: bool) => {        if recorder != null && recorder.state != "inactive" {            tossed = !keep            status = "finishing"            recorder.stop()        }    }    if status == "recording" {        every(100, () => {            spent = (now() - began) / 1000            if spent >= most { stop_recording(true) }        })    }    let attach = (s) => {        stream = s        let track = js("Array.from", s.getVideoTracks())[0]        track.addEventListener("ended", (_e = null) => {            // By id: two streams compare equal with ==, as all page objects do.            if stream != null && stream.id == s.id {                if status == "recording" {                    said = "The camera stopped."                    stop_recording(true)                } else {                    if status == "live" {                        release()                        said = "The camera stopped. Open it again to go on."                        status = "idle"                    }                }            }        })        screen.srcObject = s        screen.play().catch((_ = null) => { said = "" })        status = "live"    }    let wanted_video = (which: str) => { facingMode: { ideal: which }, width: { ideal: 1280 }, height: { ideal: 720 } }    let open = () => {        if status == "starting" { return null }        said = ""        silent = false        if js("navigator").mediaDevices == null {            cannot("none", "This page cannot reach a camera.")            return null        }        can_record = js("window").MediaRecorder != null        status = "starting"        let got = ask({ video: wanted_video(side), audio: sound && can_record })        // Refused with sound: the camera alone may still be allowed.        if got.stream == null && sound && can_record {            got = ask({ video: wanted_video(side), audio: false })            silent = got.stream != null        }        if got.stream == null {            let kind = story_capture_why(got.why)            cannot(kind, kind == "refused" ? "Allow the camera in Settings to take photos and clips here." : "No camera could be opened on this device.")            return null        }        attach(got.stream)        let all = js("navigator.mediaDevices.enumerateDevices")        cameras = is_err(all) ? 1 : len(js("Array.from", all) | filter((d) => d.kind == "videoinput"))    }    // The old camera is let go first: many phones will not run two at once.    let flip = () => {        if status != "live" { return null }        let was = side        let want = side == "user" ? "environment" : "user"        release()        status = "starting"        let got = ask({ video: wanted_video(want), audio: sound && !silent })        if got.stream == null {            said = "The other camera would not open."            got = ask({ video: wanted_video(was), audio: sound && !silent })            if got.stream == null {                cannot("none", "No camera could be opened on this device.")                return null            }        } else {            said = ""            side = want        }        attach(got.stream)    }    let photo = () => {        if screen == null || screen.videoWidth == 0 || status != "live" { return null }        let box = story_capture_fit(screen.videoWidth, screen.videoHeight, max_side)        let canvas = js("document.createElement", "canvas")        canvas.width = box.w        canvas.height = box.h        canvas.getContext("2d").drawImage(screen, 0, 0, box.w, box.h)        let blob = js("Promise.resolve", js_new("Promise", (done) => { canvas.toBlob((b) => { done(b) }, "image/jpeg", quality) }))        if blob == null || is_err(blob) {            said = "The photo could not be made. Try again."            return null        }        release()        shot = { kind: "photo", url: js("URL.createObjectURL", blob), blob, name: "story.jpg", type: "image/jpeg", width: box.w, height: box.h, seconds: null }        status = "review"        if js("window").quilloNative != null { js("quilloNative.haptic", "light") }    }    let finished = (mime: str) => {        let kept = parts        recorder = null        parts = []        held = false        if tossed {            tossed = false            spent = 0            status = stream != null ? "live" : "idle"            return null        }        let seconds = spent > most ? most : spent        release()        let blob = js_new("Blob", js("Array.from", kept), { type: mime })        if blob.size == 0 {            said = "Nothing was recorded. Try again."            status = "idle"            return null        }        shot = { kind: "video", url: js("URL.createObjectURL", blob), blob, name: `story.${story_capture_ext(mime)}`, type: split(mime, ";")[0],                 width: null, height: null, seconds }        status = "review"    }    let record = () => {        if stream == null || status != "live" || !can_record { return null }        let kinds = story_capture_kinds | filter((t) => js("MediaRecorder.isTypeSupported", t))        let mime = len(kinds) > 0 ? kinds[0] : ""        let refused = () => {            held = false            can_record = false            said = "This browser cannot record here. A tap still takes a photo."        }        // In a Promise, here and at the start: both throw, which would stop the handler, not give an error value.        let r = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("MediaRecorder", stream, mime != "" ? { mimeType: mime } : {})) }))        if is_err(r) {            refused()            return null        }        parts = []        tossed = false        r.addEventListener("dataavailable", (e) => { if e.data.size > 0 { parts = parts | push(e.data) } })        r.addEventListener("stop", (_e = null) => { finished(r.mimeType != "" ? r.mimeType : mime != "" ? mime : "video/webm") })        let going = js("Promise.resolve", js_new("Promise", (done) => {            r.start(500)            done(true)        }))        if is_err(going) {            refused()            return null        }        recorder = r        began = now()        spent = 0        status = "recording"        if js("window").quilloNative != null { js("quilloNative.haptic", "medium") }    }    // Down long enough, the press becomes a hold and recording starts. `every` is the only    // timer a component has; press going false stops this one.    if press {        every(story_capture_hold_ms, () => {            press = false            if can_record {                held = true                record()            } else {                photo()            }        })    }    // The review's buttons appear where the shutter was, under a finger still held at the    // limit or coming back for a second tap: that would press one unseen. They wait until it has gone.    if lifted {        every(300, () => {            lifted = false            finger = false        })    }    let again = () => {        let had = status == "used"        free()        input.value = ""        if had { on_capture && on_capture(null) }        status = "idle"        open()    }    let use_it = () => {        let dt = js_new("DataTransfer")        dt.items.add(js_new("File", [shot.blob], shot.name, { type: shot.type }))        input.files = dt.files        status = "used"        on_capture && on_capture({ kind: shot.kind, name: shot.name, size: shot.blob.size, type: shot.type, width: shot.width, height: shot.height, seconds: shot.seconds })    }    <div class={ui_cls(["ui-story-capture", status == "recording" && "ui-story-capture-holding", class])} data-state={status} data-side={side}         @mount={(_el) => {             let d = js("document")             let seen = (_e = null) => {                 if d.hidden {                     if status == "recording" {                         stop_recording(true)                     } else {                         if status == "live" {                             release()                             status = "idle"                             said = "The camera was closed while the app was away."                         }                     }                 }             }             let up = (_e = null) => { if finger { lifted = true } }             d.addEventListener("visibilitychange", seen)             d.addEventListener("pointerup", up)             d.addEventListener("pointercancel", up)             if auto { open() }             () => {                 d.removeEventListener("visibilitychange", seen)                 d.removeEventListener("pointerup", up)                 d.removeEventListener("pointercancel", up)                 if recorder != null && recorder.state != "inactive" {                     tossed = true                     recorder.stop()                 }                 release()                 free()             }         }}>        <div class={ui_cls(["ui-story-capture-view", side == "user" && "ui-story-capture-mirror"])}>            <video muted={true} playsinline={true} autoplay={true} hidden={!live} aria-label="The camera's picture" @mount={(el) => { screen = el }}></video>            {(status == "review" || status == "used") && shot != null && shot.kind == "photo" && <img.ui-story-capture-shot src={shot.url} alt="The photo taken"/>}            {(status == "review" || status == "used") && shot != null && shot.kind == "video" && <video.ui-story-capture-shot src={shot.url} controls={true} playsinline={true} loop={true} aria-label="The clip recorded"></video>}        </div>        {live && <div.ui-story-capture-top>            <span>{status == "recording" && <span.ui-story-capture-clock role="timer" aria-label="Time recorded">{story_capture_clock(spent)} / {story_capture_clock(most)}</span>}</span>            {cameras > 1 && <button.ui-story-capture-round type="button" aria-label="Switch camera" disabled={status != "live"} @click={flip}>                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9a8 8 0 0 1 14-3l2 2M20 15a8 8 0 0 1-14 3l-2-2M20 4v4h-4M4 20v-4h4"/></svg>            </button>}        </div>}        {live && <div.ui-story-capture-bottom>            <div.ui-story-capture-row>                <span></span>                <div.ui-story-capture-go>                    <svg.ui-story-capture-ring viewBox="0 0 88 88" aria-hidden="true">                        <circle.ui-story-capture-ring-back cx="44" cy="44" r="40"/>                        <circle.ui-story-capture-ring-on cx="44" cy="44" r="40" stroke-dasharray={str(story_capture_ring_len)} stroke-dashoffset={str(story_capture_ring(status == "recording" ? spent : 0, most))}/>                    </svg>                    <button.ui-story-capture-shutter type="button" aria-label={status == "recording" ? "Stop recording" : "Take a photo"}                             @contextmenu={(e) => { e.preventDefault() }}                             @pointerdown={(e) => {                                 if e.button == 0 {                                     e.target.setPointerCapture(e.pointerId)                                     finger = true                                     lifted = false                                     if status == "live" { press = true }                                 }                             }}                             @pointerup={() => {                                 touched = now()                                 if press {                                     press = false                                     photo()                                 } else {                                     if status == "recording" { stop_recording(true) }                                 }                             }}                             @pointercancel={() => {                                 touched = now()                                 press = false                                 if status == "recording" && held { stop_recording(false) }                             }}                             @click={() => {                                 // A click no finger has just made: the keyboard's, or a screen reader's.                                 if now() - touched > 700 {                                     if status == "recording" { stop_recording(true) } else { photo() }                                 }                             }}></button>                </div>                {status == "live" && can_record && <button.ui-story-capture-round type="button" aria-label="Record" @click={record}>Rec</button>}                {status == "recording" && <button.ui-story-capture-round type="button" aria-label="Stop" @click={() => { stop_recording(true) }}>Stop</button>}            </div>            {said != "" ? <p.ui-story-capture-why role="alert">{said}</p>                : status == "live" && <p.ui-story-capture-hint>{can_record ? "Tap for a photo, hold to record" : "Tap for a photo"}{silent ? ". No sound." : ""}</p>}        </div>}        {(status == "review" || status == "used") && <div.ui-story-capture-bottom>            <div class={ui_cls(["ui-story-capture-review", finger && "ui-story-capture-wait"])}>                {status == "review" && <Button size="lg" on_click={use_it}>Use this</Button>}                <Button size="lg" variant="soft" tone="neutral" on_click={again}>Retake</Button>            </div>            {status == "used" && <p.ui-story-capture-hint role="status">Ready to send.</p>}        </div>}        {(status == "idle" || status == "starting") && <div.ui-story-capture-screen>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2v11ZM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"/></svg>            <Button size="lg" disabled={status == "starting"} on_click={open}>{status == "starting" ? "Asking for the camera…" : "Open the camera"}</Button>            {said != "" && <p.ui-story-capture-why role="alert">{said}</p>}        </div>}        {status == "finishing" && <div.ui-story-capture-screen><p role="status">Finishing the clip…</p></div>}        {(status == "refused" || status == "none") && <div.ui-story-capture-screen>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 1l22 22M9 3h6l2 3h4a2 2 0 0 1 2 2v9.3M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h1"/></svg>            <h3>{status == "refused" ? "The camera is not allowed" : "No camera"}</h3>            <p role="alert">{said}</p>            {status == "refused" && settings && <Button size="lg" on_click={() => { js("quilloNative.openSettings") }}>Open Settings</Button>}            <Button size="lg" variant="soft" tone="neutral" on_click={open}>Try again</Button>        </div>}        <input type="file" name={name} tabindex="-1" aria-hidden="true" @mount={(el) => { input = el }}/>    </div>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.