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.
Demo
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.qwemain.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>}Related
PhotoCaptureTakes a photo through the phone's camera, with preview and retake, made smaller on the phone and upright by its EXIF, as a real file in a form.3 views · 5 downloadsVideoCaptureRecords a short clip through the camera with a limit and a timer ring, shows it before it is accepted, and falls back to the system's camera where recording is refused.2 views · 5 downloadsDocumentCapturePhotographs pages one by one, with reorder, drop, and a crop by four corners by finger that flattens the page, kept as a list of images.4 views · 6 downloadsGalleryPickerPicks several photos through the system's picker, with thumbnails, an order, caps by count and size, HEIC refused with a reason, and previews freed after.2 views · 5 downloadsPhotoEditorRotates, crops with handles, changes the light and straightens by finger in a canvas, with undo, and gives back the image as a file.2 views · 7 downloadsPhotoEvidencePhotos stamped with the time and place into the picture, with counts of what is still needed, tags, and a queue for when there is no connection.2 views · 5 downloads
Thread
Thread 0 posts
On StoryCapture: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.