CI / Typecheck, test, build (pull_request) Successful in 12s
A TikTok post played perfectly and said nothing on an iPhone. The file was not the problem: every rendition TikTok offers for it carries a full AAC track, and the page as served measures loud audio in WebKit and Chromium alike, straight through the /m/ proxy. iOS is the problem. A video playing inline gets the "ambient" audio session, which the Ring/Silent switch mutes; only going fullscreen gets the sound back. Claiming "playback" says what is true of this page -- the sound is the point, not decoration -- and the switch stops applying. Declared only where there is a video, so an ordinary text post never claims it, and the session activates when something plays rather than on load, so it interrupts nothing on a page nobody presses play on. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01B3RUMiXa6eAW3okw9nB9NF
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
// Progressive enhancement only. Without this file the page still shows the
|
|
// media in a swipeable rail and the original URL as selectable text.
|
|
|
|
import { browsersFor, detectOs, openUrlFor } from './browsers.js';
|
|
|
|
const VIEW_KEY = 'antisocial:view';
|
|
const BROWSER_KEY = 'antisocial:browser';
|
|
|
|
function setupCopy() {
|
|
for (const button of document.querySelectorAll('.copy')) {
|
|
button.addEventListener('click', async () => {
|
|
const url = button.dataset.url;
|
|
if (!url) return;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
} catch {
|
|
// No clipboard permission, or an insecure context. Select the URL
|
|
// instead so one keystroke finishes the job.
|
|
const code = button.closest('.original')?.querySelector('code');
|
|
if (code) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(code);
|
|
const selection = getSelection();
|
|
selection?.removeAllRanges();
|
|
selection?.addRange(range);
|
|
}
|
|
return;
|
|
}
|
|
|
|
button.dataset.copied = 'true';
|
|
button.querySelector('.copy__idle')?.setAttribute('hidden', '');
|
|
button.querySelector('.copy__done')?.removeAttribute('hidden');
|
|
setTimeout(() => {
|
|
delete button.dataset.copied;
|
|
button.querySelector('.copy__idle')?.removeAttribute('hidden');
|
|
button.querySelector('.copy__done')?.setAttribute('hidden', '');
|
|
}, 1600);
|
|
});
|
|
}
|
|
}
|
|
|
|
function storedBrowser() {
|
|
try {
|
|
return localStorage.getItem(BROWSER_KEY) ?? 'default';
|
|
} catch {
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
// The markup carries the plain address, which is the one thing the button
|
|
// must not offer: followed in the browser the rewrite rules are installed
|
|
// in, it redirects straight back to this page. So the link ships hidden and
|
|
// is only revealed once a browser has been picked to hand it to. With no
|
|
// choice made -- or none this system has a scheme for -- the copy button
|
|
// and the URL below it are the whole of it.
|
|
function applyBrowser(id) {
|
|
const os = detectOs();
|
|
|
|
for (const link of document.querySelectorAll('.original__open')) {
|
|
link.dataset.original ??= link.getAttribute('href');
|
|
|
|
const href = openUrlFor(link.dataset.original, id, os);
|
|
link.href = href;
|
|
link.hidden = href === link.dataset.original;
|
|
}
|
|
}
|
|
|
|
// Only on the index page, and only with this file running: the choice is
|
|
// useless without the rewriting above, so the control ships hidden.
|
|
function setupBrowserPicker() {
|
|
const select = document.querySelector('.picker__select');
|
|
if (!select) return;
|
|
|
|
for (const browser of browsersFor(detectOs())) {
|
|
const option = document.createElement('option');
|
|
option.value = browser.id;
|
|
option.textContent = browser.label;
|
|
select.append(option);
|
|
}
|
|
|
|
// A stored id with no scheme on this system would leave the control
|
|
// showing nothing at all, so fall back rather than render a blank.
|
|
const stored = storedBrowser();
|
|
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
|
|
|
|
select.addEventListener('change', () => {
|
|
try {
|
|
localStorage.setItem(BROWSER_KEY, select.value);
|
|
} catch {
|
|
// Private browsing. The choice just won't survive the page.
|
|
}
|
|
applyBrowser(select.value);
|
|
});
|
|
|
|
select.closest('.picker').hidden = false;
|
|
}
|
|
|
|
// One per rail: a post can carry several -- a thread of them, or a post and
|
|
// the post it quotes -- and wiring only the first leaves the rest inert.
|
|
function setupMedia(media) {
|
|
const rail = media.querySelector('.media__rail');
|
|
const items = [...media.querySelectorAll('.item')];
|
|
const at = media.querySelector('.media__at');
|
|
const buttons = [...media.querySelectorAll('.media__view')];
|
|
if (!rail || items.length < 2) return;
|
|
|
|
function applyView(view, remember) {
|
|
media.dataset.view = view;
|
|
for (const button of buttons) {
|
|
button.setAttribute('aria-pressed', String(button.dataset.view === view));
|
|
}
|
|
if (remember) {
|
|
try {
|
|
localStorage.setItem(VIEW_KEY, view);
|
|
} catch {
|
|
// Private browsing. The choice just won't survive the page.
|
|
}
|
|
}
|
|
}
|
|
|
|
let stored = null;
|
|
try {
|
|
stored = localStorage.getItem(VIEW_KEY);
|
|
} catch {
|
|
stored = null;
|
|
}
|
|
applyView(stored === 'grid' ? 'grid' : 'swipe', false);
|
|
|
|
for (const button of buttons) {
|
|
button.addEventListener('click', () => applyView(button.dataset.view, true));
|
|
}
|
|
|
|
// Tapping a thumbnail goes back to the swiper, at that image.
|
|
for (const item of items) {
|
|
item.addEventListener('click', (event) => {
|
|
if (media.dataset.view !== 'grid') return;
|
|
if (event.target.closest('video')) return;
|
|
applyView('swipe', true);
|
|
item.scrollIntoView({ inline: 'center', block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
// Keep the counter honest as the rail scrolls.
|
|
if (at) {
|
|
let frame = 0;
|
|
rail.addEventListener('scroll', () => {
|
|
if (frame) return;
|
|
frame = requestAnimationFrame(() => {
|
|
frame = 0;
|
|
const index = Math.round(rail.scrollLeft / rail.clientWidth);
|
|
at.textContent = String(Math.min(items.length, Math.max(1, index + 1)));
|
|
});
|
|
}, { passive: true });
|
|
}
|
|
}
|
|
|
|
// On iOS a video playing inline gets the "ambient" audio session, which the
|
|
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
|
|
// and the only way to hear it is to go fullscreen. Claiming "playback" says
|
|
// what is true here: the sound is the point, not decoration. The session is
|
|
// not activated until something actually plays, so declaring it up front
|
|
// interrupts nothing; it is declared only on a page that has a video so an
|
|
// ordinary text post never claims it at all.
|
|
function setupAudioSession() {
|
|
if (!('audioSession' in navigator)) return;
|
|
if (!document.querySelector('video')) return;
|
|
navigator.audioSession.type = 'playback';
|
|
}
|
|
|
|
// Every comment is a <details open>, so folding one already works with this
|
|
// file missing. All this adds is doing the whole page at once, which is why
|
|
// the button ships hidden and is only revealed here.
|
|
function setupComments() {
|
|
const toggle = document.querySelector('.thread__toggle');
|
|
if (!toggle) return;
|
|
|
|
const all = [...document.querySelectorAll('details.c')];
|
|
if (all.length === 0) return;
|
|
toggle.hidden = false;
|
|
|
|
toggle.addEventListener('click', () => {
|
|
const collapsing = toggle.dataset.collapsed !== 'true';
|
|
for (const comment of all) comment.open = !collapsing;
|
|
toggle.dataset.collapsed = String(collapsing);
|
|
toggle.textContent = collapsing ? 'Expand all' : 'Collapse all';
|
|
});
|
|
|
|
// Reopening one by hand means the page is no longer collapsed, so the
|
|
// button should offer the other thing.
|
|
for (const comment of all) {
|
|
comment.addEventListener('toggle', () => {
|
|
if (comment.open && toggle.dataset.collapsed === 'true') {
|
|
toggle.dataset.collapsed = 'false';
|
|
toggle.textContent = 'Collapse all';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setupCopy();
|
|
applyBrowser(storedBrowser());
|
|
setupBrowserPicker();
|
|
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
|
setupAudioSession();
|
|
setupComments();
|
|
|
|
// --- Verification puzzles -------------------------------------------------
|
|
//
|
|
// The puzzle is rendered in the browser on the server, not this one. So the
|
|
// page shows a live screenshot of it and forwards pointer events back to be
|
|
// replayed. Solving it leaves the cookie in that browser, and the post then
|
|
// loads normally.
|
|
|
|
function setupChallenge() {
|
|
const article = document.querySelector('[data-challenge]');
|
|
if (!article) return;
|
|
|
|
const id = article.dataset.challenge;
|
|
const frame = document.getElementById('frame');
|
|
const status = document.getElementById('status');
|
|
const stage = document.getElementById('stage');
|
|
const giveUp = document.getElementById('give-up');
|
|
|
|
let box = parseBox(stage.dataset.box);
|
|
let dragging = false;
|
|
let lastSent = 0;
|
|
let stopped = false;
|
|
|
|
function parseBox(value) {
|
|
const parts = (value || '').split(',').map(Number);
|
|
return parts.length === 4 && parts.every(Number.isFinite)
|
|
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
|
|
: null;
|
|
}
|
|
|
|
// Self-paced: the next frame is only asked for once the last one painted,
|
|
// so a slow screenshot slows the stream instead of queueing behind itself.
|
|
function pump() {
|
|
if (stopped) return;
|
|
frame.src = `/challenge/${id}/frame?t=${Date.now()}`;
|
|
}
|
|
frame.addEventListener('load', () => {
|
|
status.hidden = true;
|
|
setTimeout(pump, dragging ? 60 : 220);
|
|
});
|
|
frame.addEventListener('error', () => {
|
|
if (!stopped) setTimeout(pump, 1000);
|
|
});
|
|
pump();
|
|
|
|
function toPageCoords(event) {
|
|
const rect = frame.getBoundingClientRect();
|
|
if (!rect.width || !rect.height) return null;
|
|
const fx = (event.clientX - rect.left) / rect.width;
|
|
const fy = (event.clientY - rect.top) / rect.height;
|
|
// Without a clip box the screenshot is the whole viewport, whose size
|
|
// the server knows; fall back to the image's own pixel dimensions.
|
|
const region = box ?? { x: 0, y: 0, width: frame.naturalWidth / 2, height: frame.naturalHeight / 2 };
|
|
return { x: region.x + fx * region.width, y: region.y + fy * region.height };
|
|
}
|
|
|
|
async function send(type, point) {
|
|
try {
|
|
const response = await fetch(`/challenge/${id}/input`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ type, x: point.x, y: point.y }),
|
|
});
|
|
if (response.status === 410) finish();
|
|
} catch {
|
|
// A dropped event during a drag is survivable; the next one follows.
|
|
}
|
|
}
|
|
|
|
frame.addEventListener('pointerdown', (event) => {
|
|
const point = toPageCoords(event);
|
|
if (!point) return;
|
|
event.preventDefault();
|
|
dragging = true;
|
|
frame.setPointerCapture(event.pointerId);
|
|
void send('down', point);
|
|
});
|
|
|
|
frame.addEventListener('pointermove', (event) => {
|
|
if (!dragging) return;
|
|
const now = Date.now();
|
|
if (now - lastSent < 25) return;
|
|
lastSent = now;
|
|
const point = toPageCoords(event);
|
|
if (point) void send('move', point);
|
|
});
|
|
|
|
for (const type of ['pointerup', 'pointercancel']) {
|
|
frame.addEventListener(type, async (event) => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
const point = toPageCoords(event);
|
|
if (point) await send('up', point);
|
|
status.hidden = false;
|
|
status.textContent = 'Checking…';
|
|
void poll();
|
|
});
|
|
}
|
|
|
|
async function poll() {
|
|
for (let attempt = 0; attempt < 12 && !stopped; attempt += 1) {
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
try {
|
|
const response = await fetch(`/challenge/${id}/state`);
|
|
if (response.status === 410) return finish();
|
|
const state = await response.json();
|
|
if (state.box) box = state.box;
|
|
if (state.solved) return finish();
|
|
} catch {
|
|
// Keep trying; the page may be mid-navigation.
|
|
}
|
|
}
|
|
status.textContent = 'Still there — have another go.';
|
|
}
|
|
|
|
async function finish() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
status.hidden = false;
|
|
status.textContent = 'Got it. Loading the post…';
|
|
try {
|
|
const response = await fetch(`/challenge/${id}/done`, { method: 'POST' });
|
|
const { returnTo } = await response.json();
|
|
location.href = returnTo || '/';
|
|
} catch {
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
giveUp?.addEventListener('click', () => {
|
|
stopped = true;
|
|
void fetch(`/challenge/${id}/done`, { method: 'POST' }).finally(() => {
|
|
article.querySelector('.original__url')?.scrollIntoView({ block: 'center' });
|
|
status.textContent = 'Gave up. The original link is below.';
|
|
status.hidden = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
setupChallenge();
|