openvibers/openvibe.cam Commits

The camera: a big layout on tablets and desktops, and a swipe changes mode (#98)

OP OpenVibers committed 6 days ago · b3986aa1e9 · 4 files, +61 −4

On a screen 900 px or wider the camera is as tall as the screen under the bar: the picture takes the width and a rail
on its right holds flip at the top, the modes over the shutter in the middle and your photos at the foot, as a camera
app does on a tablet. The caption and status sit under the picture. Before, the 16:9 picture pushed the shutter to
the fold and the caption below it.

A sideways swipe on the picture changes mode (left for the next, right for the one before), and the mode's name
shows on the picture for a moment; the filter strip and the top controls keep their own gestures.

Changes

ddiff --git a/public/css/cam.css b/public/css/cam.css
iindex 317dc10..4c7f5c5 100644
---- a/public/css/cam.css
++++ b/public/css/cam.css
@@@ -207,7 +207,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
countdown, the filter strip's snap. */
.cm-cam-app { display: grid; gap: 22px; }
.cm-camera { position: relative; display: grid; gap: 12px; margin: 0; }
--.cm-stage { position: relative; overflow: hidden; border-radius: 26px; background: #04060b; aspect-ratio: 4 / 3; max-height: 72vh; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06), 0 30px 70px -40px rgba(0,0,0,.9); isolation: isolate; }
++.cm-stage { position: relative; overflow: hidden; touch-action: pan-y; border-radius: 26px; background: #04060b; aspect-ratio: 4 / 3; max-height: 72vh; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06), 0 30px 70px -40px rgba(0,0,0,.9); isolation: isolate; }
.cm-stage video, .cm-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: filter .25s ease; }
.cm-camera[data-facing="user"] .cm-stage video { transform: scaleX(-1); }
.cm-grid-overlay { position: absolute; inset: 0; pointer-events: none; z-index: 1;
@@@ -220,6 +220,12 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@keyframes cm-flash { 0% { opacity: .85; } 100% { opacity: 0; } }
.cm-count { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; font-size: clamp(72px, 22vw, 160px); font-weight: 900; color: #fff; text-shadow: 0 6px 30px rgba(0,0,0,.5); pointer-events: none; }
.cm-count:empty { display: none; }
++/* A swipe's new mode, shown on the picture for a moment. */
++.cm-mode-say { position: absolute; z-index: 3; top: 18px; left: 50%; padding: 6px 14px; border-radius: 999px; background: rgba(4,6,11,.55); color: #fff;
++ -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase;
++ opacity: 0; transform: translate(-50%, -6px); pointer-events: none; transition: opacity .18s ease, transform .18s ease; }
++.cm-mode-say.is-on { opacity: 1; transform: translate(-50%, 0); }
++.cm-mode-say:empty { display: none; }
.cm-count.is-tick { animation: cm-tick .9s cubic-bezier(.2,1.3,.4,1); }
@keyframes cm-tick { 0% { transform: scale(1.6); opacity: 0; } 30% { transform: scale(1); opacity: 1; } 100% { opacity: .2; } }
/* Off: a calm card on the dark stage, one big button. */
@@@ -259,7 +265,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.cm-flip { justify-self: end; color: var(--text-primary, #e6edf7); background: var(--bg-card, #0f1729); box-shadow: inset 0 0 0 1px var(--border, #1f2d47); -webkit-backdrop-filter: none; backdrop-filter: none; }
.cm-shoot-wrap { display: grid; justify-items: center; gap: 8px; }
.cm-modes { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
--.cm-mode { height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; color: var(--text-secondary, #96a7c2); font: inherit; font-size: 12.5px; font-weight: 750; letter-spacing: .02em; text-transform: uppercase; cursor: pointer; }
++.cm-mode { height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; transition: background-color .2s ease, color .2s ease; color: var(--text-secondary, #96a7c2); font: inherit; font-size: 12.5px; font-weight: 750; letter-spacing: .02em; text-transform: uppercase; cursor: pointer; }
.cm-mode[aria-checked="true"] { color: var(--room-strong, var(--room-accent)); background: color-mix(in srgb, var(--room-accent) 16%, transparent); }
.cm-shutter { position: relative; display: grid; place-items: center; width: 76px; height: 76px; padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; }
.cm-shutter::before { content: ""; position: absolute; inset: 0; border-radius: 50%; box-shadow: inset 0 0 0 4px var(--text-primary, #e6edf7); }
@@@ -284,7 +290,25 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.cm-under { padding: 0 16px; }
}
@media (min-width: 701px) { .cm-cam-app { padding-top: 18px; } .cm-stage { width: 100%; } .cm-bar { width: min(100%, 560px); margin: 0 auto; } .cm-filters { justify-content: safe center; } }
--@media (prefers-reduced-motion: reduce) { .cm-flash.is-on, .cm-count.is-tick, .cm-thumb.is-new { animation: none; } .cm-stage video { transition: none; } }
++/* A tablet or a desktop (11-07, "a big layout"): the camera is as tall as the screen under the bar. The picture takes the
++ width; a rail on its right holds flip at the top, the modes over the shutter in the middle and your photos at the
++ foot, as a camera app does on a tablet. The caption and the status sit under the picture. */
++@media (min-width: 900px) {
++ .cm-camera { grid-template-columns: minmax(0, 1fr) 128px; grid-template-rows: minmax(0, 1fr) auto; column-gap: 16px; row-gap: 12px;
++ height: calc(100dvh - var(--ovnav-h, 60px) - 40px); min-height: 460px; }
++ .cm-stage { grid-column: 1; grid-row: 1; aspect-ratio: auto; max-height: none; height: 100%; }
++ .cm-bar { grid-column: 2; grid-row: 1 / span 2; width: auto; margin: 0; padding: 18px 0; grid-template-columns: none; grid-template-rows: 1fr auto 1fr;
++ justify-items: center; border-radius: 26px; background: var(--bg-card, #0f1729); box-shadow: inset 0 0 0 1px var(--border, #1f2d47); }
++ .cm-flip { grid-row: 1; align-self: start; justify-self: center; }
++ .cm-shoot-wrap { grid-row: 2; gap: 14px; }
++ .cm-thumb { grid-row: 3; align-self: end; justify-self: center; width: 60px; height: 60px; }
++ .cm-modes { flex-direction: column; border-radius: 18px; }
++ .cm-mode { height: 34px; }
++ .cm-shutter { width: 84px; height: 84px; }
++ .cm-shutter span { width: 66px; height: 66px; }
++ .cm-under { grid-column: 1; grid-row: 2; grid-template-columns: minmax(0, 520px) auto; justify-content: center; align-items: center; gap: 16px; }
++}
++@media (prefers-reduced-motion: reduce) { .cm-flash.is-on, .cm-count.is-tick, .cm-thumb.is-new { animation: none; } .cm-stage video, .cm-mode-say, .cm-mode { transition: none; } }
/* The /developers embed: the attributes, events and methods tables read as text, not as nowrap cells, and the live
ddiff --git a/public/js/cam.js b/public/js/cam.js
iindex b87a195..9677225 100644
---- a/public/js/cam.js
++++ b/public/js/cam.js
@@@ -204,6 +204,38 @@
modeBtns.forEach(function (x) { x.setAttribute('aria-checked', x === b ? 'true' : 'false'); });
shootBtn.setAttribute('aria-label', mode === 'burst' ? 'Take three photos' : 'Take the photo');
}); });
++ // Swipe the viewfinder sideways to change mode, as a phone camera does (11-07). Not from the filter strip or the
++ // top controls, which scroll or press on their own; the mode's name shows on the picture for a moment.
++ var stageEl = root.querySelector('.cm-stage');
++ var modeSay = root.querySelector('[data-cm-mode-say]');
++ var modeSayTimer = null;
++ function stepMode(step) {
++ var at = -1;
++ modeBtns.forEach(function (x, i) { if (x.getAttribute('aria-checked') === 'true') at = i; });
++ var next = modeBtns[at + step];
++ if (!next) return;
++ next.click();
++ if (!modeSay) return;
++ modeSay.textContent = next.textContent;
++ modeSay.classList.remove('is-on');
++ void modeSay.offsetWidth;
++ modeSay.classList.add('is-on');
++ clearTimeout(modeSayTimer);
++ modeSayTimer = setTimeout(function () { modeSay.classList.remove('is-on'); }, 900);
++ }
++ if (stageEl && modeBtns.length > 1) {
++ var swipe = null;
++ stageEl.addEventListener('pointerdown', function (e) {
++ swipe = (root.dataset.state === 'off' || (e.target.closest && e.target.closest('.cm-filters, .cm-top, .cm-off'))) ? null : { x: e.clientX, y: e.clientY, t: Date.now() };
++ });
++ stageEl.addEventListener('pointerup', function (e) {
++ if (!swipe) return;
++ var dx = e.clientX - swipe.x, dy = e.clientY - swipe.y, quick = Date.now() - swipe.t < 700;
++ swipe = null;
++ if (quick && Math.abs(dx) > 50 && Math.abs(dy) < Math.abs(dx) * 0.6) stepMode(dx < 0 ? 1 : -1);
++ });
++ stageEl.addEventListener('pointercancel', function () { swipe = null; });
++ }
burstBtn.addEventListener('click', burst);
if (gridBtn) gridBtn.addEventListener('click', function () {
if (!gridEl) return;
ddiff --git a/server/views/index.js b/server/views/index.js
iindex cd8bad3..07bf310 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -200,6 +200,7 @@ function app({ signedIn = false, photos = [] } = {}) {
<div class="cm-grid-overlay" data-cm-grid hidden aria-hidden="true"></div>
<div class="cm-flash" data-cm-flash aria-hidden="true"></div>
<div class="cm-count" data-cm-count aria-hidden="true"></div>
++<div class="cm-mode-say" data-cm-mode-say aria-hidden="true"></div>
<div class="cm-off" data-cm-off>
<span class="ov-icon" data-icon="cam" data-size="64" aria-hidden="true"></span>
<h1 class="cm-off-title">Camera</h1>
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex d211671..b3d8428 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -46,7 +46,7 @@ function jsonLd(html) {
const r = await t.get('/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
-- for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid']) assert.ok(r.text.includes(marker), marker);
++ for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid', 'data-cm-mode-say']) assert.ok(r.text.includes(marker), marker);
assert.ok(r.text.includes('Sign in'));
});