Files
antisocial/public/app.js
T
thatguygriffandClaude Opus 5 4c78ce8a27
CI / Typecheck, test, build (pull_request) Successful in 12s
Ask iOS for the playback audio session on a page with a video
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
2026-09-02 21:06:12 -03:00

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();