Files
rm-circle-team-router/public/tool-video.js
T
martbost 1896b707f8 Tool walkthrough videos: collapsible player + volume-served /tv/ route
Twelve narrated walkthroughs, one per Suite tool, in Marty's cloned voice.
No screen recording involved: Playwright drives and records the real UI,
ElevenLabs narrates, ffmpeg muxes. Pipeline lives outside this repo at
../video-pipeline.

Placement (Marty's call): TOP of each tool page but COLLAPSED — a one-line
"Watch the walkthrough" bar that auto-opens on a member's first visit to that
specific tool and stays shut after. A video in a separate library never gets
watched; an embedded player pushes the tool below the fold on mobile for every
returning member. First-visit autoplay is muted, so it is never a surprise
noise, and every localStorage access is guarded because it throws outright in
private windows.

Served from the data volume via /tv/<slug>.mp4, NOT from public/. That
directory is already 199MB and another 33MB of MP4 would ride along on every
deploy forever; this way a video can also be re-cut and dropped in without a
rebuild. The route implements HTTP range requests — without them the scrub bar
renders but cannot seek, which looks broken in a way people rarely report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 14:14:21 -05:00

113 lines
4.6 KiB
JavaScript

// Collapsible walkthrough player for Suite tool pages.
//
// Placement reasoning (Marty's call): the video belongs at the TOP of the tool,
// because a video in a separate library never gets watched — but it must not be
// an embedded player, because after the first week most visits are returning
// members who came to USE the tool, and a player pushes the tool below the fold
// on mobile every single time.
//
// So: a one-line bar that expands inline, auto-opened on the member's FIRST
// visit to that specific tool and collapsed on every visit after. The state is
// per-tool, per-browser. localStorage can throw outright in some contexts
// (private windows, blocked site data), so every access is guarded and the bar
// falls back to "collapsed but present" rather than breaking the page.
(function () {
'use strict';
var el = document.currentScript;
var SRC = el && el.getAttribute('data-video');
var TITLE = (el && el.getAttribute('data-title')) || 'Watch the walkthrough';
var KEY = 'rmc.tv.' + ((el && el.getAttribute('data-key')) || (location.pathname.replace(/\W+/g, '') || 'tool'));
var SECS = (el && el.getAttribute('data-secs')) || '';
if (!SRC) return;
function seen() { try { return localStorage.getItem(KEY) === '1'; } catch (e) { return true; } }
function markSeen() { try { localStorage.setItem(KEY, '1'); } catch (e) {} }
function build() {
var host = document.querySelector('main') || document.body;
var wrap = document.createElement('div');
wrap.className = 'tv-wrap';
var bar = document.createElement('button');
bar.type = 'button';
bar.className = 'tv-bar';
bar.setAttribute('aria-expanded', 'false');
bar.innerHTML = '<span class="tv-play">▶</span><span class="tv-t">' + TITLE +
(SECS ? ' <span class="tv-secs">' + SECS + '</span>' : '') + '</span><span class="tv-chev">⌄</span>';
var body = document.createElement('div');
body.className = 'tv-body';
body.hidden = true;
var vid = document.createElement('video');
vid.controls = true;
vid.preload = 'none';
vid.playsInline = true;
vid.src = SRC;
body.appendChild(vid);
function open(autoplay) {
body.hidden = false;
wrap.classList.add('on');
bar.setAttribute('aria-expanded', 'true');
if (autoplay) {
// Muted so a first-visit autoplay is never a surprise noise; the member
// unmutes if they want it. Browsers block unmuted autoplay anyway.
vid.muted = true;
vid.play().catch(function () {});
}
}
function close() {
body.hidden = true;
wrap.classList.remove('on');
bar.setAttribute('aria-expanded', 'false');
vid.pause();
}
bar.addEventListener('click', function () {
if (body.hidden) { open(false); markSeen(); }
else close();
});
wrap.appendChild(bar);
wrap.appendChild(body);
// Sit directly under the page heading if there is one, otherwise at the top.
var h1 = host.querySelector('h1');
var lede = h1 && h1.nextElementSibling && /lede|sub/.test(h1.nextElementSibling.className || '')
? h1.nextElementSibling : h1;
if (lede && lede.parentNode) lede.parentNode.insertBefore(wrap, lede.nextSibling);
else host.insertBefore(wrap, host.firstChild);
if (!seen()) { open(true); markSeen(); }
}
function style() {
if (document.getElementById('tv-style')) return;
var s = document.createElement('style');
s.id = 'tv-style';
s.textContent = [
'.tv-wrap{margin:0 0 18px}',
'.tv-bar{display:flex;align-items:center;gap:10px;width:100%;text-align:left;cursor:pointer;',
' background:rgba(212,175,55,.08);border:1px solid var(--gold,#d4af37);border-radius:12px;',
' color:var(--text,#e8eef4);padding:11px 15px;font:inherit;font-size:14.5px;font-weight:700}',
'.tv-bar:hover{background:rgba(212,175,55,.14)}',
'.tv-bar:focus-visible{outline:2px solid var(--teal,#4ed6cb);outline-offset:2px}',
'.tv-play{color:var(--gold,#d4af37);font-size:12px}',
'.tv-t{flex:1}',
'.tv-secs{color:var(--muted,#8fa6bb);font-weight:600;font-size:12.5px}',
'.tv-chev{color:var(--muted,#8fa6bb);transition:transform .18s}',
'.tv-wrap.on .tv-chev{transform:rotate(180deg)}',
'.tv-body{margin-top:10px;border:1px solid var(--line,#24425d);border-radius:12px;overflow:hidden;line-height:0}',
'.tv-body video{width:100%;height:auto;display:block;background:#000}',
'@media (prefers-reduced-motion:reduce){.tv-chev{transition:none}}'
].join('');
document.head.appendChild(s);
}
function go() { style(); build(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go);
else go();
})();